This commit is contained in:
pengxiaolong
2026-01-15 13:01:31 +08:00
parent c1a80dd4cf
commit a1fbc6417f
219 changed files with 25793 additions and 951 deletions

View File

@@ -0,0 +1,12 @@
package com.example.myapplication
import android.content.Context
object AppContext {
lateinit var context: Context
private set
fun init(ctx: Context) {
context = ctx.applicationContext
}
}

View File

@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import android.widget.ImageView
import android.text.TextWatcher
import android.text.Editable
import com.example.myapplication.network.BehaviorReporter
class GuideActivity : AppCompatActivity() {
@@ -91,12 +92,22 @@ class GuideActivity : AppCompatActivity() {
}
// 情话复制
findViewById<TextView>(R.id.love_words_1).setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "guide",
"element_id" to "copy_example_1",
)
val text = it as TextView
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("text", text.text))
Toast.makeText(this, "Copy successfully", Toast.LENGTH_SHORT).show()
}
findViewById<TextView>(R.id.love_words_2).setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "guide",
"element_id" to "copy_example_2",
)
val text = it as TextView
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("text", text.text))

View File

@@ -12,6 +12,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.network.BehaviorReporter
class ImeGuideActivity : AppCompatActivity() {
@@ -33,6 +34,11 @@ class ImeGuideActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ime_guide)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_permission_guide",
)
Log.d(TAG, "onCreate")
btnEnable = findViewById(R.id.enabled) // btn启用输入法
@@ -69,6 +75,15 @@ class ImeGuideActivity : AppCompatActivity() {
}
}
override fun onBackPressed() {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_permission_guide",
"element_id" to "close_btn",
)
super.onBackPressed()
}
private fun registerImeObserver() {
if (imeObserver != null) return
@@ -153,6 +168,11 @@ class ImeGuideActivity : AppCompatActivity() {
selectLayout.setOnClickListener {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showInputMethodPicker()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_permission_guide",
"element_id" to "open_settings_btn",
)
}
}
@@ -192,6 +212,11 @@ class ImeGuideActivity : AppCompatActivity() {
selectText.setTextColor(Color.parseColor("#A1A1A1"))
step1.text = "Completed"
step2.text = "You have completed the relevant Settings"
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_permission_guide",
"element_id" to "close_btn",
)
Toast.makeText(this, "The input method is all set!", Toast.LENGTH_SHORT).show()
try {
startActivity(Intent(this, GuideActivity::class.java))

View File

@@ -1,6 +1,7 @@
package com.example.myapplication
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -10,6 +11,7 @@ import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.coroutines.flow.collectLatest
@@ -59,6 +61,45 @@ class MainActivity : AppCompatActivity() {
private val globalNavController: NavController
get() = globalHost.navController
// =======================
// 全局路由埋点:新增字段
// =======================
private val ROUTE_TAG = "RouteReport"
private var lastHomeDestIdForReport: Int? = null
private var lastShopDestIdForReport: Int? = null
private var lastMineDestIdForReport: Int? = null
private var lastGlobalDestIdForReport: Int? = null
// 统一 listener方便 add/remove
private val homeRouteListener =
NavController.OnDestinationChangedListener { _, dest, _ ->
if (lastHomeDestIdForReport == dest.id) return@OnDestinationChangedListener
lastHomeDestIdForReport = dest.id
reportPageView(source = "home_tab", destId = dest.id)
}
private val shopRouteListener =
NavController.OnDestinationChangedListener { _, dest, _ ->
if (lastShopDestIdForReport == dest.id) return@OnDestinationChangedListener
lastShopDestIdForReport = dest.id
reportPageView(source = "shop_tab", destId = dest.id)
}
private val mineRouteListener =
NavController.OnDestinationChangedListener { _, dest, _ ->
if (lastMineDestIdForReport == dest.id) return@OnDestinationChangedListener
lastMineDestIdForReport = dest.id
reportPageView(source = "mine_tab", destId = dest.id)
}
private val globalRouteListener =
NavController.OnDestinationChangedListener { _, dest, _ ->
if (lastGlobalDestIdForReport == dest.id) return@OnDestinationChangedListener
lastGlobalDestIdForReport = dest.id
reportPageView(source = "global_overlay", destId = dest.id)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -105,9 +146,11 @@ class MainActivity : AppCompatActivity() {
openGlobal(R.id.loginFragment)
}
}
is AuthEvent.GenericError -> {
Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show()
Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_LONG).show()
}
// 登录成功事件处理
is AuthEvent.LoginSuccess -> {
// 关闭 global overlay回到 empty
@@ -123,24 +166,46 @@ class MainActivity : AppCompatActivity() {
}
}
pendingTabAfterLogin = null
// 处理intent跳转目标页
if (pendingNavigationAfterLogin == "recharge_fragment") {
openGlobal(R.id.rechargeFragment)
pendingNavigationAfterLogin = null
}
// ✅ 登录成功后也刷新一次
bottomNav.post { updateBottomNavVisibility() }
}
// 登出事件处理
is AuthEvent.Logout -> {
pendingTabAfterLogin = event.returnTabTag
// ✅ 用户没登录按返回,应回首页,所以先切到首页
switchTab(TAB_HOME, force = true)
bottomNav.post {
bottomNav.selectedItemId = R.id.home_graph
openGlobal(R.id.loginFragment) // ✅ 退出登录后立刻打开登录页
}
}
// 打开全局页面事件处理
is AuthEvent.OpenGlobalPage -> {
// 打开指定的全局页面
openGlobal(event.destinationId, event.bundle)
}
is AuthEvent.UserUpdated -> {
// 不需要处理
}
is AuthEvent.CharacterDeleted -> {
// 不需要处理
}
is AuthEvent.CharacterAdded -> {
// 不需要处理由HomeFragment处理
}
}
}
}
@@ -158,9 +223,27 @@ class MainActivity : AppCompatActivity() {
TAB_MINE -> R.id.mine_graph
else -> R.id.home_graph
}
updateBottomNavVisibility()
}
}
override fun onResume() {
super.onResume()
// ✅ 最终兜底:从后台回来 / 某些场景没触发 listener也能恢复底栏
bottomNav.post { updateBottomNavVisibility() }
}
override fun onDestroy() {
// ✅ 防泄漏移除路由监听Activity 销毁时)
runCatching {
homeHost.navController.removeOnDestinationChangedListener(homeRouteListener)
shopHost.navController.removeOnDestinationChangedListener(shopRouteListener)
mineHost.navController.removeOnDestinationChangedListener(mineRouteListener)
globalHost.navController.removeOnDestinationChangedListener(globalRouteListener)
}
super.onDestroy()
}
private fun initHosts() {
val fm = supportFragmentManager
@@ -196,22 +279,59 @@ class MainActivity : AppCompatActivity() {
// 绑定全局导航可见性监听
bindGlobalVisibility()
// 绑定底部导航栏可见性监听
bindBottomNavVisibilityForTabs()
// ✅ 全局路由埋点监听(每次导航变化上报)
bindGlobalRouteReporting()
bottomNav.post { updateBottomNavVisibility() }
}
/**
* 这些页面需要隐藏底部导航栏:你按需加/减
*/
private fun shouldHideBottomNav(destId: Int): Boolean {
return destId in setOf(
R.id.searchFragment,
R.id.searchResultFragment,
R.id.MySkin,
R.id.notificationFragment,
R.id.feedbackFragment,
R.id.MyKeyboard,
R.id.PersonalSettings,
)
}
/**
* ✅ 统一底栏显隐逻辑:任何地方状态变化都调用它
*/
private fun updateBottomNavVisibility() {
// ✅ 只要 global overlay 不在 empty底栏必须隐藏用 NavController 判断,别用 View.visibility
if (isGlobalVisible()) {
bottomNav.visibility = View.GONE
return
}
// 否则按“当前可见 tab 的当前目的地”判断
val destId = currentTabNavController.currentDestination?.id
bottomNav.visibility =
if (destId != null && shouldHideBottomNav(destId)) View.GONE else View.VISIBLE
}
private fun bindGlobalVisibility() {
globalNavController.addOnDestinationChangedListener { _, dest, _ ->
val isEmpty = dest.id == R.id.globalEmptyFragment
findViewById<View>(R.id.global_container).visibility =
if (isEmpty) View.GONE else View.VISIBLE
bottomNav.visibility =
if (isEmpty) View.VISIBLE else View.GONE
// ✅ 只在"刚从某个全局页关闭回 empty"时触发回退逻辑
val justClosedOverlay = (dest.id == R.id.globalEmptyFragment && lastGlobalDestId != R.id.globalEmptyFragment)
findViewById<View>(R.id.global_container).visibility =
if (isEmpty) View.GONE else View.VISIBLE
// ✅ 底栏统一走 update
updateBottomNavVisibility()
val justClosedOverlay =
(dest.id == R.id.globalEmptyFragment && lastGlobalDestId != R.id.globalEmptyFragment)
lastGlobalDestId = dest.id
if (justClosedOverlay) {
@@ -220,16 +340,17 @@ class MainActivity : AppCompatActivity() {
TAB_MINE -> R.id.mine_graph
else -> R.id.home_graph
}
// 未登录且当前处在受保护tab强制回首页
if (!isLoggedIn() && currentTabGraphId in protectedTabs) {
switchTab(TAB_HOME, force = true)
bottomNav.selectedItemId = R.id.home_graph
}
// ✅ 只有"没登录就关闭登录页"才清 pending
if (!isLoggedIn()) {
pendingTabAfterLogin = null
}
bottomNav.post { updateBottomNavVisibility() }
}
}
}
@@ -238,7 +359,7 @@ class MainActivity : AppCompatActivity() {
if (!force && targetTag == currentTabTag) return
val fm = supportFragmentManager
if (fm.isStateSaved) return // ✅ 防崩stateSaved 时不做事务
if (fm.isStateSaved) return
currentTabTag = targetTag
@@ -255,57 +376,80 @@ class MainActivity : AppCompatActivity() {
}
}
.commit()
// ✅ 关键hide/show 切 tab 不会触发 destinationChanged所以手动刷新
bottomNav.post { updateBottomNavVisibility() }
// ✅ 新增:切 tab 后补一次路由上报(不改变其它逻辑)
if (!force) {
currentTabNavController.currentDestination?.id?.let { destId ->
reportPageView(source = "switch_tab", destId = destId)
}
}
}
/** 打开全局页login/recharge等 */
private fun openGlobal(destId: Int, bundle: Bundle? = null) {
val fm = supportFragmentManager
if (fm.isStateSaved) return // ✅ 防崩
if (fm.isStateSaved) return
try {
if (bundle != null) {
globalNavController.navigate(destId, bundle)
} else {
globalNavController.navigate(destId)
}
if (bundle != null) globalNavController.navigate(destId, bundle)
else globalNavController.navigate(destId)
} catch (e: IllegalArgumentException) {
// 可选:防止偶发重复 navigate 崩溃
e.printStackTrace()
}
bottomNav.post { updateBottomNavVisibility() }
}
/** 关闭全局页pop到 empty */
/** Tab 内页面变化时刷新底栏显隐 */
private fun bindBottomNavVisibilityForTabs() {
fun shouldHideBottomNav(destId: Int): Boolean {
return destId in setOf(
R.id.searchFragment,
R.id.searchResultFragment,
R.id.MySkin
// 你还有其他需要全屏的页,也加在这里
)
val listener = NavController.OnDestinationChangedListener { _, _, _ ->
updateBottomNavVisibility()
}
val listener = NavController.OnDestinationChangedListener { _, dest, _ ->
// 只要 global overlay 打开了,仍然以 overlay 为准(你已有逻辑)
if (isGlobalVisible()) return@OnDestinationChangedListener
bottomNav.visibility = if (shouldHideBottomNav(dest.id)) View.GONE else View.VISIBLE
}
homeHost.navController.addOnDestinationChangedListener(listener)
shopHost.navController.addOnDestinationChangedListener(listener)
mineHost.navController.addOnDestinationChangedListener(listener)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home",
)
}
// ✅ 绑定全局路由埋点(四个 NavController
private fun bindGlobalRouteReporting() {
homeHost.navController.addOnDestinationChangedListener(homeRouteListener)
shopHost.navController.addOnDestinationChangedListener(shopRouteListener)
mineHost.navController.addOnDestinationChangedListener(mineRouteListener)
globalHost.navController.addOnDestinationChangedListener(globalRouteListener)
// ✅ 删除:初始化手动上报(否则启动时会重复上报)
// runCatching {
// currentTabNavController.currentDestination?.id?.let { reportPageView("init_current_tab", it) }
// globalNavController.currentDestination?.id?.let { reportPageView("init_global", it) }
// }
}
private fun closeGlobalIfPossible(): Boolean {
if (!isGlobalVisible()) return false
val popped = globalNavController.popBackStack()
val stillVisible = globalNavController.currentDestination?.id != R.id.globalEmptyFragment
return popped || stillVisible
// ✅ pop 后刷新一次注意currentDestination 可能要等一帧才更新,所以 post
bottomNav.post { updateBottomNavVisibility() }
// popped = true 表示确实 pop 了;即使 popped=false 也可能已经在 empty 了
return popped || !isGlobalVisible()
}
/**
* ✅ 改这里:不要再用 View.visibility 判断 overlay
* 以 NavController 的目的地为准
*/
private fun isGlobalVisible(): Boolean {
return findViewById<View>(R.id.global_container).visibility == View.VISIBLE
return globalNavController.currentDestination?.id != R.id.globalEmptyFragment
}
private fun setupBackPress() {
@@ -316,14 +460,15 @@ class MainActivity : AppCompatActivity() {
// 2) 再 pop 当前tab
val popped = currentTabNavController.popBackStack()
if (popped) return
if (popped) {
bottomNav.post { updateBottomNavVisibility() }
return
}
// 3) 当前tab到根了如果不是home切回home否则退出
if (currentTabTag != TAB_HOME) {
bottomNav.post {
bottomNav.selectedItemId = R.id.home_graph
}
switchTab(TAB_HOME)
if (currentTabTag != TAB_HOME) {
bottomNav.post { bottomNav.selectedItemId = R.id.home_graph }
switchTab(TAB_HOME)
} else {
finish()
}
@@ -331,17 +476,25 @@ class MainActivity : AppCompatActivity() {
})
}
private var pendingNavigationAfterLogin: String? = null
private fun handleNavigationFromIntent() {
val navigateTo = intent.getStringExtra("navigate_to")
if (navigateTo == "recharge_fragment") {
bottomNav.post {
if (!isLoggedIn()) {
pendingNavigationAfterLogin = navigateTo
openGlobal(R.id.loginFragment)
return@post
return@post
}
openGlobal(R.id.rechargeFragment)
}
}
if (navigateTo == "login_fragment") {
bottomNav.post {
openGlobal(R.id.loginFragment)
}
}
}
private fun isLoggedIn(): Boolean {
@@ -352,4 +505,68 @@ class MainActivity : AppCompatActivity() {
outState.putString("current_tab_tag", currentTabTag)
super.onSaveInstanceState(outState)
}
}
// =======================
// 全局路由埋点page_id 映射 + 上报
// =======================
private fun pageIdForDest(destId: Int): String {
return when (destId) {
/** ==================== 首页 Home ==================== */
R.id.homeFragment -> "home_main" // 首页-主页面
R.id.keyboardDetailFragment -> "skin_detail" // 键盘详情页
R.id.MyKeyboard -> "my_keyboard" // 键盘设置
/** ==================== 商城 Shop ==================== */
R.id.shopFragment -> "shop" // 商城首页
R.id.searchFragment -> "search" // 搜索页
R.id.searchResultFragment -> "search_result" // 搜索结果页
R.id.MySkin -> "my_skin" // 我的皮肤
/** ==================== 我的 Mine ==================== */
R.id.mineFragment -> "my" // 我的-首页
R.id.PersonalSettings -> "person_info" // 个人设置
R.id.notificationFragment -> "notice" // 消息通知
R.id.feedbackFragment -> "feedback" // 意见反馈
R.id.consumptionRecordFragment -> "consumption_record" // 消费记录
/** ==================== 登录 & 注册 ==================== */
R.id.loginFragment -> "login" // 登录页
R.id.registerFragment -> "register_email" // 注册页
R.id.registerVerifyFragment -> "register_verify_email" // 注册验证码
R.id.forgetPasswordEmailFragment -> "forgot_password_email" // 忘记密码-邮箱
R.id.forgetPasswordVerifyFragment -> "forgot_password_verify" // 忘记密码-验证码
R.id.forgetPasswordResetFragment -> "forgot_password_newpwd" // 忘记密码-重置密码
/** ==================== 充值相关 ==================== */
R.id.rechargeFragment -> "vip_pay" // 充值首页
R.id.goldCoinRechargeFragment -> "points_recharge" // 金币充值
/** ==================== 全局 / 占位 ==================== */
R.id.globalEmptyFragment -> "global_empty" // 全局占位页(兜底)
/** ==================== 兜底处理 ==================== */
else -> "unknown_$destId" // 未配置的页面,方便排查遗漏
}
}
private fun reportPageView(source: String, destId: Int) {
val pageId = pageIdForDest(destId)
if (destId == R.id.globalEmptyFragment) return
if (destId == R.id.loginFragment || destId == R.id.registerFragment){
BehaviorReporter.report(
isNewUser = true,
"page_id" to pageId,
)
return
}
Log.d(ROUTE_TAG, "route: source=$source destId=$destId page_id=$pageId")
BehaviorReporter.report(
isNewUser = false,
"page_id" to pageId,
)
}
}

View File

@@ -2,13 +2,16 @@ package com.example.myapplication
import android.app.Application
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.NetworkClient
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
// 初始化 RetrofitClient传入 ApplicationContext
AppContext.init(this) // ✅ 新增:全局 Application Context
RetrofitClient.init(this)
NetworkClient.init(this) // ✅ SSE 用(带 token/签名拦截器)
}
}

View File

@@ -46,6 +46,8 @@ import android.graphics.drawable.GradientDrawable
import kotlin.math.abs
import java.text.BreakIterator
import android.widget.EditText
import android.content.res.Configuration
import androidx.constraintlayout.widget.ConstraintLayout
class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
@@ -264,23 +266,6 @@ 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()
// }
// }
// }
}
// 输入法状态变化
@@ -319,6 +304,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
val keyboard = ensureMainKeyboard()
currentKeyboardView = keyboard.rootView
mainKeyboardView = keyboard.rootView
(keyboard.rootView.parent as? ViewGroup)?.removeView(keyboard.rootView)
return keyboard.rootView
}
@@ -405,7 +391,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 初始状态:隐藏联想条,显示控制面板
mainKeyboardView
?.findViewById<HorizontalScrollView>(R.id.completion_scroll)
?.findViewById<ConstraintLayout>(R.id.completion_scroll)
?.visibility = View.GONE
mainKeyboardView
@@ -604,15 +590,19 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
if (aiKeyboard == null) {
aiKeyboard = AiKeyboard(this)
aiKeyboardView = aiKeyboard!!.rootView
// ✅ AI 键盘 Delete 按钮也绑定“长按连删 + 上滑清空”
val delId = resources.getIdentifier("keyboard_button_Delete", "id", packageName)
aiKeyboardView?.findViewById<View?>(delId)?.let { attachRepeatDeleteInternal(it) }
}
return aiKeyboard!!
}
override fun showMainKeyboard() {
clearEditorState()
val kb = ensureMainKeyboard()
currentKeyboardView = kb.rootView
setInputView(kb.rootView)
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -620,7 +610,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureNumberKeyboard()
currentKeyboardView = kb.rootView
setInputView(kb.rootView)
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -628,7 +618,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureSymbolKeyboard()
currentKeyboardView = kb.rootView
setInputView(kb.rootView)
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -636,18 +626,48 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureAiKeyboard()
currentKeyboardView = kb.rootView
setInputView(kb.rootView)
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
kb.refreshPersonas()
}
override fun showEmojiKeyboard() {
clearEditorState()
val kb = ensureEmojiKeyboard()
currentKeyboardView = kb.rootView
setInputView(kb.rootView)
setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
override fun associateClose() {
clearEditorState()
val kb = ensureEmojiKeyboard()
}
override fun onConfigurationChanged(newConfig: Configuration) {
// 先清理缓存,避免复用旧 View
currentKeyboardView = null
mainKeyboardView = null
numberKeyboardView = null
symbolKeyboardView = null
aiKeyboardView = null
emojiKeyboardView = null
mainKeyboard = null
numberKeyboard = null
symbolKeyboard = null
aiKeyboard = null
emojiKeyboard = null
super.onConfigurationChanged(newConfig)
}
private fun setInputViewSafely(v: View) {
(v.parent as? ViewGroup)?.removeView(v)
super.setInputView(v)
}
// Emoji 键盘
private fun ensureEmojiKeyboard(): com.example.myapplication.keyboard.EmojiKeyboard {
if (emojiKeyboard == null) {
@@ -943,7 +963,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 新增:联想滚动条 & 控制面板
val completionScroll =
mainKeyboardView?.findViewById<HorizontalScrollView>(R.id.completion_scroll)
mainKeyboardView?.findViewById<ConstraintLayout>(R.id.completion_scroll)
val controlLayout =
mainKeyboardView?.findViewById<LinearLayout>(R.id.control_layout)
@@ -1006,7 +1026,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 自动滚回到最左边
private fun scrollSuggestionsToStart() {
val sv = mainKeyboardView?.findViewById<HorizontalScrollView>(R.id.completion_scroll)
val sv = mainKeyboardView?.findViewById<HorizontalScrollView>(R.id.completion_HorizontalScrollView)
sv?.post { sv.fullScroll(View.FOCUS_LEFT) }
}
@@ -1402,7 +1422,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 4. UI联想条隐藏 & 控制面板显示
mainHandler.post {
val completionScroll =
mainKeyboardView?.findViewById<HorizontalScrollView>(R.id.completion_scroll)
mainKeyboardView?.findViewById<ConstraintLayout>(R.id.completion_scroll)
val controlLayout =
mainKeyboardView?.findViewById<LinearLayout>(R.id.control_layout)

View File

@@ -3,16 +3,64 @@ package com.example.myapplication
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import android.view.ViewGroup
import android.widget.Toast
import com.example.myapplication.ui.common.LoadingOverlay
import android.os.Handler
import android.os.Looper
class OnboardingActivity : AppCompatActivity() {
private var selectedGender = -1 // 0: male, 1: female, 2: third
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_onboarding)
val btnStart = findViewById<TextView>(R.id.tv_skip)
val maleLayout = findViewById<LinearLayout>(R.id.male_layout)
val femaleLayout = findViewById<LinearLayout>(R.id.female_layout)
val thirdLayout = findViewById<LinearLayout>(R.id.third_layout)
val tvDescription = findViewById<TextView>(R.id.tv_description)
// 设置性别选择点击事件
maleLayout.setOnClickListener {
resetAllLayouts()
maleLayout.setBackgroundResource(R.drawable.gender_background_select)
selectedGender = 0
}
femaleLayout.setOnClickListener {
resetAllLayouts()
femaleLayout.setBackgroundResource(R.drawable.gender_background_select)
selectedGender = 1
}
thirdLayout.setOnClickListener {
resetAllLayouts()
thirdLayout.setBackgroundResource(R.drawable.gender_background_select)
selectedGender = 2
}
tvDescription.setOnClickListener {
if (selectedGender != -1) {
// 这里可以获取selectedGender的值(0,1,2)
// 标记已经不是第一次启动了
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
prefs.edit().putBoolean("is_first_launch", false).apply()
EncryptedSharedPreferencesUtil.save(this, "gender", selectedGender.toString())
// 跳转到主界面
val rootView = window.decorView.findViewById<ViewGroup>(android.R.id.content)
LoadingOverlay.attach(rootView).show()
startActivity(Intent(this, MainActivity::class.java))
finish()
}else{
Toast.makeText(this, "Please select your gender.", Toast.LENGTH_SHORT).show()
}
}
btnStart.setOnClickListener {
// 标记已经不是第一次启动了
@@ -20,8 +68,16 @@ class OnboardingActivity : AppCompatActivity() {
prefs.edit().putBoolean("is_first_launch", false).apply()
// 跳转到主界面
val rootView = window.decorView.findViewById<ViewGroup>(android.R.id.content)
LoadingOverlay.attach(rootView).show()
startActivity(Intent(this, MainActivity::class.java))
finish()
}
}
private fun resetAllLayouts() {
findViewById<LinearLayout>(R.id.male_layout).setBackgroundResource(R.drawable.gender_background)
findViewById<LinearLayout>(R.id.female_layout).setBackgroundResource(R.drawable.gender_background)
findViewById<LinearLayout>(R.id.third_layout).setBackgroundResource(R.drawable.gender_background)
}
}

View File

@@ -2,23 +2,54 @@ package com.example.myapplication
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
// import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import com.example.myapplication.network.BehaviorReporter
class SplashActivity : AppCompatActivity() {
// private lateinit var progressBar: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_splash)
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
if (isFirstLaunch) {
// 第一次启动 → 进入引导页
startActivity(Intent(this, OnboardingActivity::class.java))
} else {
// 不是第一次 → 直接进入主界面
startActivity(Intent(this, MainActivity::class.java))
}
finish()
// progressBar = findViewById(R.id.progressBar)
Handler(Looper.getMainLooper()).postDelayed({
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
if(isFirstLaunch){
BehaviorReporter.report(
isNewUser = false,
"page_id" to "sex_select",
)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "guide",
)
}
val targetIntent = if (isFirstLaunch) {
// 第一次启动 → 进入引导页
prefs.edit().putBoolean("is_first_launch", false).apply()
Intent(this, OnboardingActivity::class.java)
} else {
// 不是第一次 → 进入主界面携带原始intent的参数
Intent(this, MainActivity::class.java).apply {
intent.extras?.let { putExtras(it) }
}
}
startActivity(targetIntent)
finish()
}, 1000) // 0.5秒延迟,确保初始化
}
override fun onDestroy() {
// progressBar.clearAnimation()
super.onDestroy()
}
}

View File

@@ -1,24 +1,46 @@
package com.example.myapplication.keyboard
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
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 android.widget.TextView
import android.content.ClipboardManager
import android.util.Log
import android.widget.Toast
import android.view.inputmethod.ExtractedTextRequest
import com.example.myapplication.SplashActivity
import com.example.myapplication.network.LlmStreamCallback
import com.example.myapplication.network.ListByUserWithNot
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.NetworkClient
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.theme.ThemeManager
import com.google.android.flexbox.FlexboxLayout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import kotlin.math.min
import com.example.myapplication.network.BehaviorReporter
class AiKeyboard(
env: KeyboardEnvironment
@@ -26,7 +48,18 @@ class AiKeyboard(
private var currentStreamCall: Call? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
// ✅ 卡片选中的 characterIdSSE 参数)
private var selectedCharacterId: Int? = null
// ✅ Paste 得到的内容SSE message
private var lastPastedText: String? = null
// ✅ 记录“上次从卡片填入到输入框”的文本,用于覆盖
private var lastFilledText: String? = null
// 输出容器
private val messagesContainer: LinearLayout by lazy {
val res = env.ctx.resources
val id = res.getIdentifier("container_messages", "id", env.ctx.packageName)
@@ -39,15 +72,13 @@ class AiKeyboard(
rootView.findViewById(id)
}
// 当前正在流式更新的那一个 AI 文本
private var currentAssistantTextView: TextView? = null
// 用来处理 <SPLIT> 的缓冲
private val streamBuffer = StringBuilder()
//新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新
/**
* ✅ inflate 一条消息item_ai_message.xml
* ✅ 点击卡片:把内容填入输入框,并覆盖上次填入内容
*/
private fun addAssistantMessage(initialText: String = ""): TextView {
val inflater = env.layoutInflater
val res = env.ctx.resources
@@ -58,160 +89,440 @@ class AiKeyboard(
res.getIdentifier("tv_content", "id", env.ctx.packageName)
)
tv.text = initialText
messagesContainer.addView(itemView)
// ✅ 点击整张卡片:把当前卡片内容填入输入框,并覆盖上次填入内容
itemView.setOnClickListener {
val text = tv.text?.toString().orEmpty()
if (text.isNotBlank()) {
fillToEditorOverwriteLast(text)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_StreamTextView",
"handle_label" to "handle_label",
"send_text" to text,
)
}
}
messagesContainer.addView(itemView)
scrollToBottom()
return tv
}
/**
* (可选)如果你也想显示用户提问
*/
private fun addUserMessage(text: String) {
// 简单写:复用同一个 item 布局
val tv = addAssistantMessage(text)
// 这里可以改成设置 gravity、背景区分用户/AI 等
}
private fun scrollToBottom() {
// 延迟一点点执行,保证 addView 完成后再滚动
messagesScrollView.post {
messagesScrollView.fullScroll(View.FOCUS_DOWN)
}
}
//后端每来一个 llm_chunk 的 data就调用一次这个方法
private fun onLlmChunk(data: String) {
// 丢掉 data=":\n\n" 这条
if (data == ":\n\n") return
// 确保在主线程更新 UI
mainHandler.post {
// 如果还没有正在流式的 TextView就新建一条 AI 消息
if (currentAssistantTextView == null) {
currentAssistantTextView = addAssistantMessage("")
streamBuffer.clear()
}
// 累积到缓冲区
streamBuffer.append(data)
// 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来)
var text = streamBuffer.toString().replace(":\n\n", "")
// 处理 <SPLIT>:代表下一句/下一条消息
val splitTag = "<SPLIT>"
var index = text.indexOf(splitTag)
while (index != -1) {
// split 前面这一段是上一条消息的最终内容
val before = text.substring(0, index)
currentAssistantTextView?.text = before
scrollToBottom()
// 开启下一条 AI 消息
currentAssistantTextView = addAssistantMessage("")
// 剩下的留给下一轮
text = text.substring(index + splitTag.length)
index = text.indexOf(splitTag)
}
// 循环结束后 text 就是「当前这条消息的未完成尾巴」
currentAssistantTextView?.text = text
scrollToBottom()
// 缓冲区只保留尾巴(避免无限变长)
streamBuffer.clear()
streamBuffer.append(text)
}
}
// 收到 type="done" 时调用,表示这一轮回答结束
private fun onLlmDone() {
mainHandler.post {
// 这里目前不需要做太多事,必要的话可以清掉 buffer
streamBuffer.clear()
currentAssistantTextView = null
}
}
// 开始一次新的 AI 回答流式请求
fun startAiStream(userQuestion: String) {
// 可选:先把用户问题显示出来
addUserMessage(userQuestion)
// ✅ 关键:调用 POST /chat/talk 的 SSE并渲染到输出区
private fun startTalkStream(characterId: Int, message: String) {
// 每次发起新对话:清空输出区(你如果想保留历史,把这行删掉)
messagesContainer.removeAllViews()
// 如果之前有没结束的流,先取消
// 取消旧流
currentStreamCall?.cancel()
currentStreamCall = NetworkClient.startLlmStream(
question = userQuestion,
currentStreamCall = NetworkClient.startChatTalkStream(
characterId = characterId,
message = message,
callback = object : LlmStreamCallback {
override fun onEvent(type: String, data: String?) {
when (type) {
"llm_chunk" -> {
if (data != null) {
onLlmChunk(data) // 这里就是之前写的流式 UI 更新
guard("SSE.onEvent(type=$type)") {
when (type) {
"llm_chunk" -> if (data != null) onLlmChunk(data)
"done" -> onLlmDone()
else -> {
Log.d("AI_KB", "unknown event type=$type data=${data?.take(200)}")
}
}
"done" -> {
onLlmDone() // 一轮结束
}
"search_result" -> {
}
}
}
override fun onError(t: Throwable) {
addAssistantMessage("出错了:${t.message}")
// 尝试解析JSON错误响应
val errorResponse = try {
val errorJson = t.message?.let {
org.json.JSONObject(it)
}
if (errorJson != null) {
ApiResponse<Nothing>(
code = errorJson.optInt("code", 500),
message = errorJson.optString("message", "Unknown error"),
data = null
)
} else {
ApiResponse<Nothing>(
code = 500,
message = t.message ?: "Unknown error",
data = null
)
}
} catch (e: Exception) {
ApiResponse<Nothing>(
code = 500,
message = t.message ?: "Unknown error",
data = null
)
}
// SSE错误处理如没有vip)
if (errorResponse.code == 50022) {
mainHandler.post {
Toast.makeText(env.ctx, errorResponse.message, Toast.LENGTH_LONG).show()
}
} else {
showErrorOnUi(errorResponse.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)
if (layoutId != 0) {
env.layoutInflater.inflate(layoutId, null) as View
} else {
// 如果找不到布局创建一个默认的View
LinearLayout(env.ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
addView(TextView(env.ctx).apply {
text = "AI Keyboard"
})
addView(TextView(env.ctx).apply { text = "AI Keyboard" })
}
}
}
init {
applyKeyBackground(rootView, "background")
applyTheme(
env.currentTextColor,
env.currentBorderColor,
env.currentBackgroundColor
)
setupListeners()
guard("AiKeyboard.init") {
applyKeyBackground(rootView, "background")
applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
setupListeners()
loadPersonasAndRender()
}
}
private fun applyKeyBackground(
root: View,
viewIdName: String,
drawableName: String? = null
) {
// ========= UI 绑定 =========
private fun setupListeners() {
val res = env.ctx.resources
val pkg = env.ctx.packageName
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
aiPersonaView?.visibility = View.VISIBLE
aiOutputView?.visibility = View.GONE
// 返回主键盘
val backId = res.getIdentifier("key_abc", "id", pkg)
if (backId != 0) {
rootView.findViewById<ImageView?>(backId)?.setOnClickListener {
env.showMainKeyboard()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_main_panel",
)
}
}
// VIP
val vipButtonId = res.getIdentifier("key_vip", "id", pkg)
if (vipButtonId != 0) {
rootView.findViewById<ImageView?>(vipButtonId)?.setOnClickListener {
navigateToRechargeFragment()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_subscription_panel",
)
}
}
// Return输出区 -> 人设区
val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
if (returnButtonId != 0) {
rootView.findViewById<View?>(returnButtonId)?.setOnClickListener {
aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
aiOutputView.visibility = View.GONE
aiPersonaView?.visibility = View.VISIBLE
aiPersonaView?.alpha = 0f
aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
}
}
}
// Paste读取剪贴板第一条 -> 保存 lastPastedText并显示到 completion_text
val pasteBtnId = res.getIdentifier("keyboard_button_Paste", "id", pkg)
val completionTextId = res.getIdentifier("completion_text", "id", pkg)
if (pasteBtnId != 0) {
rootView.findViewById<View?>(pasteBtnId)?.setOnClickListener {
val text = readClipboardFirstText()
lastPastedText = text
if (completionTextId != 0) {
rootView.findViewById<TextView?>(completionTextId)?.text =
if (text.isNullOrBlank()) "" else text
}
}
}
// HorizontalScrollView点击事件与Paste按钮相同
val scrollViewId = res.getIdentifier("completion_container", "id", pkg)
if (scrollViewId != 0) {
rootView.findViewById<View?>(scrollViewId)?.setOnClickListener {
val text = readClipboardFirstText()
lastPastedText = text
if (completionTextId != 0) {
rootView.findViewById<TextView?>(completionTextId)?.text =
if (text.isNullOrBlank()) "" else text
}
}
}
// ✅ Delete沿用 MyInputMethodService.deleteOne()
val deleteBtnId = res.getIdentifier("keyboard_button_Delete", "id", pkg)
if (deleteBtnId != 0) {
rootView.findViewById<View?>(deleteBtnId)?.setOnClickListener {
env.deleteOne() // 就是 MyInputMethodService.deleteOne()
lastFilledText = null // 可选:防止覆盖逻辑误删
}
}
// ✅ Send沿用 MyInputMethodService.performSendAction()
val sendBtnId = res.getIdentifier("keyboard_button_Send", "id", pkg)
if (sendBtnId != 0) {
rootView.findViewById<View?>(sendBtnId)?.setOnClickListener {
env.performSendAction() // 就是 MyInputMethodService.performSendAction()
lastFilledText = null // 发送后不再尝试覆盖
}
}
// ✅ Clear清空输入框
val clearBtnId = res.getIdentifier("keyboard_button_Clear", "id", pkg)
if (clearBtnId != 0) {
rootView.findViewById<View?>(clearBtnId)?.setOnClickListener {
clearEditorAll()
lastFilledText = null
}
}
}
// ========= 人设卡片listByUser 渲染 =========
fun refreshPersonas() {
loadPersonasAndRender()
}
private fun loadPersonasAndRender() {
val res = env.ctx.resources
val pkg = env.ctx.packageName
val containerId = res.getIdentifier("persona_container", "id", pkg)
val container = if (containerId != 0) rootView.findViewById<FlexboxLayout?>(containerId) else null
if (container == null) return
uiScope.launch {
try {
val resp = withContext(Dispatchers.IO) { RetrofitClient.apiService.listByUser() }
Log.d("1314520-AI_KB", "listByUser response: $resp")
if (resp.code == 0) {
val list = resp.data ?: emptyList()
renderPersonaCards(container, list)
} else if (resp.code == 40102) {
Toast.makeText(env.ctx, "You need to log in to use this function.", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(env.ctx, resp.message, Toast.LENGTH_LONG).show()
}
} catch (_: Throwable) {
container.removeAllViews()
}
}
}
private fun renderPersonaCards(container: FlexboxLayout, list: List<ListByUserWithNot>) {
container.removeAllViews()
val inflater = env.layoutInflater
list.forEach { item ->
val v = inflater.inflate(
com.example.myapplication.R.layout.item_ai_persona_card,
container,
false
)
val avatar = v.findViewById<de.hdodenhof.circleimageview.CircleImageView>(
com.example.myapplication.R.id.avatar
)
val nameTv = v.findViewById<TextView>(com.example.myapplication.R.id.name)
nameTv.text = item.characterName
val sizePx = (env.ctx.resources.displayMetrics.density * 20f).toInt()
avatar.setImageBitmap(emojiToBitmap(item.emoji, sizePx))
v.setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_function_panel",
"element_id" to "renshe_item",
"id" to item.characterId,
"name" to item.characterName,
)
// ✅ SSE 要的是 characterId不是 id
selectedCharacterId = item.characterId
val res = env.ctx.resources
val pkg = env.ctx.packageName
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
// 获取消息内容优先使用lastPastedText其次读取剪贴板
val message = lastPastedText ?: readClipboardFirstText() ?: run {
addAssistantMessage("Please Paste the content of the clipboard first or make sure the clipboard is not empty")
return@setOnClickListener
}
aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
aiPersonaView.visibility = View.GONE
aiOutputView?.visibility = View.VISIBLE
aiOutputView?.alpha = 0f
aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
}
// 发送SSE请求
startTalkStream(item.characterId, message)
}
container.addView(v)
}
}
private fun emojiToBitmap(emoji: String, sizePx: Int): Bitmap {
val safeSize = min(sizePx.coerceAtLeast(16), 128)
val bmp = Bitmap.createBitmap(safeSize, safeSize, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT
textSize = safeSize * 0.8f
}
val fm = paint.fontMetrics
val x = safeSize / 2f
val y = safeSize / 2f - (fm.ascent + fm.descent) / 2f
canvas.drawText(emoji, x, y, paint)
return bmp
}
private fun readClipboardFirstText(): String? {
val cm = env.ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null
val clip = cm.primaryClip ?: return null
if (clip.itemCount <= 0) return null
return clip.getItemAt(0).coerceToText(env.ctx)?.toString()
}
/**
* ✅ 将卡片文本填入宿主输入框,并覆盖上一次“卡片填入”的内容
* 覆盖策略(安全版):
* - 仅当光标前的文本 == lastFilledText 时,才删除那段并覆盖
* - 否则就直接插入(避免误删用户手动输入的内容)
*/
private fun fillToEditorOverwriteLast(newText: String) {
val ic = env.getInputConnection() ?: return
ic.beginBatchEdit()
try {
val prev = lastFilledText
if (!prev.isNullOrEmpty()) {
val before = ic.getTextBeforeCursor(prev.length, 0)?.toString()
if (before == prev) {
ic.deleteSurroundingText(prev.length, 0)
}
}
ic.commitText(newText, 1)
lastFilledText = newText
} finally {
ic.endBatchEdit()
}
}
/** ✅ 清空宿主输入框(当前编辑框) */
private fun clearEditorAll() {
val ic = env.getInputConnection() ?: return
val et = try {
ic.getExtractedText(ExtractedTextRequest(), 0)
} catch (_: Throwable) {
null
}
val full = et?.text?.toString().orEmpty()
if (full.isEmpty()) return
ic.beginBatchEdit()
try {
ic.setSelection(0, full.length)
ic.commitText("", 1)
} finally {
ic.endBatchEdit()
}
}
// ========= 换肤相关(保留你原有逻辑) =========
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
@@ -250,125 +561,51 @@ class AiKeyboard(
}
}
private fun setupListeners() {
val res = env.ctx.resources
val pkg = env.ctx.packageName
// 获取ai_persona和ai_output视图引用
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
// 初始化显示状态显示ai_persona隐藏ai_output
aiPersonaView?.visibility = View.VISIBLE
aiOutputView?.visibility = View.GONE
// 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc就绑定一下
val backId = res.getIdentifier("key_abc", "id", pkg)
if (backId != 0) {
rootView.findViewById<ImageView?>(backId)?.setOnClickListener {
env.showMainKeyboard()
}
}
// 绑定 VIP 按钮点击事件,跳转到充值页面
val vipButtonId = res.getIdentifier("key_vip", "id", pkg)
if (vipButtonId != 0) {
rootView.findViewById<ImageView?>(vipButtonId)?.setOnClickListener {
navigateToRechargeFragment()
}
}
//显示切换
val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
if (returnButtonId != 0) {
rootView.findViewById<View?>(returnButtonId)?.let { returnButton ->
// 确保按钮可点击且可获得焦点,防止事件穿透
returnButton.isClickable = true
returnButton.isFocusable = true
returnButton.setOnClickListener {
// 点击Return_keyboard先隐藏ai_output再显示ai_persona顺序动画
aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
aiOutputView?.visibility = View.GONE
// 等ai_output完全隐藏后再显示ai_persona
aiPersonaView?.visibility = View.VISIBLE
aiPersonaView?.alpha = 0f
aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
}
}
}
}
val cardButtonId = res.getIdentifier("card", "id", pkg)
if (cardButtonId != 0) {
rootView.findViewById<View?>(cardButtonId)?.setOnClickListener {
// 点击card先隐藏ai_persona再显示ai_output顺序动画
aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
aiPersonaView?.visibility = View.GONE
// 等ai_persona完全隐藏后再显示ai_output
aiOutputView?.visibility = View.VISIBLE
aiOutputView?.alpha = 0f
aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
}
}
}
// // 假设 ai_keyboard.xml 里有一个发送按钮 key_send
// val sendId = res.getIdentifier("key_send", "id", pkg)
// val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id
// if (sendId != 0 && inputId != 0) {
// val inputView = rootView.findViewById<TextView?>(inputId)
// rootView.findViewById<View?>(sendId)?.setOnClickListener {
// val question = inputView?.text?.toString()?.trim().orEmpty()
// if (question.isNotEmpty()) {
// startAiStream(question)
// }
// }
// }
}
private fun navigateToRechargeFragment() {
try {
val intent = Intent(env.ctx, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
putExtra("navigate_to", "recharge_fragment")
}
val intent = Intent(env.ctx, SplashActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
intent.putExtra("navigate_to", "recharge_fragment")
env.ctx.startActivity(intent)
} catch (e: Exception) {
// 如果启动失败,记录错误日志
android.util.Log.e("AiKeyboard", "Failed to navigate to recharge fragment", e)
Log.e("AiKeyboard", "Failed to navigate to recharge fragment", e)
}
}
override fun applyTheme(
textColor: ColorStateList,
borderColor: ColorStateList,
backgroundColor: ColorStateList
) {
override fun applyTheme(textColor: ColorStateList, 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) }
}
}
private fun showErrorOnUi(title: String, t: Throwable? = null) {
val msg = if (t == null) title else "$title${t.message ?: t.toString()}"
Log.e("AI_KB", msg, t)
mainHandler.post {
runCatching {
val res = env.ctx.resources
val pkg = env.ctx.packageName
val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById<View?>(aiPersonaId) else null
val aiOutputView = if (aiOutputId != 0) rootView.findViewById<View?>(aiOutputId) else null
aiPersonaView?.visibility = View.GONE
aiOutputView?.visibility = View.VISIBLE
addAssistantMessage(msg)
}
}
}
/** 包一层 try-catch避免任何地方异常直接白屏 */
private inline fun guard(tag: String, block: () -> Unit) {
try {
block()
} catch (t: Throwable) {
showErrorOnUi("发生异常($tag)", t)
}
}
}

View File

@@ -65,8 +65,8 @@ abstract class BaseKeyboard(
protected fun applyBorderToAllKeyViews(root: View?) {
if (root == null) return
val keyMarginPx = 1.dpToPx()
val keyPaddingH = 6.dpToPx()
val keyMarginPx = 2.dpToPx()
val keyPaddingH = 8.dpToPx()
// 忽略 suggestion_0..20(联想栏)
val ignoredIds = HashSet<Int>().apply {
@@ -112,6 +112,12 @@ abstract class BaseKeyboard(
return (this * density + 0.5f).toInt()
}
/** dp -> px (float version) */
protected fun Float.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) {

View File

@@ -39,6 +39,8 @@ interface KeyboardEnvironment {
fun showAiKeyboard()
//emoji键盘
fun showEmojiKeyboard()
// 关闭联想
fun associateClose()
// 音效
fun playKeyClick()

View File

@@ -15,7 +15,9 @@ import android.view.MotionEvent
import android.view.View
import android.widget.PopupWindow
import android.widget.TextView
import android.widget.LinearLayout
import com.example.myapplication.theme.ThemeManager
import com.example.myapplication.network.BehaviorReporter
class MainKeyboard(
env: KeyboardEnvironment,
@@ -145,6 +147,10 @@ class MainKeyboard(
view.findViewById<View?>(res.getIdentifier("key_ai", "id", pkg))?.setOnClickListener {
vibrateKey(); env.showAiKeyboard()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "keyboard_function_panel",
)
}
view.findViewById<View?>(res.getIdentifier("key_send", "id", pkg))?.setOnClickListener {
@@ -159,6 +165,10 @@ class MainKeyboard(
updateRevokeButtonVisibility(view, res, pkg)
}
view.findViewById<LinearLayout?>(res.getIdentifier("associate_close", "id", pkg))?.setOnClickListener {
vibrateKey();env.associateClose()
}
view.findViewById<View?>(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener {
vibrateKey(); env.showEmojiKeyboard()
}

View File

@@ -1,6 +1,7 @@
// 请求方法
package com.example.myapplication.network
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.*
@@ -65,9 +66,42 @@ interface ApiService {
@POST("user/updateInfo")
suspend fun updateUserInfo(
@Body body: updateInfoRequest
): ApiResponse<User>
): ApiResponse<Boolean>
//分页查询钱包交易记录
@POST("wallet/transactions")
suspend fun transactions(
@Body body: transactionsRequest
): ApiResponse<transactionsResponse>
//用户人设列表
@GET("character/listByUser")
suspend fun listByUser(
): ApiResponse<List<ListByUserWithNot>>
//更新用户人设排序
@POST("character/updateUserCharacterSort")
suspend fun updateUserCharacterSort(
@Body body: updateUserCharacterSortRequest
): ApiResponse<Boolean>
// 删除用户人设
@GET("character/delUserCharacter")
suspend fun delUserCharacter(
@Query("id") id: Int
): ApiResponse<Boolean>
//提交反馈
@POST("user/feedback")
suspend fun feedback(
@Body body: feedbackRequest
): ApiResponse<Boolean>
//查询邀请码
@GET("user/inviteCode")
suspend fun inviteCode(
): ApiResponse<ShareResponse>
//===========================================首页=================================
// 标签列表
@GET("tag/list")
@@ -102,17 +136,11 @@ interface ApiService {
@Query("id") id: Int
): ApiResponse<CharacterDetailResponse>
//删除用户人设
@GET("character/delUserCharacter")
suspend fun delUserCharacter(
@Query("id") id: Int
): ApiResponse<Unit>
//添加用户人设
@POST("character/addUserCharacter")
suspend fun addUserCharacter(
@Body body: AddPersonaClick
): ApiResponse<Unit>
): ApiResponse<Boolean>
//==========================================商城===========================================
@@ -188,4 +216,18 @@ interface ApiService {
suspend fun downloadZipFromUrl(
@Url url: String // 完整的下载 URL
): Response<ResponseBody>
}
/**
* 文件上传服务接口
*/
interface FileUploadService {
@Multipart
@POST("file/upload")
suspend fun uploadFile(
@Query("file") fileQuery: String,
@Part file: MultipartBody.Part
): ApiResponse<String>
}

View File

@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.SharedFlow
object AuthEventBus {
// replay=0不缓存历史事件extraBufferCapacity:避免瞬时事件
// replay=1缓存最近一次事件extraBufferCapacity=64增加缓冲区防止瞬时事件丢失
private val _events = MutableSharedFlow<AuthEvent>(
replay = 0,
extraBufferCapacity = 1
@@ -21,8 +21,11 @@ object AuthEventBus {
sealed class AuthEvent {
data class TokenExpired(val message: String? = null) : AuthEvent()
data class CharacterAdded(val personaId: Int, val newAdded: Boolean = false) : AuthEvent()
data class GenericError(val message: String) : AuthEvent()
object LoginSuccess : AuthEvent()
data class Logout(val returnTabTag: String) : AuthEvent()
data class OpenGlobalPage(val destinationId: Int, val bundle: Bundle? = null) : AuthEvent()
}
object UserUpdated : AuthEvent()
data class CharacterDeleted(val characterId: Int) : AuthEvent()
}

View File

@@ -0,0 +1,21 @@
package com.example.myapplication.network
import com.google.gson.JsonObject
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface BehaviorApiService {
// 新用户
@POST("newAccount")
suspend fun reportNewUserBehavior(
@Body body: JsonObject
): Response<Unit>
// 老用户
@POST("genericData")
suspend fun reportGenericUserBehavior(
@Body body: JsonObject
): Response<Unit>
}

View File

@@ -0,0 +1,84 @@
package com.example.myapplication.network
import android.util.Log
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object BehaviorHttpClient {
private const val TAG = "BehaviorHttp"
// TODO改成你的行为服务 baseUrl必须以 / 结尾)
private const val BASE_URL = "http://192.168.2.21:35310/api/"
/**
* 请求拦截器:打印请求信息
*/
private val requestInterceptor = Interceptor { chain ->
val request = chain.request()
val bodyStr = request.body?.let { body ->
val buffer = okio.Buffer()
body.writeTo(buffer)
buffer.readUtf8()
}
Log.d(TAG, "201314-请求")
Log.d(TAG, "201314-URL: ${request.url}")
Log.d(TAG, "201314-Method: ${request.method}")
if (!bodyStr.isNullOrBlank()) {
Log.d(TAG, "201314-Body: $bodyStr")
}
chain.proceed(request)
}
/**
* 响应拦截器:打印响应信息
* ⚠️ response.body 只能读一次,这里使用 clone()
*/
private val responseInterceptor = Interceptor { chain ->
val response = chain.proceed(chain.request())
val responseBody = response.body
val bodyStr = responseBody?.source()?.let { source ->
source.request(Long.MAX_VALUE)
source.buffer.clone().readUtf8()
}
Log.d(TAG, "201314-响应")
Log.d(TAG, "201314-URL: ${response.request.url}")
Log.d(TAG, "201314-Code: ${response.code}")
if (!bodyStr.isNullOrBlank()) {
Log.d(TAG, "201314-Body: $bodyStr")
}
response
}
private val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.addInterceptor(requestInterceptor)
.addInterceptor(responseInterceptor)
.build()
}
private val retrofit: Retrofit by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val service: BehaviorApiService by lazy {
retrofit.create(BehaviorApiService::class.java)
}
}

View File

@@ -0,0 +1,111 @@
package com.example.myapplication.network
import android.util.Log
import com.example.myapplication.AppContext
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
object BehaviorReporter {
private const val TAG = "BehaviorHttp"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val gson = Gson()
/**
* 你只管调用这个方法即可:
*
* BehaviorReporter.report(
* isNewUser = true,
* "event" to "register_success",
* "page" to "home",
* "key" to "A",
* "count" to 1,
* "obj" to mapOf("a" to 1)
* )
*
* @param isNewUser true = newAccountfalse = genericData
* @param extra 你想上报的任意字段null/空字符串/空集合会被过滤掉
*/
fun report(
isNewUser: Boolean,
vararg extra: Pair<String, Any?>
) {
scope.launch {
runCatching {
val user = EncryptedSharedPreferencesUtil.get(
AppContext.context,
"user",
LoginResponse::class.java
)
val token = user?.token.orEmpty()
// ✅ 用 JsonObject避免 Retrofit 的 Map<?, ?> wildcard 报错
val body = JsonObject()
// token非空才带放 body 内)
if (token.isNotBlank()) body.addProperty("token", token)
// extra你传什么都行自动过滤无效值并转成 JsonElement
extra.forEach { (k, v) ->
val element = v.toJsonElementOrNull() ?: return@forEach
body.add(k, element)
}
// 根据新/老用户走不同接口
if (isNewUser) {
BehaviorHttpClient.service.reportNewUserBehavior(body)
} else {
BehaviorHttpClient.service.reportGenericUserBehavior(body)
}
}.onFailure { e ->
Log.e(TAG, "201314-report failed: ${e.message}", e)
}
}
}
/**
* 把 Any? 转 JsonElement并过滤掉你不想传的“空值”
* 支持String/Number/Boolean/Char/Map/List/Set/以及其他对象(尽量 gson.toJsonTree
*/
private fun Any?.toJsonElementOrNull(): JsonElement? {
return when (this) {
null -> null
is String -> if (this.isBlank()) null else JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
is Char -> JsonPrimitive(this.toString())
is Map<*, *> -> {
if (this.isEmpty()) return null
gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE }
}
is List<*> -> {
if (this.isEmpty()) return null
gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE } ?: JsonArray()
}
is Set<*> -> {
if (this.isEmpty()) return null
gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE }
}
else -> {
// 兜底:尽量序列化;失败就丢弃,避免影响主流程
runCatching { gson.toJsonTree(this) }.getOrNull()
?.takeIf { it != JsonNull.INSTANCE }
}
}
}
}

View File

@@ -9,7 +9,16 @@ import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import android.content.Context
import com.example.myapplication.network.security.BodyParamsExtractor
import com.example.myapplication.network.security.NonceUtils
import com.example.myapplication.network.security.SignUtils
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import okio.Buffer
import java.net.URLDecoder
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
// * 不需要登录的接口路径(相对完整路径)
// * 只写 /api/ 后面的部分
@@ -18,59 +27,95 @@ import android.content.Context
private val NO_LOGIN_REQUIRED_PATHS = setOf(
"/themes/listByStyle",
"/wallet/balance",
"/character/listByUser",
)
private fun noLoginRequired(url: HttpUrl): Boolean {
val path = url.encodedPath // 例:/api/home/banner
private val NO_SIGN_REQUIRED_PATHS = setOf(
"/auth/login",
)
// 统一裁掉 /api 前缀
val apiPath = path.substringAfter("/api", path)
return NO_LOGIN_REQUIRED_PATHS.contains(apiPath)
private fun apiPath(url: HttpUrl): String {
val path = url.encodedPath
return path.substringAfter("/api", path)
}
private fun noLoginRequired(url: HttpUrl): Boolean =
NO_LOGIN_REQUIRED_PATHS.contains(apiPath(url))
private fun noSignRequired(url: HttpUrl): Boolean =
NO_SIGN_REQUIRED_PATHS.contains(apiPath(url))
/**
* 请求拦截器:统一加 Header、token 等
*/
fun requestInterceptor(appContext: Context) = Interceptor { chain ->
val original = chain.request()
val url = original.url
val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
val token = user?.token.orEmpty()
val newRequest = original.newBuilder()
val builder = original.newBuilder()
.apply {
if (token.isNotBlank()) {
addHeader("auth-token", "$token")
}
if (token.isNotBlank()) addHeader("auth-token", token)
}
.addHeader("Accept-Language", "lang")
.build()
// ===== 打印请求信息 =====
val request = newRequest
val url = request.url
// ======= ✅ 按你规则加签名header + query + body=======
if (!noSignRequired(url)) {
val appId = "loveKeyboard"
val secret = "kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H" // TODO 正式环境建议下发/混淆/NDK
val timestamp = (System.currentTimeMillis() / 1000).toString()
val nonce = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
// 1) 合并成 Map去掉 sign 本身)
val params = linkedMapOf<String, String>()
params["appId"] = appId
params["timestamp"] = timestamp
params["nonce"] = nonce
// 2) query 参数
for (i in 0 until url.querySize) {
params[url.queryParameterName(i)] = url.queryParameterValue(i).orEmpty()
}
// 3) body 参数json / form
params.putAll(extractBodyParams(original))
// 4) 生成 sign
val sign = calcSign(params, secret)
builder
.addHeader("X-App-Id", appId)
.addHeader("X-Timestamp", timestamp)
.addHeader("X-Nonce", nonce)
.addHeader("X-Sign", sign)
}
val request = builder.build()
// ===== 打印请求信息(保留你原来的)=====
val sb = StringBuilder()
sb.append("\n======== HTTP Request ========\n")
sb.append("Method: ${request.method}\n")
sb.append("URL: $url\n")
sb.append("URL: ${request.url}\n")
sb.append("Headers:\n")
for (name in request.headers.names()) {
sb.append(" $name: ${request.header(name)}\n")
}
if (url.querySize > 0) {
if (request.url.querySize > 0) {
sb.append("Query Params:\n")
for (i in 0 until url.querySize) {
sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n")
for (i in 0 until request.url.querySize) {
sb.append(" ${request.url.queryParameterName(i)} = ${request.url.queryParameterValue(i)}\n")
}
}
val requestBody = request.body
if (requestBody != null) {
val buffer = okio.Buffer()
val buffer = Buffer()
requestBody.writeTo(buffer)
sb.append("Body:\n")
sb.append(buffer.readUtf8())
@@ -83,6 +128,132 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
chain.proceed(request)
}
//
// ================== 签名工具(严格按你描述规则) ==================
//
private fun calcSign(params: Map<String, String>, secret: String): String {
// 去空值 + 去 sign
val filtered = params
.filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) }
// 按 key 字典序排序
val sorted = filtered.toSortedMap()
// 拼接k=v&...&secret=xxxvalue 统一做 URL encode 防止 & = 破坏结构)
val sb = StringBuilder()
sorted.forEach { (k, v) ->
if (sb.isNotEmpty()) sb.append("&")
sb.append(k).append("=").append(urlEncode(v))
}
sb.append("&secret=").append(urlEncode(secret))
// HMAC-SHA256 -> hex小写
return hmacSha256Hex(sb.toString(), secret)
}
private fun hmacSha256Hex(data: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
private fun urlEncode(v: String): String =
URLEncoder.encode(v, "UTF-8")
//
// ================== Body 参数提取json / form ==================
//
private fun extractBodyParams(request: okhttp3.Request): Map<String, String> {
val body = request.body ?: return emptyMap()
val ct = body.contentType()?.toString()?.lowercase().orEmpty()
return when {
ct.contains("application/json") -> extractJsonBody(body)
ct.contains("application/x-www-form-urlencoded") -> extractFormBody(body)
else -> emptyMap() // multipart / stream 等默认不签 body如需可再扩展
}
}
private fun extractJsonBody(body: okhttp3.RequestBody): Map<String, String> {
val raw = bodyToString(body).trim()
if (raw.isBlank()) return emptyMap()
return try {
val root: JsonElement = JsonParser.parseString(raw)
val out = linkedMapOf<String, String>()
flattenJson(root, "", out)
out
} catch (_: Exception) {
emptyMap()
}
}
private fun extractFormBody(body: okhttp3.RequestBody): Map<String, String> {
val raw = bodyToString(body)
if (raw.isBlank()) return emptyMap()
val map = linkedMapOf<String, String>()
raw.split("&")
.filter { it.isNotBlank() }
.forEach { pair ->
val idx = pair.indexOf("=")
if (idx > 0) {
val k = pair.substring(0, idx)
val v = pair.substring(idx + 1)
// form 这里解码回“原值”,后续签名阶段再统一 encode
map[k] = URLDecoder.decode(v, "UTF-8")
} else {
map[pair] = ""
}
}
return map
}
private fun bodyToString(body: okhttp3.RequestBody): String {
return try {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
buffer.readString(charset)
} catch (_: Exception) {
""
}
}
/**
* JSON 扁平化规则:
* object: a.b.c
* array : items[0].id
*/
private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap<String, String>) {
when {
elem.isJsonNull -> {
// null 不参与签名(服务端也要一致)
}
elem.isJsonPrimitive -> {
if (prefix.isNotBlank()) out[prefix] = elem.asJsonPrimitive.toString().trim('"')
}
elem.isJsonObject -> {
val obj = elem.asJsonObject
for ((k, v) in obj.entrySet()) {
val newKey = if (prefix.isBlank()) k else "$prefix.$k"
flattenJson(v, newKey, out)
}
}
elem.isJsonArray -> {
val arr = elem.asJsonArray
for (i in 0 until arr.size()) {
val newKey = "$prefix[$i]"
flattenJson(arr[i], newKey, out)
}
}
}
}
/**
* 响应拦截器:统一打印日志、做一些简单的错误处理
@@ -121,7 +292,7 @@ val responseInterceptor = Interceptor { chain ->
val gson = Gson()
val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java)
if (errorResponse.code == 40102) {
if (errorResponse.code == 40102|| errorResponse.code == 40103) {
val isNoLoginApi = noLoginRequired(request.url)
Log.w(
@@ -134,14 +305,14 @@ val responseInterceptor = Interceptor { chain ->
AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message))
}
return@Interceptor response.newBuilder()
.code(401)
.message(
if (isNoLoginApi) response.message
else "Login required: ${errorResponse.message}"
)
.body(bodyString.toResponseBody(mediaType))
.build()
// return@Interceptor response.newBuilder()
// .code(401)
// .message(
// if (isNoLoginApi) response.message
// else "Login required: ${errorResponse.message}"
// )
// .body(bodyString.toResponseBody(mediaType))
// .build()
}
// 其他非0的错误码通过事件总线发送错误信息
else if (errorResponse.code!= 0) {

View File

@@ -71,18 +71,77 @@ data class User(
val email: String,
val emailVerified: Boolean,
val isVip: Boolean,
val vipExpiry: String,
val token: String
val vipExpiry: String?,
val token: String?,
)
//更新用户
data class updateInfoRequest(
val uid: Long,
val nickName: String,
val gender: Int,
val avatarUrl: String?,
val uid: Long? = null,
val nickName: String? = null,
val gender: Int? = null,
val avatarUrl: String? = null,
)
//分页查询钱包交易记录
data class transactionsRequest(
val pageNum: Int,
val pageSize: Int,
)
//分页查询钱包交易记录响应
data class transactionsResponse(
val records: List<TransactionRecord>,
val total: Int,
val size: Int,
val current: Int,
val pages: Int
)
//分页查询钱包交易记录响应
data class TransactionRecord(
val id: Long,
val type: Int,
val amount: Number,
val beforeBalance: Number,
val afterBalance: Number,
val description: String,
val createdAt: String,
)
//用户人设列表响应
data class ListByUserWithNot(
val id: Int,
val characterName: String,
val emoji: String,
val characterId: Int,
)
//更新用户人设排序
data class updateUserCharacterSortRequest(
val sort: List<Int>
)
//提交反馈
data class feedbackRequest(
val content: String,
)
//分享响应
data class ShareResponse(
val code: String,
val status: Int,
val usedCount: Int,
val maxUses: Int,
val expiresAt: String,
val h5Link: String,
)
data class FreeTrialQuota(
val effectiveQuota: Int,
val nacosQuota: Int,
val source: Int,
)
// =======================================首页======================================
//标签列表
data class Tag(
@@ -115,7 +174,7 @@ data class listByTagWithNotLogin(
// 人设详情响应
data class CharacterDetailResponse(
val id: Long? = null,
val id: Int? = null,
val characterName: String? = null,
val characterBackground: String? = null,
val avatarUrl: String? = null,
@@ -189,4 +248,4 @@ data class deleteThemeRequest(
//购买主题
data class purchaseThemeRequest(
val themeId: Int,
)
)

View File

@@ -1,116 +1,406 @@
package com.example.myapplication.network
import android.content.Context
import android.util.Log
import okhttp3.*
import okio.BufferedSource
import okio.Buffer
import org.json.JSONObject
import java.io.IOException
import java.net.URLDecoder
import java.net.URLEncoder
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
object NetworkClient {
// 你自己后端的 base url
private const val BASE_URL = "http://192.168.2.21:7529/api"
private const val TAG = "999-SSE_TALK"
// ====== 按你给的规则固定值 ======
private const val APP_ID = "loveKeyboard"
private const val SECRET = "kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"
@Volatile
private lateinit var appContext: Context
fun init(context: Context) {
appContext = context.applicationContext
}
private fun debugLoggingInterceptor(): Interceptor = Interceptor { chain ->
val req = chain.request()
Log.d("999-HTTP", ">>> ${req.method} ${req.url}")
Log.d("999-HTTP", ">>> headers:\n${req.headers}")
val resp = chain.proceed(req)
Log.d("999-HTTP", "<<< code=${resp.code} ct=${resp.header("Content-Type")} len=${resp.header("Content-Length")}")
Log.d("999-HTTP", "<<< headers:\n${resp.headers}")
resp
}
// 专门用于 SSE 的 OkHttpClientreadTimeout = 0 代表不超时,一直保持连接
private val sseClient: OkHttpClient by lazy {
check(::appContext.isInitialized) {
"NetworkClient not initialized. Call NetworkClient.init(context) first."
}
OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时
.readTimeout(0, TimeUnit.MILLISECONDS)
.addInterceptor(debugLoggingInterceptor())
.build()
}
/**
* 启动一次 SSE 流式请求
* @param question 用户问题(你要传给后端的)
* @return Call可用于取消比如用户关闭键盘时
*/
fun startLlmStream(
question: String,
fun startChatTalkStream(
characterId: Int,
message: String,
callback: LlmStreamCallback
): Call {
// 根据你后端的接口改:是 POST 还是 GET参数格式是什么
Log.d(TAG, "POST /chat/talk send -> characterId=$characterId, message=${message.take(200)}")
val json = JSONObject().apply {
put("query", question) // 假设你后端字段叫 query
put("characterId", characterId)
put("message", message)
}
val requestBody = json.toString()
.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径
val baseRequest = Request.Builder()
.url("$BASE_URL/chat/talk")
.post(requestBody)
// 有些 SSE 接口会要求 Accept
.addHeader("Accept", "text/event-stream")
.build()
val call = sseClient.newCall(request)
// ✅ 在这里:按你提供的规则生成签名并加 header
val signedRequest = signRequest(baseRequest)
Log.d(TAG, "before newCall")
val call = sseClient.newCall(signedRequest)
Log.d(TAG, "after newCall -> enqueue")
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (call.isCanceled()) return // 被主动取消就不用回调错误了
if (call.isCanceled()) {
Log.w(TAG, "canceled: $call")
return
}
Log.e(TAG, "onFailure: ${e.javaClass.name}: ${e.message}", e)
callback.onError(e)
}
override fun onResponse(call: Call, response: Response) {
Log.d(TAG, "onResponse -> $response")
Log.d(TAG, "resp headers -> ${response.headers}")
val body = response.body
val contentType = body?.contentType()?.toString().orEmpty()
val isSse = contentType.contains("text/event-stream", ignoreCase = true)
if (!response.isSuccessful) {
callback.onError(IOException("SSE failed: ${response.code}"))
val err = peekOrReadBody(response)
Log.e(TAG, "HTTP failed: code=${response.code}, contentType=$contentType, body=$err")
callback.onError(IOException(err))
response.close()
return
}
val body = response.body ?: run {
if (!isSse) {
val text = peekOrReadBody(response)
Log.w(TAG, "Not SSE response: contentType=$contentType, body=$text")
callback.onError(IOException(text))
response.close()
return
}
if (body == null) {
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)
}
}
readSseStream(source, call, callback)
} catch (ioe: IOException) {
if (!call.isCanceled()) {
Log.e(TAG, "read error: ${ioe.message}", ioe)
callback.onError(ioe)
}
} catch (t: Throwable) {
Log.e(TAG, "unexpected error: ${t.message}", t)
callback.onError(t)
}
}
}
})
Log.d(TAG, "after enqueue")
return call
}
/**
* ✅ 按你给的规则生成:
* - timestamp = 秒
* - nonce = UUID 去 '-' 后取 16 位
* - params = appId/timestamp/nonce + query + body(flatten)
* - sign = calcSign(params, secret)
* - headers: X-App-Id / X-Timestamp / X-Nonce / X-Sign
* - token: auth-token如果有
*/
private fun signRequest(original: Request): Request {
val url = original.url
// 你原代码里 token 从 EncryptedSharedPreferencesUtil.get(...) 取
// 这里保持一致:如果你项目里有这工具类就用它;没有就把 token 获取替换成你自己的方式
val token = try {
val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
user?.token.orEmpty()
} catch (_: Throwable) {
""
}
val timestamp = (System.currentTimeMillis() / 1000).toString()
val nonce = UUID.randomUUID().toString().replace("-", "").take(16)
val params = linkedMapOf<String, String>()
params["appId"] = APP_ID
params["timestamp"] = timestamp
params["nonce"] = nonce
// query 参数
for (i in 0 until url.querySize) {
params[url.queryParameterName(i)] = url.queryParameterValue(i).orEmpty()
}
// body 参数json / form
params.putAll(extractBodyParams(original))
val sign = calcSign(params, SECRET)
val builder = original.newBuilder()
.addHeader("Accept-Language", "lang")
.addHeader("X-App-Id", APP_ID)
.addHeader("X-Timestamp", timestamp)
.addHeader("X-Nonce", nonce)
.addHeader("X-Sign", sign)
.apply {
if (token.isNotBlank()) addHeader("auth-token", token)
}
val request = builder.build()
// ✅ 打印签名相关避免泄露sign/secret别全量打印到线上
Log.d(TAG, "signed -> X-App-Id=$APP_ID X-Timestamp=$timestamp X-Nonce=$nonce X-Sign=${sign.take(16)}... tokenPresent=${token.isNotBlank()}")
return request
}
// ==================== 签名计算(严格按你给的规则) ====================
private fun calcSign(params: Map<String, String>, secret: String): String {
val filtered = params
.filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) }
val sorted = filtered.toSortedMap()
val sb = StringBuilder()
sorted.forEach { (k, v) ->
if (sb.isNotEmpty()) sb.append("&")
sb.append(k).append("=").append(urlEncode(v))
}
sb.append("&secret=").append(urlEncode(secret))
return hmacSha256Hex(sb.toString(), secret)
}
private fun hmacSha256Hex(data: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
private fun urlEncode(v: String): String =
URLEncoder.encode(v, "UTF-8")
// ==================== body 参数提取json / form ====================
private fun extractBodyParams(request: Request): Map<String, String> {
val body = request.body ?: return emptyMap()
val ct = body.contentType()?.toString()?.lowercase().orEmpty()
return when {
ct.contains("application/json") -> extractJsonBody(body)
ct.contains("application/x-www-form-urlencoded") -> extractFormBody(body)
else -> emptyMap()
}
}
private fun extractJsonBody(body: RequestBody): Map<String, String> {
val raw = bodyToString(body).trim()
if (raw.isBlank()) return emptyMap()
return try {
// 这里只支持 JSON object/array 的扁平化primitive 忽略
val root = org.json.JSONTokener(raw).nextValue()
val out = linkedMapOf<String, String>()
flattenJsonAny(root, "", out)
out
} catch (_: Throwable) {
emptyMap()
}
}
private fun extractFormBody(body: RequestBody): Map<String, String> {
val raw = bodyToString(body)
if (raw.isBlank()) return emptyMap()
val map = linkedMapOf<String, String>()
raw.split("&")
.filter { it.isNotBlank() }
.forEach { pair ->
val idx = pair.indexOf("=")
if (idx > 0) {
val k = pair.substring(0, idx)
val v = pair.substring(idx + 1)
map[k] = URLDecoder.decode(v, "UTF-8")
} else {
map[pair] = ""
}
}
return map
}
private fun bodyToString(body: RequestBody): String {
return try {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
buffer.readString(charset)
} catch (_: Throwable) {
""
}
}
/**
* JSON 扁平化规则(尽量与你给的 gson flatten 保持一致):
* object: a.b.c
* array : items[0].id
*/
private fun flattenJsonAny(any: Any?, prefix: String, out: MutableMap<String, String>) {
when (any) {
null -> Unit
is JSONObject -> {
val keys = any.keys()
while (keys.hasNext()) {
val k = keys.next()
val newKey = if (prefix.isBlank()) k else "$prefix.$k"
flattenJsonAny(any.opt(k), newKey, out)
}
}
is org.json.JSONArray -> {
for (i in 0 until any.length()) {
val newKey = "$prefix[$i]"
flattenJsonAny(any.opt(i), newKey, out)
}
}
is Boolean, is Int, is Long, is Double, is Float -> {
if (prefix.isNotBlank()) out[prefix] = any.toString()
}
is String -> {
if (prefix.isNotBlank()) out[prefix] = any
}
else -> {
// 其它类型忽略
}
}
}
// ==================== SSE 读取JSON / 纯文本 chunk ====================
private fun readSseStream(
source: BufferedSource,
call: Call,
callback: LlmStreamCallback
) {
var eventName: String? = null
val dataLines = mutableListOf<String>()
fun dispatch() {
if (eventName == null && dataLines.isEmpty()) return
val rawData = dataLines.joinToString("\n")
Log.d("999-SSE_TALK-event", "event=${eventName ?: "(null)"} rawData=[${rawData.take(500)}]")
if (rawData.isNotEmpty()) {
handlePayload(eventName, rawData, callback)
}
eventName = null
dataLines.clear()
}
while (!source.exhausted() && !call.isCanceled()) {
val line = source.readUtf8Line() ?: break
Log.d("999-SSE_TALK-raw", "raw: [$line]")
if (line.isEmpty()) {
dispatch()
continue
}
if (line.startsWith(":")) continue
val idx = line.indexOf(':')
val field = if (idx == -1) line else line.substring(0, idx)
val rawValue = if (idx == -1) "" else line.substring(idx + 1)
val value = if (rawValue.startsWith(" ")) rawValue.substring(1) else rawValue
when (field) {
"event" -> eventName = value
"data" -> dataLines.add(value)
else -> Unit
}
}
dispatch()
}
private fun handlePayload(eventName: String?, rawData: String, callback: LlmStreamCallback) {
val trimmed = rawData.trim()
val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}")
if (looksLikeJson) {
try {
val obj = JSONObject(trimmed)
val type = obj.optString("type", "")
val dataValue = if (obj.has("data") && !obj.isNull("data")) obj.opt("data") else null
val dataStr = dataValue?.toString()
if (type.isNotBlank()) {
callback.onEvent(type, dataStr)
return
}
} catch (_: Throwable) {
// fallthrough to text
}
}
callback.onEvent(eventName ?: "text_chunk", rawData)
}
private fun peekOrReadBody(response: Response): String {
return try {
response.peekBody(1024 * 1024).string()
} catch (_: Throwable) {
try {
response.body?.string().orEmpty()
} catch (t2: Throwable) {
"read body failed: ${t2.message}"
}
}
}
}

View File

@@ -6,6 +6,7 @@ import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
import com.example.myapplication.network.FileUploadService
object RetrofitClient {
@@ -50,6 +51,13 @@ object RetrofitClient {
retrofit.create(ApiService::class.java)
}
/**
* 创建文件上传服务
*/
fun createFileUploadService(): FileUploadService {
return retrofit.create(FileUploadService::class.java)
}
/**
* 创建支持完整 URL 下载的 Retrofit 实例
* @param baseUrl 完整的下载 URL

View File

@@ -0,0 +1,137 @@
package com.example.myapplication.network.security
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import okhttp3.MultipartBody
import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import java.nio.charset.Charset
object BodyParamsExtractor {
/**
* 抽取 Request 的 body 参数到 map
* - JSON: 扁平化a.b[0].c
* - Form: 直接 key=value
* - Multipart: 只签文本字段(文件字段跳过)
*/
fun extractBodyParams(request: Request): Map<String, String> {
val body = request.body ?: return emptyMap()
val contentType = body.contentType()?.toString()?.lowercase().orEmpty()
return when {
contentType.contains("application/json") -> extractJsonBody(body)
contentType.contains("application/x-www-form-urlencoded") -> extractFormBody(body)
contentType.contains("multipart/form-data") -> extractMultipartBody(body)
else -> {
// 其他类型(例如 stream、protobuf、octet-stream
// 建议不签或签一个摘要(需要服务端同样实现)
emptyMap()
}
}
}
private fun extractJsonBody(body: RequestBody): Map<String, String> {
val json = bodyToString(body).trim()
if (json.isBlank()) return emptyMap()
return try {
val root: JsonElement = JsonParser.parseString(json)
val out = linkedMapOf<String, String>()
flattenJson(root, "", out)
out
} catch (_: Exception) {
// JSON 解析失败就不签 body也可以选择直接把原文作为 bodyRaw 参与签名)
emptyMap()
}
}
private fun extractFormBody(body: RequestBody): Map<String, String> {
// x-www-form-urlencoded 本质就是 querystringa=1&b=2
val raw = bodyToString(body)
if (raw.isBlank()) return emptyMap()
val map = linkedMapOf<String, String>()
raw.split("&")
.filter { it.isNotBlank() }
.forEach { pair ->
val idx = pair.indexOf("=")
if (idx > 0) {
val k = pair.substring(0, idx)
val v = pair.substring(idx + 1)
// 注意:这里 raw 是已经 urlencoded 的内容
// 为了与服务端一致,推荐:服务端拿到 form 参数的“解码后值”再参与签名
// 客户端这里可以不 decode改为后续签名阶段统一 encodeSignUtils 做了 encode
map[k] = java.net.URLDecoder.decode(v, "UTF-8")
} else {
map[pair] = ""
}
}
return map
}
private fun extractMultipartBody(body: RequestBody): Map<String, String> {
if (body !is MultipartBody) return emptyMap()
val map = linkedMapOf<String, String>()
for (i in 0 until body.parts.size) {
val part = body.parts[i]
val headers = part.headers
val disp = headers?.get("Content-Disposition").orEmpty()
// 取 name="xxx"
val name = Regex("""name="([^"]+)"""").find(disp)?.groupValues?.getOrNull(1) ?: continue
val filename = Regex("""filename="([^"]+)"""").find(disp)?.groupValues?.getOrNull(1)
// 有 filename 认为是文件字段:默认不签(避免读文件流/超大)
if (!filename.isNullOrBlank()) continue
// 文本字段:读出内容
val value = bodyToString(part.body).trim()
map[name] = value
}
return map
}
private fun bodyToString(body: RequestBody): String {
return try {
val buffer = Buffer()
body.writeTo(buffer)
val charset: Charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
buffer.readString(charset)
} catch (_: Exception) {
""
}
}
/**
* JSON 扁平化规则:
* object: a.b.c
* array: items[0].id
*/
private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap<String, String>) {
when {
elem.isJsonNull -> {
// null 不参与(也可以 out[prefix] = "null" 但需要服务端一致)
}
elem.isJsonPrimitive -> {
if (prefix.isNotBlank()) out[prefix] = elem.asJsonPrimitive.toString().trim('"')
}
elem.isJsonObject -> {
val obj = elem.asJsonObject
for ((k, v) in obj.entrySet()) {
val newKey = if (prefix.isBlank()) k else "$prefix.$k"
flattenJson(v, newKey, out)
}
}
elem.isJsonArray -> {
val arr = elem.asJsonArray
for (i in 0 until arr.size()) {
val newKey = "$prefix[$i]"
flattenJson(arr[i], newKey, out)
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package com.example.myapplication.network.security
import java.util.UUID
object NonceUtils {
fun genNonce(): String =
UUID.randomUUID().toString().replace("-", "").take(16)
fun genTimestampSeconds(): String =
(System.currentTimeMillis() / 1000).toString()
}

View File

@@ -0,0 +1,38 @@
package com.example.myapplication.network.security
import java.net.URLEncoder
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object SignUtils {
fun calcSign(params: Map<String, String>, secret: String): String {
val signStr = buildSignString(params, secret)
return hmacSha256Hex(signStr, secret)
}
fun buildSignString(params: Map<String, String>, secret: String): String {
val filtered = params
.filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) }
.toSortedMap()
val sb = StringBuilder()
filtered.forEach { (k, v) ->
if (sb.isNotEmpty()) sb.append("&")
sb.append(k).append("=").append(urlEncode(v))
}
sb.append("&secret=").append(urlEncode(secret))
return sb.toString()
}
private fun hmacSha256Hex(data: String, secret: String): String {
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
mac.init(keySpec)
val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
private fun urlEncode(v: String): String =
URLEncoder.encode(v, "UTF-8")
}

View File

@@ -1,19 +0,0 @@
package com.example.myapplication.ui.circle
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import com.example.myapplication.R
class CircleFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_circle, container, false)
}
}

View File

@@ -1,5 +1,7 @@
package com.example.myapplication.ui.common
import android.os.Looper
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -12,20 +14,33 @@ class LoadingOverlay private constructor(
companion object {
fun attach(parent: ViewGroup): LoadingOverlay {
val overlay = LayoutInflater.from(parent.context)
.inflate(R.layout.view_fullscreen_loading, parent, false)
.inflate(R.layout.view_fullscreen_loading, parent, false).apply {
visibility = View.GONE
bringToFront()
elevation = 100f // 确保在其它视图之上
}
overlay.visibility = View.GONE
parent.addView(overlay) // 加到最上层(最后添加的在最上面)
parent.addView(overlay)
return LoadingOverlay(parent, overlay)
}
}
fun show() {
overlay.visibility = View.VISIBLE
if (Looper.getMainLooper().thread == Thread.currentThread()) {
overlay.visibility = View.VISIBLE
} else {
overlay.post { overlay.visibility = View.VISIBLE }
}
Log.d("LoadingOverlay", "Show loading")
}
fun hide() {
overlay.visibility = View.GONE
if (Looper.getMainLooper().thread == Thread.currentThread()) {
overlay.visibility = View.GONE
} else {
overlay.post { overlay.visibility = View.GONE }
}
Log.d("LoadingOverlay", "Hide loading")
}
fun remove() {

View File

@@ -25,13 +25,21 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.ImeGuideActivity
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.R
import com.example.myapplication.network.*
import com.example.myapplication.network.AddPersonaClick
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.listByTagWithNotLogin
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import com.example.myapplication.network.PersonaClick
import com.example.myapplication.ui.home.PersonaAdapter
import kotlin.math.abs
class HomeFragment : Fragment() {
@@ -47,6 +55,7 @@ class HomeFragment : Fragment() {
private lateinit var tabList2: TextView
private lateinit var backgroundImage: ImageView
private var lastList1RenderKey: String? = null
private lateinit var loadingOverlay: LoadingOverlay
private var preloadJob: Job? = null
private var allPersonaCache: List<listByTagWithNotLogin> = emptyList()
@@ -85,6 +94,7 @@ class HomeFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
AuthEventBus.events.collect { event ->
@@ -95,13 +105,13 @@ class HomeFragment : Fragment() {
personaCache.clear()
allPersonaCache = emptyList()
lastList1RenderKey = null
// 2) 重新拉列表1登录态接口会变
viewLifecycleOwner.lifecycleScope.launch {
allPersonaCache = fetchAllPersonaList()
notifyPageChangedOnMain(0)
}
// 3) 如果当前在某个 tag 页,也建议重新拉当前页数据
val pos = viewPager.currentItem
if (pos > 0) {
@@ -114,21 +124,100 @@ class HomeFragment : Fragment() {
}
}
}
is AuthEvent.CharacterAdded -> {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
// 1) 列表一:重新拉
allPersonaCache = fetchAllPersonaList()
lastList1RenderKey = null
notifyPageChangedOnMain(0)
// 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
personaCache.clear()
// 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新)
val pos = viewPager.currentItem
if (pos > 0) {
val tagId = tags.getOrNull(pos - 1)?.id
if (tagId != null) {
// 先刷新一次,让页面进入 loading因为缓存被清了
notifyPageChangedOnMain(pos)
// 再拉当前 tag 的新数据
val list = fetchPersonaByTag(tagId)
personaCache[tagId] = list
notifyPageChangedOnMain(pos)
}
}
// 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍
startPreloadAllTagsFillCacheOnly()
} finally {
loadingOverlay.hide()
}
}
}
is AuthEvent.CharacterDeleted -> {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
// 1) 列表一:重新拉
allPersonaCache = fetchAllPersonaList()
lastList1RenderKey = null
notifyPageChangedOnMain(0)
// 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
personaCache.clear()
// 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新)
val pos = viewPager.currentItem
if (pos > 0) {
val tagId = tags.getOrNull(pos - 1)?.id
if (tagId != null) {
// 先刷新一次,让页面进入 loading因为缓存被清了
notifyPageChangedOnMain(pos)
// 再拉当前 tag 的新数据
val list = fetchPersonaByTag(tagId)
personaCache[tagId] = list
notifyPageChangedOnMain(pos)
}
}
// 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍
startPreloadAllTagsFillCacheOnly()
} finally {
loadingOverlay.hide()
}
}
}
else -> Unit
}
}
}
}
// 充值按钮点击 - 使用事件总线打开全局页面
view.findViewById<View>(R.id.rechargeButton).setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home_main",
"element_id" to "buy_vip_btn",
)
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.rechargeFragment))
}
// 输入法激活跳转
view.findViewById<ImageView>(R.id.floatingImage).setOnClickListener {
if (!isAdded) return@setOnClickListener
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home_main",
"element_id" to "permission_float_btn",
)
startActivity(Intent(requireActivity(), ImeGuideActivity::class.java))
}
@@ -141,11 +230,13 @@ class HomeFragment : Fragment() {
tabList2 = view.findViewById(R.id.tab_list2)
viewPager = view.findViewById(R.id.viewPager)
viewPager.isSaveEnabled = false
viewPager.offscreenPageLimit = 2
viewPager.offscreenPageLimit = 2
backgroundImage = bottomSheet.findViewById(R.id.backgroundImage)
val root = view.findViewById<CoordinatorLayout>(R.id.rootCoordinator)
val floatingImage = view.findViewById<ImageView>(R.id.floatingImage)
loadingOverlay = LoadingOverlay.attach(root)
Log.d("HomeFragment", "LoadingOverlay initialized")
root.post {
if (!isAdded) return@post
@@ -169,22 +260,26 @@ class HomeFragment : Fragment() {
// 加载列表一
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val list = fetchAllPersonaList()
if (!isAdded) return@launch
allPersonaCache = list
// ✅ 关键:数据变了就清 renderKey允许重建一次 UI
lastList1RenderKey = null
notifyPageChangedOnMain(0)
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "获取列表一失败", e)
} finally {
loadingOverlay.hide()
}
}
// 拉标签 + 预加载
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val response = RetrofitClient.apiService.tagList()
if (!isAdded) return@launch
@@ -204,11 +299,13 @@ class HomeFragment : Fragment() {
startPreloadAllTagsFillCacheOnly()
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "获取标签失败", e)
} finally {
loadingOverlay.hide()
}
}
}
// ================== 你要求的核心优化setupViewPager 只初始化一次 ==================
// ================== 核心setupViewPager 只初始化一次 ==================
private fun setupViewPagerOnce() {
if (sheetAdapter != null) return
@@ -223,7 +320,7 @@ class HomeFragment : Fragment() {
override fun onPageSelected(position: Int) {
if (!isAdded) return
updateTabsAndTags(position)
// ✅ 修复当切换到标签页且缓存已有数据时强制刷新UI
if (position > 0) {
val tagIndex = position - 1
@@ -245,6 +342,52 @@ class HomeFragment : Fragment() {
}
}
// ---------------- 方案A成功后“造新数据(copy)替换缓存”并刷新 ----------------
private fun applyAddedToggle(personaId: Int, newAdded: Boolean) {
// 1) 更新列表一缓存
run {
val oldAll = allPersonaCache
val idxAll = oldAll.indexOfFirst { it.id == personaId }
if (idxAll >= 0) {
val newList = oldAll.toMutableList()
val oldItem = newList[idxAll]
newList[idxAll] = oldItem.copy(added = newAdded)
allPersonaCache = newList
// renderList1 有 renderKey必须清一下
lastList1RenderKey = null
notifyPageChangedOnMain(0)
}
}
// 2) 更新所有 tag 缓存personaCache
val keys = personaCache.keys.toList()
var changedCurrentTagPage = false
for (tagId in keys) {
val old = personaCache[tagId] ?: continue
val idx = old.indexOfFirst { it.id == personaId }
if (idx >= 0) {
val newList = old.toMutableList()
val oldItem = newList[idx]
newList[idx] = oldItem.copy(added = newAdded)
personaCache[tagId] = newList
// 如果当前就在这个 tag 页,标记需要刷新
val pos = viewPager.currentItem
val currentTagId = tags.getOrNull(pos - 1)?.id
if (pos > 0 && currentTagId == tagId) {
changedCurrentTagPage = true
}
}
}
if (changedCurrentTagPage) {
notifyPageChangedOnMain(viewPager.currentItem)
}
}
// ---------------- 拖拽效果 ----------------
private fun initDrag(target: View, parent: ViewGroup) {
@@ -328,62 +471,52 @@ class HomeFragment : Fragment() {
private fun setupBottomSheet(root: View) {
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
bottomSheetBehavior.isDraggable = true
bottomSheetBehavior.isHideable = false
bottomSheetBehavior.isFitToContents = false
bottomSheetBehavior.halfExpandedRatio = 0.7f
root.post {
if (!isAdded) return@post
val coordinatorHeight = root.height - 40
val button = root.findViewById<View>(R.id.rechargeButton)
val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200)
bottomSheetBehavior.peekHeight = peek
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
// ✅ 固定初始高度,避免每次计算导致跳动
bottomSheetBehavior.peekHeight = dpToPx(260) // 你想要多少就写多少
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
scrim.isVisible = newState != BottomSheetBehavior.STATE_COLLAPSED
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (slideOffset >= 0f) scrim.alpha = slideOffset.coerceIn(0f, 1f)
}
})
scrim.setOnClickListener {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
scrim.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_MOVE) {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
true
} else false
}
header.setOnClickListener {
when (bottomSheetBehavior.state) {
BottomSheetBehavior.STATE_COLLAPSED ->
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
BottomSheetBehavior.STATE_HALF_EXPANDED,
BottomSheetBehavior.STATE_EXPANDED ->
bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
else -> {}
}
}
}
private fun dpToPx(dp: Int): Int {
return (dp * resources.displayMetrics.density).toInt()
}
// ---------------- Tabs ----------------
private fun setupTopTabs() {
tabList1.setOnClickListener { viewPager.currentItem = 0 }
tabList1.setOnClickListener {
viewPager.currentItem = 0
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home_rank",
)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home_rank_content",
)
}
tabList2.setOnClickListener {
if (tags.isNotEmpty()) viewPager.currentItem = 1
BehaviorReporter.report(
isNewUser = false,
"page_id" to "home_hot",
)
}
}
@@ -488,28 +621,28 @@ class HomeFragment : Fragment() {
private fun startPreloadAllTagsFillCacheOnly() {
preloadJob?.cancel()
val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2)
preloadJob = viewLifecycleOwner.lifecycleScope.launch {
if (tags.isEmpty()) return@launch
tags.forEach { tag ->
if (personaCache.containsKey(tag.id)) return@forEach
launch {
semaphore.acquire()
try {
val list = fetchPersonaByTag(tag.id)
personaCache[tag.id] = list
// ✅ 只在用户正在看的页时刷新一次(不算乱刷 UI
val idx = tags.indexOfFirst { it.id == tag.id }
val thisPos = 1 + idx
if (idx >= 0 && viewPager.currentItem == thisPos) {
notifyPageChangedOnMain(thisPos)
}
} catch (e: Exception) {
Log.e("HomeFragment", "preload tag=${tag.id} fail", e)
} finally {
@@ -519,7 +652,6 @@ class HomeFragment : Fragment() {
}
}
}
// ---------------- ViewPager Adapter ----------------
@@ -531,10 +663,10 @@ class HomeFragment : Fragment() {
fun updatePageCount(newCount: Int) {
if (newCount == pageCount) return
val old = pageCount
pageCount = newCount
if (newCount > old) {
notifyItemRangeInserted(old, newCount - old)
} else {
@@ -564,10 +696,7 @@ class HomeFragment : Fragment() {
val loadingView = root.findViewById<View>(R.id.loadingView)
rv2.setHasFixedSize(true)
// ✅ 禁止 itemAnimator减少 layout 抖动)
rv2.itemAnimator = null
rv2.isNestedScrollingEnabled = false
var adapter = rv2.adapter as? PersonaAdapter
@@ -584,22 +713,31 @@ class HomeFragment : Fragment() {
is PersonaClick.Add -> {
viewLifecycleOwner.lifecycleScope.launch {
val personaId = click.persona.id?: return@launch
val oldAdded = click.persona.added
val newAdded = !oldAdded
try {
if (click.persona.added == true) {
click.persona.id?.let { id ->
RetrofitClient.apiService.delUserCharacter(id.toInt())
}
} else {
if (oldAdded) {
// RetrofitClient.apiService.delUserCharacter(personaId)
// // ✅ 成功后替换缓存并刷新
// applyAddedToggle(personaId, newAdded)
}
else {
Log.d("1314520-HomeFragment", "add persona id=${click.persona.id}")
val req = AddPersonaClick(
characterId = click.persona.id?.toInt() ?: 0,
characterId = personaId,
emoji = click.persona.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
// ✅ 成功后替换缓存并刷新
applyAddedToggle(personaId, newAdded)
}
} catch (_: Exception) {
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "grid toggle add failed id=$personaId", e)
}
}
}
else -> Unit
}
}
rv2.layoutManager = GridLayoutManager(root.context, 2)
@@ -629,7 +767,7 @@ class HomeFragment : Fragment() {
override fun getItemCount(): Int = pageCount
}
// ---------------- 列表一渲染(原逻辑不动) ----------------
// ---------------- 列表一渲染 ----------------
private fun renderList1(root: View, list: List<listByTagWithNotLogin>) {
val key = buildString {
@@ -641,6 +779,7 @@ class HomeFragment : Fragment() {
}
if (key == lastList1RenderKey) return
lastList1RenderKey = key
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()
@@ -650,6 +789,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_first,
nameId = R.id.name_first,
addBtnId = R.id.btn_add_first,
addBtnIcon = R.id.add_first_icon,
containerId = R.id.container_first,
item = top3.getOrNull(0)
)
@@ -659,6 +799,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_second,
nameId = R.id.name_second,
addBtnId = R.id.btn_add_second,
addBtnIcon = R.id.add_second_icon,
containerId = R.id.container_second,
item = top3.getOrNull(1)
)
@@ -668,6 +809,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_third,
nameId = R.id.name_third,
addBtnId = R.id.btn_add_third,
addBtnIcon = R.id.add_third_icon,
containerId = R.id.container_third,
item = top3.getOrNull(2)
)
@@ -686,6 +828,62 @@ class HomeFragment : Fragment() {
val iv = itemView.findViewById<de.hdodenhof.circleimageview.CircleImageView>(R.id.iv_avatar)
com.bumptech.glide.Glide.with(iv).load(p.avatarUrl).into(iv)
// ---------------- add 按钮(失败回滚 + 防连点) ----------------
val addBtn = itemView.findViewById<LinearLayout>(R.id.btn_add)
val addIcon = itemView.findViewById<ImageView>(R.id.add_icon)
val originBg = addBtn.background
val originIcon = addIcon.drawable
fun renderAddState(added: Boolean) {
if (added) {
addBtn.setBackgroundResource(R.drawable.round_bg_others_already)
addIcon.setImageResource(R.drawable.ime_guide_activity_btn_completed_img)
} else {
addBtn.background = originBg
addIcon.setImageDrawable(originIcon)
}
}
// ✅ 首次渲染
renderAddState(p.added == true)
addBtn.setOnClickListener {
if (!addBtn.isEnabled) return@setOnClickListener
viewLifecycleOwner.lifecycleScope.launch {
val personaId = p.id?: return@launch
val oldAdded = p.added
val newAdded = !oldAdded
addBtn.isEnabled = false
renderAddState(newAdded)
try {
if (oldAdded) {
// RetrofitClient.apiService.delUserCharacter(personaId)
// // ✅ 只有成功才更新缓存 + 更新UI失败则保持原样
// applyAddedToggle(personaId, newAdded)
}
else {
Log.d("1314520-HomeFragment", "add persona id=${p.id}")
val req = AddPersonaClick(
characterId = personaId,
emoji = p.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
// ✅ 只有成功才更新缓存 + 更新UI失败则保持原样
applyAddedToggle(personaId, newAdded)
}
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "others toggle add failed id=$personaId", e)
renderAddState(oldAdded)
} finally {
addBtn.isEnabled = true
}
}
}
// ---------------- item 点击 ----------------
itemView.setOnClickListener {
if (!isAdded || childFragmentManager.isStateSaved) return@setOnClickListener
PersonaDetailDialogFragment
@@ -693,23 +891,6 @@ class HomeFragment : Fragment() {
.show(childFragmentManager, "persona_detail")
}
itemView.findViewById<View>(R.id.btn_add).setOnClickListener {
viewLifecycleOwner.lifecycleScope.launch {
try {
if (p.added == true) {
p.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) }
} else {
val req = AddPersonaClick(
characterId = p.id?.toInt() ?: 0,
emoji = p.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
}
} catch (_: Exception) {
}
}
}
container.addView(itemView)
}
}
@@ -719,12 +900,14 @@ class HomeFragment : Fragment() {
avatarId: Int,
nameId: Int,
addBtnId: Int,
addBtnIcon: Int,
containerId: Int,
item: listByTagWithNotLogin?
) {
val avatar = root.findViewById<de.hdodenhof.circleimageview.CircleImageView>(avatarId)
val name = root.findViewById<TextView>(nameId)
val addBtn = root.findViewById<View>(addBtnId)
val addBtn = root.findViewById<LinearLayout>(addBtnId)
val addIcon = root.findViewById<ImageView>(addBtnIcon)
val container = root.findViewById<LinearLayout>(containerId)
if (item == null) {
@@ -739,19 +922,60 @@ class HomeFragment : Fragment() {
name.text = item.characterName ?: ""
com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar)
// ✅ 记录“原始背景/原始icon”用于 added=false 时恢复
val originBg = addBtn.background
val originIconRes = when (addBtnId) {
R.id.btn_add_first -> R.drawable.first_add
R.id.btn_add_second -> R.drawable.second_add
R.id.btn_add_third -> R.drawable.third_add
else -> 0
}
fun renderAddState(added: Boolean) {
if (added) {
addBtn.setBackgroundResource(R.drawable.round_bg_others_already)
addIcon.setImageResource(R.drawable.ime_guide_activity_btn_completed_img)
} else {
addBtn.background = originBg
if (originIconRes != 0) addIcon.setImageResource(originIconRes)
}
}
// ✅ 首次渲染
renderAddState(item.added == true)
// ✅ 点击:失败回滚 + 防连点(请求中禁用按钮)
addBtn.setOnClickListener {
if (!addBtn.isEnabled) return@setOnClickListener
viewLifecycleOwner.lifecycleScope.launch {
val personaId = item.id?: return@launch
val oldAdded = item.added
val newAdded = !oldAdded
addBtn.isEnabled = false
renderAddState(newAdded)
try {
if (item.added == true) {
item.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) }
} else {
if (oldAdded) {
// RetrofitClient.apiService.delUserCharacter(personaId)
// // ✅ 只有成功才更新缓存 + 更新UI
// applyAddedToggle(personaId, newAdded)
}
else {
Log.d("1314520-HomeFragment", "add persona id=${item.id}")
val req = AddPersonaClick(
characterId = item.id?.toInt() ?: 0,
characterId = personaId,
emoji = item.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
// ✅ 只有成功才更新缓存 + 更新UI
applyAddedToggle(personaId, newAdded)
}
} catch (_: Exception) {
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "others toggle add failed id=$personaId", e)
renderAddState(oldAdded)
} finally {
addBtn.isEnabled = true
}
}
}

View File

@@ -3,14 +3,15 @@ package com.example.myapplication.ui.home
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
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 com.example.myapplication.network.listByTagWithNotLogin
import de.hdodenhof.circleimageview.CircleImageView
import android.util.Log
class PersonaAdapter(
private val onClick: (PersonaClick) -> Unit
@@ -26,35 +27,37 @@ class PersonaAdapter(
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)
private val ivAvatar: CircleImageView = itemView.findViewById(R.id.ivAvatar)
private val tvName: TextView = itemView.findViewById(R.id.tvName)
private val characterBackground: TextView = itemView.findViewById(R.id.characterBackground)
private val download: TextView = itemView.findViewById(R.id.download)
private val operation: LinearLayout = itemView.findViewById(R.id.operation)
private val operationIcon: ImageView = itemView.findViewById(R.id.operation_add_icon)
/** ✅ 统一绑定 + 点击逻辑 */
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))
}
val isAdded = item.added
// ✅ 背景改 operation外层容器
operation.setBackgroundResource(
if (isAdded) R.drawable.list_two_bg_already else R.drawable.list_two_bg
)
// ✅ 图标改 operationIcon中间图
operationIcon.setImageResource(
if (isAdded) R.drawable.ime_guide_activity_btn_completed_img else R.drawable.operation_add
)
itemView.setOnClickListener { onClick(PersonaClick.Item(item)) }
operation.setOnClickListener { onClick(PersonaClick.Add(item)) }
}
}

View File

@@ -15,6 +15,9 @@ import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.CharacterDetailResponse
import kotlinx.coroutines.launch
import com.example.myapplication.network.AddPersonaClick
import android.util.Log
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
class PersonaDetailDialogFragment : DialogFragment() {
@@ -59,8 +62,9 @@ class PersonaDetailDialogFragment : DialogFragment() {
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)
btnAdd.text = if (data.added == true) "Added" else "Add"
btnAdd.setBackgroundResource(if (data.added == true) R.drawable.ic_added else R.drawable.keyboard_ettings)
val newAdded = !(data.added ?: false)
Glide.with(requireContext())
.load(data.avatarUrl)
@@ -72,13 +76,13 @@ class PersonaDetailDialogFragment : DialogFragment() {
lifecycleScope.launch {
if(data.added == true){
//取消收藏
data.id?.let { id ->
try {
RetrofitClient.apiService.delUserCharacter(id.toInt())
} catch (e: Exception) {
// 处理错误
}
}
// data.id?.let { id ->
// try {
// RetrofitClient.apiService.delUserCharacter(id.toInt())
// } catch (e: Exception) {
// // 处理错误
// }
// }
}else{
val addPersonaRequest = AddPersonaClick(
characterId = data.id?.toInt() ?: 0,
@@ -86,8 +90,11 @@ class PersonaDetailDialogFragment : DialogFragment() {
)
try {
RetrofitClient.apiService.addUserCharacter(addPersonaRequest)
data.id?.let { personaId ->
AuthEventBus.emit(AuthEvent.CharacterAdded(personaId,newAdded))
}
} catch (e: Exception) {
// 处理错误
Log.e("1314520-PersonaDetailDialogFragment", "addUserCharacter error", e)
}
}
dismissAllowingStateLoss()

View File

@@ -0,0 +1,72 @@
package com.example.myapplication.ui.keyboard
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Window
import android.widget.TextView
import androidx.fragment.app.DialogFragment
import com.example.myapplication.R
class ConfirmDeleteDialogFragment : DialogFragment() {
companion object {
private const val ARG_NAME = "arg_name"
private const val ARG_EMOJI = "arg_emoji"
fun newInstance(
characterName: String?,
emoji: String?,
onConfirm: () -> Unit
): ConfirmDeleteDialogFragment {
return ConfirmDeleteDialogFragment().apply {
this.onConfirm = onConfirm
arguments = Bundle().apply {
putString(ARG_NAME, characterName ?: "")
putString(ARG_EMOJI, emoji ?: "🙂")
}
}
}
}
private var onConfirm: (() -> Unit)? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = Dialog(requireContext())
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
val v = LayoutInflater.from(requireContext())
.inflate(R.layout.dialog_confirm_delete_character, null, false)
dialog.setContentView(v)
dialog.setCancelable(true)
dialog.setCanceledOnTouchOutside(true)
val name = arguments?.getString(ARG_NAME).orEmpty()
val emoji = arguments?.getString(ARG_EMOJI) ?: "🙂"
v.findViewById<TextView>(R.id.tv_name).text = name
v.findViewById<TextView>(R.id.tv_emoji).text = emoji
v.findViewById<TextView>(R.id.btn_cancel).setOnClickListener {
dismissAllowingStateLoss()
}
v.findViewById<TextView>(R.id.btn_confirm).setOnClickListener {
dismissAllowingStateLoss()
onConfirm?.invoke()
}
// 宽度更贴合弹窗
dialog.window?.setLayout(
(resources.displayMetrics.widthPixels * 0.86f).toInt(),
android.view.ViewGroup.LayoutParams.WRAP_CONTENT
)
return dialog
}
override fun onDestroyView() {
super.onDestroyView()
onConfirm = null
}
}

View File

@@ -0,0 +1,68 @@
package com.example.myapplication.ui.keyboard
import android.view.HapticFeedbackConstants
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
class DragSortCallback(
private val onMove: (from: Int, to: Int) -> Unit
) : ItemTouchHelper.Callback() {
private var didHaptic = false
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = false
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
return makeMovementFlags(dragFlags, 0)
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
super.onSelectedChanged(viewHolder, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) {
// ✅ 触觉反馈(只触发一次)
if (!didHaptic) {
viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
didHaptic = true
}
// ✅ 视觉反馈:放大 + 半透明
viewHolder.itemView.animate()
.scaleX(1.03f).scaleY(1.03f)
.alpha(0.85f)
.setDuration(120)
.start()
}
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
didHaptic = false
// ✅ 结束拖动,恢复视觉
viewHolder.itemView.animate()
.scaleX(1f).scaleY(1f)
.alpha(1f)
.setDuration(120)
.start()
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
if (from == RecyclerView.NO_POSITION || to == RecyclerView.NO_POSITION) return false
onMove(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
}

View File

@@ -0,0 +1,57 @@
package com.example.myapplication.ui.keyboard
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.example.myapplication.network.ListByUserWithNot
class KeyboardAdapter(
private val onItemClick: (ListByUserWithNot) -> Unit
) : RecyclerView.Adapter<KeyboardAdapter.VH>() {
private val items = mutableListOf<ListByUserWithNot>()
fun submitList(newList: List<ListByUserWithNot>) {
items.clear()
items.addAll(newList)
notifyDataSetChanged()
}
fun getCurrentIdsInOrder(): List<Int> = items.map { it.id }
fun moveItem(from: Int, to: Int) {
if (from == to) return
val item = items.removeAt(from)
items.add(to, item)
notifyItemMoved(from, to)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.item_keyboard_character, parent, false)
return VH(v)
}
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val root: View = itemView.findViewById(R.id.item_root)
private val tvEmoji: TextView = itemView.findViewById(R.id.tv_emoji)
private val tvName: TextView = itemView.findViewById(R.id.tv_name)
fun bind(item: ListByUserWithNot) {
tvEmoji.text = item.emoji ?: "🙂"
tvName.text = item.characterName ?: ""
// ✅ 点击整卡(不直接删,交给外层弹窗确认)
root.setOnClickListener { onItemClick(item) }
}
}
}

View File

@@ -42,6 +42,7 @@ import java.io.BufferedInputStream
import java.io.FileInputStream
import com.example.myapplication.ui.shop.ShopEvent
import com.example.myapplication.ui.shop.ShopEventBus
import com.example.myapplication.network.BehaviorReporter
class KeyboardDetailFragment : Fragment() {
@@ -57,6 +58,7 @@ class KeyboardDetailFragment : Fragment() {
private lateinit var enabledButtonText: TextView
private lateinit var progressBar: android.widget.ProgressBar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private var themeDetailResp: themeDetail? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -80,7 +82,7 @@ class KeyboardDetailFragment : Fragment() {
enabledButtonText = view.findViewById<TextView>(R.id.enabledButtonText)
progressBar = view.findViewById<android.widget.ProgressBar>(R.id.progressBar)
swipeRefreshLayout = view.findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 设置按钮始终防止事件穿透的触摸监听器
enabledButton.setOnTouchListener { _, event ->
// 如果按钮被禁用,消耗所有触摸事件防止穿透
@@ -120,6 +122,13 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
enableTheme()
}
BehaviorReporter.report(
isNewUser = false,
"page_id" to "skin_detail",
"element_id" to "download_btn",
"theme_id" to themeId,
"purchased" to if (themeDetailResp?.isPurchased == true) "1" else "0",
)
}
}
@@ -132,7 +141,7 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
try {
val themeDetailResp = getThemeDetail(themeId)?.data
themeDetailResp = getThemeDetail(themeId)?.data
val recommendThemeListResp = getrecommendThemeList()?.data
Glide.with(requireView().context)
@@ -333,6 +342,13 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
setpurchaseTheme(themeId)
dialog.dismiss()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "skin_detail",
"element_id" to "download_btn",
"theme_id" to themeId,
"purchased" to if (themeDetailResp?.isPurchased == true) "1" else "0",
)
}
}

View File

@@ -1,28 +1,150 @@
package com.example.myapplication.ui.keyboard
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 android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.network.ListByUserWithNot
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.updateUserCharacterSortRequest
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
class MyKeyboard : Fragment() {
private lateinit var rv: RecyclerView
private lateinit var btnSave: TextView
private lateinit var adapter: KeyboardAdapter
private lateinit var loadingOverlay: LoadingOverlay
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.my_keyboard, container, false)
}
): View = inflater.inflate(R.layout.my_keyboard, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack()
requireActivity().onBackPressedDispatcher.onBackPressed()
}
rv = view.findViewById(R.id.rv_keyboard)
btnSave = view.findViewById(R.id.btn_keyboard)
adapter = KeyboardAdapter(
onItemClick = { item ->
// ✅ 点击卡片:弹自定义确认弹窗
ConfirmDeleteDialogFragment
.newInstance(item.characterName, item.emoji) {
// ✅ 用户确认后才删除
viewLifecycleOwner.lifecycleScope.launch {
val resp = setdelUserCharacter(item.id)
if (resp?.code == 0 && resp.data == true) {
Toast.makeText(requireContext(),"Deleted successfully", Toast.LENGTH_SHORT).show()
AuthEventBus.emit(AuthEvent.CharacterDeleted(item.id))
loadList()
} else {
Toast.makeText(requireContext(), resp?.message ?: "Delete failed", Toast.LENGTH_SHORT).show()
}
}
}
.show(parentFragmentManager, "confirm_delete")
}
)
rv.layoutManager = GridLayoutManager(requireContext(), 2)
rv.adapter = adapter
// ✅ 长按拖动排序 + 反馈
ItemTouchHelper(
DragSortCallback { from, to ->
adapter.moveItem(from, to)
}
).attachToRecyclerView(rv)
loadingOverlay = LoadingOverlay.attach(view as ViewGroup)
loadList()
// ✅ Save上传当前排序id数组
btnSave.setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my_keyboard",
"element_id" to "save_btn",
)
val sortIds = adapter.getCurrentIdsInOrder()
Log.d("MyKeyboard-sort", sortIds.toString())
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val body = updateUserCharacterSortRequest(sort = sortIds)
val resp = setupdateUserCharacterSort(body)
if (resp?.code == 0 && resp.data == true) {
requireActivity().onBackPressedDispatcher.onBackPressed()
Toast.makeText(requireContext(), "Sorting has been successfully modified.", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), resp?.message ?: "Save failed", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Network error: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
loadingOverlay.hide()
}
}
}
}
private fun loadList() {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val resp = getlistByUser()
if (resp?.code == 0 && resp.data != null) {
adapter.submitList(resp.data)
Log.d("1314520-list", resp.data.toString())
} else {
Toast.makeText(requireContext(), resp?.message ?: "Load failed", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(requireContext(), "Load failed: ${e.message}", Toast.LENGTH_SHORT).show()
} finally {
loadingOverlay.hide()
}
}
}
// 获取用户人设列表
private suspend fun getlistByUser(): ApiResponse<List<ListByUserWithNot>>? =
runCatching { RetrofitClient.apiService.listByUser() }.getOrNull()
// 更新用户人设排序
private suspend fun setupdateUserCharacterSort(body: updateUserCharacterSortRequest): ApiResponse<Boolean>? =
runCatching { RetrofitClient.apiService.updateUserCharacterSort(body) }.getOrNull()
// 删除用户人设
private suspend fun setdelUserCharacter(id: Int): ApiResponse<Boolean>? {
loadingOverlay.show()
return try {
runCatching { RetrofitClient.apiService.delUserCharacter(id) }.getOrNull()
} finally {
loadingOverlay.hide()
}
}
}

View File

@@ -18,6 +18,7 @@ import com.example.myapplication.network.SendVerifyCodeRequest
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import android.util.Log
import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordEmailFragment : Fragment() {
@@ -55,6 +56,11 @@ class ForgetPasswordEmailFragment : Fragment() {
} else if (!isValidEmail(email)) {
Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show()
} else {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "forgot_password_email",
"element_id" to "next_btn",
)
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {

View File

@@ -21,6 +21,7 @@ import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordResetFragment : Fragment() {
@@ -110,6 +111,11 @@ class ForgetPasswordResetFragment : Fragment() {
} else if (password != confirmPassword) {
Toast.makeText(activity, "The two password entries are inconsistent", Toast.LENGTH_SHORT).show()
} else {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "forgot_password_newpwd",
"element_id" to "next_btn",
)
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.VerifyCodeRequest
import android.util.Log
import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordVerifyFragment : Fragment() {
@@ -59,6 +60,11 @@ class ForgetPasswordVerifyFragment : Fragment() {
// 显示加载遮罩层
loadingOverlay?.show()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "forgot_password_verify",
"element_id" to "next_btn",
)
viewLifecycleOwner.lifecycleScope.launch {
try {
val body = VerifyCodeRequest(

View File

@@ -26,6 +26,7 @@ import com.example.myapplication.ui.mine.MineFragment
import androidx.core.os.bundleOf
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.BehaviorReporter
class LoginFragment : Fragment() {
@@ -57,10 +58,20 @@ class LoginFragment : Fragment() {
// 注册
view.findViewById<TextView>(R.id.tv_signup).setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "login",
"element_id" to "signup_btn",
)
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
}
// 忘记密码
view.findViewById<TextView>(R.id.tv_forgot_password).setOnClickListener {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "login",
"element_id" to "forgot_btn",
)
findNavController().navigate(R.id.action_loginFragment_to_forgetPasswordEmailFragment)
}
// 返回 - 在global_graph中直接popBackStack回到globalEmptyFragment
@@ -110,6 +121,12 @@ class LoginFragment : Fragment() {
// 输入框不能为空
Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show()
} else {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "login_email",
"element_id" to "submit_btn",
)
loadingOverlay?.show()
// 调用登录API
lifecycleScope.launch {
@@ -125,6 +142,7 @@ class LoginFragment : Fragment() {
EncryptedSharedPreferencesUtil.save(requireContext(), "email",email)
// 触发登录成功事件让MainActivity关闭全局overlay
AuthEventBus.emit(AuthEvent.LoginSuccess)
AuthEventBus.emit(AuthEvent.CharacterDeleted(0))
// 不在这里popBackStack让MainActivity的LoginSuccess事件处理关闭全局overlay
} else {
Toast.makeText(requireContext(), "Login failed: ${response.message}", Toast.LENGTH_SHORT).show()

View File

@@ -20,6 +20,7 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.example.myapplication.ui.common.LoadingOverlay
import kotlinx.coroutines.launch
import android.util.Log
import com.example.myapplication.network.BehaviorReporter
class RegisterFragment : Fragment() {
@@ -113,6 +114,11 @@ class RegisterFragment : Fragment() {
} else if (!isValidEmail(email)) {
Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show()
} else {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "register_email",
"element_id" to "submit_btn",
)
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {

View File

@@ -20,6 +20,7 @@ import com.example.myapplication.ui.common.CodeEditText
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import kotlinx.coroutines.launch
import android.widget.TextView
import com.example.myapplication.network.BehaviorReporter
class RegisterVerifyFragment : Fragment() {
@@ -73,7 +74,11 @@ class RegisterVerifyFragment : Fragment() {
}
loadingOverlay?.show()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "register_verify_email",
"element_id" to "confirm_btn",
)
viewLifecycleOwner.lifecycleScope.launch {
try {
val body = RegisterRequest(

View File

@@ -1,5 +1,8 @@
package com.example.myapplication.ui.mine
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
@@ -17,22 +20,30 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.LoginResponse
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.ShareResponse
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import de.hdodenhof.circleimageview.CircleImageView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
class MineFragment : Fragment() {
private lateinit var nickname: TextView
private lateinit var time: TextView
private lateinit var logout: TextView
private lateinit var avatar: CircleImageView
private lateinit var share: LinearLayout
private lateinit var loadingOverlay: LoadingOverlay
private var loadUserJob: Job? = null
@@ -48,6 +59,7 @@ class MineFragment : Fragment() {
override fun onDestroyView() {
loadUserJob?.cancel()
loadingOverlay.remove()
super.onDestroyView()
}
@@ -57,24 +69,44 @@ class MineFragment : Fragment() {
nickname = view.findViewById(R.id.nickname)
time = view.findViewById(R.id.time)
logout = view.findViewById(R.id.logout)
avatar = view.findViewById(R.id.avatar)
share = view.findViewById(R.id.click_Share)
loadingOverlay = LoadingOverlay.attach(view.findViewById(R.id.rootCoordinator))
// 1) 先用本地缓存秒出首屏
renderFromCache()
// 2) 首次进入不刷新由onResume处理
// // ✅ 手动刷新:不改布局也能用
// // - 点昵称刷新
// nickname.setOnClickListener { refreshUser(force = true, showToast = true) }
// // - 长按 time 刷新
// time.setOnLongClickListener {
// refreshUser(force = true, showToast = true)
// true
// }
share.setOnClickListener {
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val response = getinviteCode()
response?.data?.h5Link?.let { link ->
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("h5Link", link)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "The sharing link has been copied to the clipboard.", Toast.LENGTH_LONG).show()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my",
"element_id" to "invite_copy",
)
}
} finally {
loadingOverlay.hide()
}
}
}
logout.setOnClickListener {
LogoutDialogFragment { doLogout() }
.show(parentFragmentManager, "logout_dialog")
BehaviorReporter.report(
isNewUser = false,
"page_id" to "person_info",
"element_id" to "logout_btn",
)
}
view.findViewById<ImageView>(R.id.imgLeft).setOnClickListener {
@@ -85,17 +117,43 @@ class MineFragment : Fragment() {
// 使用事件总线打开金币充值页面
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
view.findViewById<CircleImageView>(R.id.avatar).setOnClickListener {
safeNavigate(R.id.action_mineFragment_to_personalSettings)
avatar.setOnClickListener {
// 个人设置页面
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.PersonalSettings))
}
view.findViewById<LinearLayout>(R.id.keyboard_settings).setOnClickListener {
safeNavigate(R.id.action_mineFragment_to_mykeyboard)
// 我的键盘页面
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.MyKeyboard))
}
view.findViewById<LinearLayout>(R.id.click_record).setOnClickListener {
//消费记录
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.consumptionRecordFragment))
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my",
"element_id" to "menu_item",
"item_title" to "消费记录"
)
}
view.findViewById<LinearLayout>(R.id.click_Feedback).setOnClickListener {
safeNavigate(R.id.action_mineFragment_to_feedbackFragment)
//反馈
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.feedbackFragment))
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my",
"element_id" to "menu_item",
"item_title" to "反馈"
)
}
view.findViewById<LinearLayout>(R.id.click_Notice).setOnClickListener {
safeNavigate(R.id.action_mineFragment_to_notificationFragment)
//通知
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.notificationFragment))
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my",
"element_id" to "menu_item",
"item_title" to "通知"
)
}
// ✅ 监听登录成功/登出事件(跨 NavHost 可靠)
@@ -108,6 +166,10 @@ class MineFragment : Fragment() {
renderFromCache()
refreshUser(force = true, showToast = false)
}
AuthEvent.UserUpdated -> {
renderFromCache()
refreshUser(force = true, showToast = false)
}
else -> Unit
}
}
@@ -131,6 +193,11 @@ class MineFragment : Fragment() {
)
nickname.text = cached?.nickName ?: ""
time.text = cached?.vipExpiry?.let { "Due on November $it" } ?: ""
cached?.avatarUrl?.let { url ->
Glide.with(requireContext())
.load(url)
.into(avatar)
}
}
/**
@@ -154,15 +221,22 @@ class MineFragment : Fragment() {
Log.d(TAG, "getUser ok: nick=${u?.nickName} vip=${u?.vipExpiry}")
nickname.text = u?.nickName ?: ""
time.text = u?.vipExpiry?.let { "Due on November $it" } ?: ""
u?.avatarUrl?.let { url ->
Glide.with(requireContext())
.load(url)
.into(avatar)
}
EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", u)
if (showToast) Toast.makeText(requireContext(), "已刷新", Toast.LENGTH_SHORT).show()
if (showToast) Toast.makeText(requireContext(), "Refreshed", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) return@launch
Log.e(TAG, "getUser failed", e)
if (showToast && isAdded) Toast.makeText(requireContext(), "刷新失败", Toast.LENGTH_SHORT).show()
if (showToast && isAdded) Toast.makeText(requireContext(), "Refresh failed", Toast.LENGTH_SHORT).show()
}
}
}
@@ -180,6 +254,9 @@ class MineFragment : Fragment() {
// 清空 UI
nickname.text = ""
time.text = ""
Glide.with(requireContext())
.load(R.drawable.default_avatar)
.into(avatar)
// 触发登出事件让MainActivity打开登录页面
AuthEventBus.emit(AuthEvent.Logout(returnTabTag = "tab_mine"))
@@ -209,4 +286,7 @@ class MineFragment : Fragment() {
companion object {
private const val TAG = "1314520-MineFragment"
}
private suspend fun getinviteCode(): ApiResponse<ShareResponse>? =
runCatching<ApiResponse<ShareResponse>> { RetrofitClient.apiService.inviteCode() }.getOrNull()
}

View File

@@ -0,0 +1,174 @@
package com.example.myapplication.ui.mine.consumptionRecord
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.example.myapplication.network.TransactionRecord
class TransactionAdapter(
private val data: MutableList<TransactionRecord>,
private val onCloseClick: () -> Unit,
private val onRechargeClick: () -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_HEADER = 0
private const val TYPE_ITEM = 1
private const val TYPE_FOOTER = 2
}
// Header: balance
private var headerBalanceText: String = "0.00"
// Footer state
private var showFooter: Boolean = false
private var footerNoMore: Boolean = false
fun updateHeaderBalance(text: Any?) {
headerBalanceText = (text ?: "0.00").toString()
notifyItemChanged(0)
}
fun setFooterLoading() {
showFooter = true
footerNoMore = false
notifyDataSetChanged()
}
fun setFooterNoMore() {
showFooter = true
footerNoMore = true
notifyDataSetChanged()
}
fun hideFooter() {
showFooter = false
footerNoMore = false
notifyDataSetChanged()
}
fun replaceAll(list: List<TransactionRecord>) {
data.clear()
data.addAll(list)
notifyDataSetChanged()
}
fun append(list: List<TransactionRecord>) {
if (list.isEmpty()) return
val start = 1 + data.size // header占1
data.addAll(list)
notifyItemRangeInserted(start, list.size)
}
override fun getItemCount(): Int {
// header + items + optional footer
return 1 + data.size + if (showFooter) 1 else 0
}
override fun getItemViewType(position: Int): Int {
return when {
position == 0 -> TYPE_HEADER
showFooter && position == itemCount - 1 -> TYPE_FOOTER
else -> TYPE_ITEM
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_HEADER -> {
val v = inflater.inflate(R.layout.layout_consumption_record_header, parent, false)
HeaderVH(v, onCloseClick, onRechargeClick)
}
TYPE_FOOTER -> {
val v = inflater.inflate(R.layout.item_loading_footer, parent, false)
FooterVH(v)
}
else -> {
val v = inflater.inflate(R.layout.item_transaction_record, parent, false)
ItemVH(v)
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind(headerBalanceText)
is FooterVH -> holder.bind(footerNoMore)
is ItemVH -> holder.bind(data[position - 1]) // position-1 because header
}
}
class HeaderVH(
itemView: View,
onCloseClick: () -> Unit,
onRechargeClick: () -> Unit
) : RecyclerView.ViewHolder(itemView) {
private val balance: TextView = itemView.findViewById(R.id.balance)
init {
itemView.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener { onCloseClick() }
itemView.findViewById<View>(R.id.rechargeButton).setOnClickListener { onRechargeClick() }
}
fun bind(balanceText: String) {
balance.text = balanceText
adjustBalanceTextSize(balance, balanceText)
}
private fun adjustBalanceTextSize(tv: TextView, text: String) {
tv.textSize = when (text.length) {
in 0..3 -> 40f
4 -> 36f
5 -> 32f
6 -> 28f
7 -> 24f
8 -> 22f
9 -> 20f
else -> 16f
}
}
}
class ItemVH(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvTime: TextView = itemView.findViewById(R.id.tvTime)
private val tvDesc: TextView = itemView.findViewById(R.id.tvDesc)
private val tvAmount: TextView = itemView.findViewById(R.id.tvAmount)
fun bind(item: TransactionRecord) {
tvTime.text = item.createdAt
tvDesc.text = item.description
tvAmount.text = "${item.amount}"
// 根据type设置字体颜色
val color = when (item.type) {
1 -> Color.parseColor("#CD2853") // 收入 - 红色
2 -> Color.parseColor("#66CD7C") // 支出 - 绿色
else -> tvAmount.currentTextColor // 保持当前颜色
}
tvAmount.setTextColor(color)
}
}
class FooterVH(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val progress: ProgressBar = itemView.findViewById(R.id.progress)
private val tv: TextView = itemView.findViewById(R.id.tvLoading)
fun bind(noMore: Boolean) {
if (noMore) {
progress.visibility = View.GONE
tv.text = "No more"
} else {
progress.visibility = View.VISIBLE
tv.text = "Loading..."
}
}
}
}

View File

@@ -0,0 +1,181 @@
package com.example.myapplication.ui.mine.consumptionRecord
import android.app.Dialog
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
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.TransactionRecord
import com.example.myapplication.network.Wallet
import com.example.myapplication.network.transactionsRequest
import com.example.myapplication.network.transactionsResponse
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import kotlinx.coroutines.launch
class ConsumptionRecordFragment : BottomSheetDialogFragment() {
private lateinit var swipeRefresh: SwipeRefreshLayout
private lateinit var rv: RecyclerView
private lateinit var adapter: TransactionAdapter
private val listData = arrayListOf<TransactionRecord>()
private var pageNum = 1
private val pageSize = 10
private var totalPages = Int.MAX_VALUE
private var isLoading = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_consumption_record, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
swipeRefresh = view.findViewById(R.id.swipeRefresh)
rv = view.findViewById(R.id.rvTransactions)
setupRecycler()
setupRefresh()
refreshAll()
}
/**
* ✅ 重点:关闭必须走 NavController popBackStack
* 不要 dismiss(),否则 global_nav 栈不会变,底部导航就会一直被隐藏
*/
private fun closeByNav() {
runCatching {
findNavController().popBackStack()
}.onFailure {
// 万一不是走 nav 打开的(极少情况),再兜底 dismiss
dismissAllowingStateLoss()
}
}
override fun onCancel(dialog: DialogInterface) {
super.onCancel(dialog)
// ✅ 用户手势下拉/点外部取消,也要 pop 返回栈
closeByNav()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
// ✅ 有些机型/场景只走 onDismiss不走 onCancel双保险
closeByNav()
}
private fun setupRecycler() {
adapter = TransactionAdapter(
data = listData,
onCloseClick = { closeByNav() }, // ✅ 改这里:不要 dismiss()
onRechargeClick = {
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
)
rv.layoutManager = LinearLayoutManager(requireContext())
rv.adapter = adapter
rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (newState != RecyclerView.SCROLL_STATE_IDLE) return
val reachedBottom = !recyclerView.canScrollVertically(1)
if (!reachedBottom) return
if (!isLoading && pageNum < totalPages) {
loadMore()
} else if (!isLoading && pageNum >= totalPages) {
adapter.setFooterNoMore()
}
}
})
}
private fun setupRefresh() {
swipeRefresh.setOnRefreshListener { refreshAll() }
}
private fun refreshAll() {
lifecycleScope.launch {
swipeRefresh.isRefreshing = true
pageNum = 1
totalPages = Int.MAX_VALUE
isLoading = false
adapter.hideFooter()
adapter.replaceAll(emptyList())
val walletResp = getwalletBalance()
val balanceText = walletResp?.data?.balanceDisplay ?: "0.00"
adapter.updateHeaderBalance(balanceText)
loadPage(targetPage = 1, isRefresh = true)
swipeRefresh.isRefreshing = false
}
}
private fun loadMore() {
lifecycleScope.launch { loadPage(targetPage = pageNum + 1, isRefresh = false) }
}
private suspend fun loadPage(targetPage: Int, isRefresh: Boolean) {
if (isLoading) return
isLoading = true
if (!isRefresh) adapter.setFooterLoading()
val body = transactionsRequest(pageNum = targetPage, pageSize = pageSize)
val resp = gettransactions(body)
val data = resp?.data
if (data != null) {
totalPages = data.pages
pageNum = data.current
val records = data.records
if (isRefresh) adapter.replaceAll(records) else adapter.append(records)
if (pageNum >= totalPages) adapter.setFooterNoMore() else adapter.hideFooter()
rv.post {
val notScrollableYet = !rv.canScrollVertically(1)
if (!isLoading && notScrollableYet && pageNum < totalPages) {
loadMore()
}
}
} else {
adapter.hideFooter()
}
isLoading = false
}
// ========================网络请求===========================================
private suspend fun getwalletBalance(): ApiResponse<Wallet>? =
runCatching { RetrofitClient.apiService.walletBalance() }.getOrNull()
private suspend fun gettransactions(body: transactionsRequest): ApiResponse<transactionsResponse>? =
runCatching { RetrofitClient.apiService.transactions(body) }.getOrNull()
}

View File

@@ -2,19 +2,24 @@ package com.example.myapplication.ui.mine.myotherpages
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.example.myapplication.R
import android.widget.FrameLayout
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputLayout
import java.util.*
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.feedbackRequest
import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
class FeedbackFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -26,9 +31,56 @@ class FeedbackFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 设置关闭按钮点击事件
// 关闭按钮
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack()
}
// 让多行输入框:不聚焦也能上下滑动内容
val etFeedback = view.findViewById<TextInputEditText>(R.id.et_feedback)
etFeedback.apply {
// 可选:让它本身可滚动(你 XML 已经写了也没问题)
isVerticalScrollBarEnabled = true
setOnTouchListener { v, event ->
// 告诉父布局NestedScrollView先别抢这个触摸事件
v.parent?.requestDisallowInterceptTouchEvent(true)
// 手指抬起/取消时,把控制权还给父布局(页面还能继续滚)
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
v.parent?.requestDisallowInterceptTouchEvent(false)
}
// false不吞事件让 EditText 自己处理滚动/光标
false
}
}
// 提交反馈按钮点击事件
view.findViewById<View>(R.id.btn_keyboard).setOnClickListener {
val feedbackText = etFeedback.text.toString().trim()
if (feedbackText.isEmpty()) {
Toast.makeText(context, "Please enter your feedback", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
BehaviorReporter.report(
isNewUser = false,
"page_id" to "feedback",
"element_id" to "commit_btn",
"content" to feedbackText
)
CoroutineScope(Dispatchers.Main).launch {
val response = submitFeedback(feedbackRequest(content = feedbackText))
if (response?.code == 0) {
Toast.makeText(context, "Feedback submitted successfully", Toast.LENGTH_SHORT).show()
parentFragmentManager.popBackStack()
} else {
Toast.makeText(context, "Failed to submit feedback", Toast.LENGTH_SHORT).show()
}
}
}
}
}
//提交反馈
private suspend fun submitFeedback(body:feedbackRequest): ApiResponse<Boolean>? =
runCatching<ApiResponse<Boolean>> { RetrofitClient.apiService.feedback(body) }.getOrNull()
}

View File

@@ -0,0 +1,165 @@
package com.example.myapplication.ui.mine.myotherpages
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlin.math.abs
class GenderSelectSheet : BottomSheetDialogFragment() {
private val values = listOf("Male", "Female", "The third gender")
private var selectedIndex = 0
private val itemHeightDp = 48f // 每行高度(和 Adapter 里一致)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.sheet_select_gender, container, false)
selectedIndex = (arguments?.getInt(ARG_INITIAL) ?: 0).coerceIn(0, values.lastIndex)
val rv = view.findViewById<RecyclerView>(R.id.gender_wheel)
val layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
rv.layoutManager = layoutManager
rv.adapter = WheelAdapter(values, dpToPx(itemHeightDp))
rv.overScrollMode = View.OVER_SCROLL_NEVER
rv.isNestedScrollingEnabled = true
rv.clipToPadding = false
val snapHelper = LinearSnapHelper()
snapHelper.attachToRecyclerView(rv)
// ✅ 关键:触摸滚轮时不让 BottomSheet 抢手势(否则会拖动弹窗)
rv.setOnTouchListener { v, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
setSheetDraggable(false)
v.parent?.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE -> v.parent?.requestDisallowInterceptTouchEvent(true)
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
setSheetDraggable(true)
v.parent?.requestDisallowInterceptTouchEvent(false)
}
}
false
}
rv.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
updateChildColors(recyclerView)
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
val snapView = snapHelper.findSnapView(layoutManager) ?: return
val pos = layoutManager.getPosition(snapView).coerceIn(0, values.lastIndex)
selectedIndex = pos
updateChildColors(recyclerView)
}
}
})
// ✅ 关键:给上下 padding让 3 条内容也能滚动/居中吸附
rv.post {
val itemPx = dpToPx(itemHeightDp)
val pad = (rv.height / 2 - itemPx / 2).coerceAtLeast(0)
rv.setPadding(rv.paddingLeft, pad, rv.paddingRight, pad)
layoutManager.scrollToPositionWithOffset(selectedIndex, pad)
rv.post { updateChildColors(rv) }
}
view.findViewById<View>(R.id.btn_close).setOnClickListener { dismiss() }
view.findViewById<View>(R.id.btn_save).setOnClickListener {
parentFragmentManager.setFragmentResult(
REQ_KEY,
Bundle().apply { putInt(BUNDLE_KEY_GENDER, selectedIndex) }
)
dismiss()
}
return view
}
private fun setSheetDraggable(draggable: Boolean) {
val d = dialog as? BottomSheetDialog ?: return
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) ?: return
BottomSheetBehavior.from(sheet).isDraggable = draggable
}
/** 根据距离中心点决定文字颜色 */
private fun updateChildColors(rv: RecyclerView) {
val centerY = rv.height / 2f
val selectedColor = Color.parseColor("#02BEAC")
val normalColor = Color.parseColor("#B5B5B5")
for (i in 0 until rv.childCount) {
val child = rv.getChildAt(i)
val tv = child as? TextView ?: continue
val childCenterY = (child.top + child.bottom) / 2f
val distance = abs(childCenterY - centerY)
val isSelected = distance < dpToPx(8f)
tv.setTextColor(if (isSelected) selectedColor else normalColor)
}
}
companion object {
const val REQ_KEY = "req_select_gender"
const val BUNDLE_KEY_GENDER = "bundle_gender"
private const val ARG_INITIAL = "arg_initial_gender"
fun newInstance(initialGender: Int) = GenderSelectSheet().apply {
arguments = Bundle().apply { putInt(ARG_INITIAL, initialGender) }
}
}
private fun dpToPx(dp: Float): Int =
(dp * resources.displayMetrics.density + 0.5f).toInt()
private class WheelAdapter(
private val items: List<String>,
private val itemHeightPx: Int
) : RecyclerView.Adapter<WheelAdapter.VH>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val tv = TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
itemHeightPx
)
gravity = Gravity.CENTER
textSize = 18f
setTextColor(Color.parseColor("#B5B5B5"))
}
return VH(tv)
}
override fun onBindViewHolder(holder: VH, position: Int) {
(holder.itemView as TextView).text = items[position]
}
override fun getItemCount(): Int = items.size
class VH(itemView: View) : RecyclerView.ViewHolder(itemView)
}
}

View File

@@ -0,0 +1,64 @@
package com.example.myapplication.ui.mine.myotherpages
import android.os.Bundle
import android.text.InputType
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.setPadding
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import com.example.myapplication.R
class NicknameEditSheet : BottomSheetDialogFragment() {
override fun onStart() {
super.onStart()
dialog?.window?.setSoftInputMode(
android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or
android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view = inflater.inflate(R.layout.sheet_edit_nickname, container, false)
val initial = arguments?.getString(ARG_INITIAL).orEmpty()
val et = view.findViewById<TextInputEditText>(R.id.et_nickname)
et.setText(initial)
view.findViewById<View>(R.id.btn_close).setOnClickListener { dismiss() }
view.findViewById<View>(R.id.btn_save).setOnClickListener {
val nickname = et.text?.toString()?.trim().orEmpty()
if (nickname.isBlank()) return@setOnClickListener
parentFragmentManager.setFragmentResult(
REQ_KEY,
Bundle().apply { putString(BUNDLE_KEY_NICKNAME, nickname) }
)
dismiss()
}
return view
}
companion object {
const val REQ_KEY = "req_edit_nickname"
const val BUNDLE_KEY_NICKNAME = "bundle_nickname"
private const val ARG_INITIAL = "arg_initial_nickname"
fun newInstance(initial: String) = NicknameEditSheet().apply {
arguments = Bundle().apply { putString(ARG_INITIAL, initial) }
}
}
}

View File

@@ -1,34 +1,411 @@
package com.example.myapplication.ui.mine.myotherpages
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import com.example.myapplication.R
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.ApiResponse
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.User
import com.example.myapplication.network.updateInfoRequest
import com.example.myapplication.ui.common.LoadingOverlay
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import com.google.android.material.textfield.TextInputLayout
import java.util.*
import de.hdodenhof.circleimageview.CircleImageView
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import android.util.Log
class PersonalSettings : BottomSheetDialogFragment() {
private var user: User? = null
private lateinit var avatar: CircleImageView
private lateinit var tvNickname: TextView
private lateinit var tvGender: TextView
private lateinit var tvUserId: TextView
private lateinit var loadingOverlay: LoadingOverlay
/**
* ✅ Android Photo Picker
* - Android 13+:系统原生 Photo Picker
* - 低版本:会自动 fallback 到系统/兼容实现
* - 不需要 READ/WRITE_EXTERNAL_STORAGE
*/
private val galleryLauncher =
registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
uri?.let { handleImageResult(it) }
}
private val cameraLauncher =
registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean ->
if (success) {
cameraImageUri?.let { handleImageResult(it) }
}
}
private var cameraImageUri: Uri? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.personal_settings, container, false)
}
): View = inflater.inflate(R.layout.personal_settings, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 设置关闭按钮点击事件
// 初始化loadingOverlay
loadingOverlay = LoadingOverlay.attach(requireView() as ViewGroup)
// 关闭
view.findViewById<FrameLayout>(R.id.iv_close).setOnClickListener {
parentFragmentManager.popBackStack()
AuthEventBus.emit(AuthEvent.UserUpdated)
requireActivity().onBackPressedDispatcher.onBackPressed()
}
// bind
avatar = view.findViewById(R.id.avatar)
tvNickname = view.findViewById(R.id.tv_nickname_value)
tvGender = view.findViewById(R.id.tv_gender_value)
tvUserId = view.findViewById(R.id.tv_userid_value)
// Avatar click listener
avatar.setOnClickListener {
showImagePickerDialog()
}
// ===================== FragmentResult listeners =====================
// 昵称保存回传
parentFragmentManager.setFragmentResultListener(
NicknameEditSheet.REQ_KEY,
viewLifecycleOwner
) { _, bundle ->
val newName = bundle.getString(NicknameEditSheet.BUNDLE_KEY_NICKNAME).orEmpty()
if (newName.isBlank()) return@setFragmentResultListener
lifecycleScope.launch {
loadingOverlay.show()
try {
val returnValue = setupdateUserInfo(updateInfoRequest(nickName = newName))
Log.d("PersonalSettings", "setupdateUserInfo: $returnValue")
if (returnValue?.code == 0) {
tvNickname.text = newName
}
user = user?.copy(nickName = newName)
} catch (e: Exception) {
Log.e("PersonalSettings", "Failed to update nickname", e)
} finally {
loadingOverlay.hide()
}
}
}
// 性别保存回传
parentFragmentManager.setFragmentResultListener(
GenderSelectSheet.REQ_KEY,
viewLifecycleOwner
) { _, bundle ->
val newGender = bundle.getInt(GenderSelectSheet.BUNDLE_KEY_GENDER, 0)
lifecycleScope.launch {
loadingOverlay.show()
try {
val returnValue = setupdateUserInfo(updateInfoRequest(gender = newGender))
if (returnValue?.code == 0) {
tvGender.text = genderText(newGender)
}
user = user?.copy(gender = newGender)
} catch (e: Exception) {
Log.e("PersonalSettings", "Failed to update gender", e)
} finally {
loadingOverlay.hide()
}
}
}
// ===================== row click =====================
// Nickname打开编辑 BottomSheetarguments 传初始值)
view.findViewById<View>(R.id.row_nickname).setOnClickListener {
NicknameEditSheet.newInstance(user?.nickName.orEmpty())
.show(parentFragmentManager, "NicknameEditSheet")
}
// Gender打开选择 BottomSheet
view.findViewById<View>(R.id.row_gender).setOnClickListener {
GenderSelectSheet.newInstance(user?.gender ?: 0)
.show(parentFragmentManager, "GenderSelectSheet")
}
// UserID点击复制
view.findViewById<View>(R.id.row_userid).setOnClickListener {
val uid = user?.uid?.toString() ?: tvUserId.text?.toString().orEmpty()
if (uid.isBlank()) return@setOnClickListener
copyToClipboard(uid)
Toast.makeText(requireContext(), "Copy successfully", Toast.LENGTH_SHORT).show()
}
// ===================== load & render =====================
viewLifecycleOwner.lifecycleScope.launch {
loadingOverlay.show()
try {
val resp = getUserdata()
val u = resp?.data // 如果你的 ApiResponse 字段不是 data这里改成你的字段名
if (u == null) {
Toast.makeText(requireContext(), "Load failed", Toast.LENGTH_SHORT).show()
return@launch
}
user = u
renderUser(u)
} finally {
loadingOverlay.hide()
}
}
}
}
private fun renderUser(u: User) {
tvNickname.text = u.nickName
tvGender.text = genderText(u.gender)
tvUserId.text = u.uid.toString()
Glide.with(this)
.load(u.avatarUrl)
.placeholder(R.drawable.default_avatar)
.error(R.drawable.default_avatar)
.into(avatar)
}
private fun genderText(gender: Int): String = when (gender) {
1 -> "Female"
2 -> "The third gender"
0 -> "Male"
else -> ""
}
private fun copyToClipboard(text: String) {
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("user_id", text))
}
private suspend fun getUserdata(): ApiResponse<User>? =
runCatching { RetrofitClient.apiService.getUser() }.getOrNull()
private suspend fun setupdateUserInfo(body: updateInfoRequest): ApiResponse<Boolean>? =
runCatching { RetrofitClient.apiService.updateUserInfo(body) }.getOrNull()
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
cameraImageUri = createImageFile()
cameraImageUri?.let { cameraLauncher.launch(it) }
} else {
Toast.makeText(
requireContext(),
"Camera permission is required to take photos",
Toast.LENGTH_SHORT
).show()
}
}
private fun showImagePickerDialog() {
val options = arrayOf(
getString(R.string.choose_from_gallery),
getString(R.string.take_photo)
)
androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle(R.string.change_avatar)
.setItems(options) { _, which ->
when (which) {
0 -> {
// ✅ Photo Picker: ImageOnly
galleryLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
}
1 -> {
if (ContextCompat.checkSelfPermission(
requireContext(),
android.Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
cameraImageUri = createImageFile()
cameraImageUri?.let { cameraLauncher.launch(it) }
} else {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun handleImageResult(uri: Uri) {
Glide.with(this)
.load(uri)
.into(avatar)
lifecycleScope.launch {
uploadAvatar(uri)
}
}
private suspend fun uploadAvatar(uri: Uri) {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "person_info",
"element_id" to "avatar_edit",
)
loadingOverlay.show()
try {
val resolver = requireContext().contentResolver
// Get MIME type to determine image format
val mimeType = resolver.getType(uri)
val isPng = mimeType?.equals("image/png", ignoreCase = true) == true
// Determine file extension and media type based on MIME type
val fileExtension = if (isPng) ".png" else ".jpg"
val mediaType = if (isPng) "image/png" else "image/jpeg"
// Temp file in app-private external files dir (no storage permission needed)
val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val tempFile = File.createTempFile("UPLOAD_${timeStamp}_", fileExtension, storageDir)
// 先读取尺寸信息inJustDecodeBounds
val boundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
resolver.openInputStream(uri)?.use { ins ->
BitmapFactory.decodeStream(ins, null, boundsOptions)
} ?: return
// Calculate inSampleSize (粗略控制内存)
var inSampleSize = 1
val maxSizeBytes = 5 * 1024 * 1024 // 5MB 目标
// 简单估算w*h*4
if (boundsOptions.outHeight > 0 && boundsOptions.outWidth > 0) {
val estimated = boundsOptions.outHeight.toLong() * boundsOptions.outWidth.toLong() * 4L
if (estimated > maxSizeBytes) {
val halfHeight = boundsOptions.outHeight / 2
val halfWidth = boundsOptions.outWidth / 2
while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) {
inSampleSize *= 2
}
}
}
// Decode bitmap with inSampleSize
val decodeOptions = BitmapFactory.Options().apply {
inJustDecodeBounds = false
this.inSampleSize = inSampleSize
}
val bitmap = resolver.openInputStream(uri)?.use { ins ->
BitmapFactory.decodeStream(ins, null, decodeOptions)
}
if (bitmap == null) {
Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show()
return
}
// Compress to tempFile
// 注意:用 outputStream 反复 compress 时不要在同一个 stream 上 truncate
// 最稳是每次重新打开 stream 写入。
if (isPng) {
tempFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
}
} else {
var quality = 90
do {
tempFile.outputStream().use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
}
quality -= 10
} while (tempFile.length() > maxSizeBytes && quality > 10)
}
if (tempFile.length() > maxSizeBytes) {
Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show()
bitmap.recycle()
tempFile.delete()
return
}
val requestFile: RequestBody = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile)
val body = MultipartBody.Part.createFormData("file", tempFile.name, requestFile)
val response = RetrofitClient.createFileUploadService()
.uploadFile("avatar", body)
// Clean up
bitmap.recycle()
tempFile.delete()
if (response?.code == 0) {
setupdateUserInfo(updateInfoRequest(avatarUrl = response.data))
Toast.makeText(requireContext(), R.string.avatar_updated, Toast.LENGTH_SHORT).show()
user = user?.copy(avatarUrl = response.data)
} else {
Toast.makeText(requireContext(), R.string.upload_failed, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(requireContext(), R.string.upload_error, Toast.LENGTH_SHORT).show()
Log.e("PersonalSettings", "Upload avatar error", e)
} finally {
loadingOverlay.hide()
}
}
private fun createImageFile(): Uri? {
val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val imageFile = File.createTempFile(
"JPEG_${timeStamp}_",
".jpg",
storageDir
)
return FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
imageFile
)
}
override fun onDestroyView() {
loadingOverlay.remove()
super.onDestroyView()
}
}

View File

@@ -29,6 +29,7 @@ import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import com.example.myapplication.network.BehaviorReporter
class ShopFragment : Fragment(R.layout.fragment_shop) {
@@ -239,6 +240,10 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
}
}
}
BehaviorReporter.report(
isNewUser = false,
"page_id" to "shop_item_list",
)
}
}
@@ -400,10 +405,22 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
view.findViewById<View>(R.id.skinButton).setOnClickListener {
// 使用事件总线打开我的皮肤页面
findNavController().navigate(R.id.action_shopfragment_to_myskin)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "shop",
"element_id" to "my_skin_btn",
)
}
view.findViewById<View>(R.id.searchButton).setOnClickListener {
// 使用事件总线打开搜索页面
findNavController().navigate(R.id.action_shopfragment_to_searchfragment)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "shop",
"element_id" to "search_btn",
)
}
}

View File

@@ -17,6 +17,7 @@ import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.themeStyle
import com.google.android.material.card.MaterialCardView
import com.example.myapplication.network.BehaviorReporter
class ThemeCardAdapter : ListAdapter<themeStyle, ThemeCardAdapter.ThemeCardViewHolder>(DiffCallback) {
@@ -56,6 +57,11 @@ class ThemeCardAdapter : ListAdapter<themeStyle, ThemeCardAdapter.ThemeCardViewH
val bundle = Bundle().apply {
putInt("themeId", theme.id)
}
BehaviorReporter.report(
isNewUser = false,
"theme_id" to theme.id,
)
// 使用事件总线打开键盘详情页面
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.keyboardDetailFragment, bundle))
}

View File

@@ -19,6 +19,7 @@ import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.themeStyle
import com.example.myapplication.network.deleteThemeRequest
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
class MySkin : Fragment() {
@@ -92,10 +93,22 @@ class MySkin : Fragment() {
adapter.enterEditMode()
tvEditor.text = "Exit editing"
bottomBar.post { showBottomBar() } // post 确保有 height
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my_skin",
"element_id" to "toggle_edit",
"editing" to "1"
)
} else {
adapter.exitEditMode()
tvEditor.text = "Editor"
hideBottomBar()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my_skin",
"element_id" to "toggle_edit",
"editing" to "0"
)
}
}
@@ -103,7 +116,12 @@ class MySkin : Fragment() {
btnDelete.setOnClickListener {
val ids = adapter.getSelectedIds()
if (ids.isEmpty()) return@setOnClickListener
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my_skin",
"element_id" to "delete_btn",
"selected_count" to ids
)
viewLifecycleOwner.lifecycleScope.launch {
val resp = batchDeleteThemes(ids)
if (resp?.code == 0) {

View File

@@ -13,6 +13,8 @@ import com.example.myapplication.R
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.themeStyle
import com.example.myapplication.network.BehaviorReporter
class MySkinAdapter(
private val onItemClick: (themeStyle) -> Unit,
@@ -94,11 +96,18 @@ class MySkinAdapter(
}
holder.itemView.setOnClickListener {
if (editMode) {
if (selected) selectedIds.remove(item.id) else selectedIds.add(item.id)
onSelectionChanged(selectedIds.size)
notifyItemChanged(position)
} else {
BehaviorReporter.report(
isNewUser = false,
"page_id" to "my_skin",
"element_id" to "theme_card",
"theme_id" to item.id
)
// 跳转到主题详情页面
val bundle = Bundle().apply {
putInt("themeId", item.id)

View File

@@ -23,6 +23,7 @@ import com.example.myapplication.ui.shop.ThemeCardAdapter
import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.FlexboxLayout.LayoutParams
import kotlinx.coroutines.launch
import com.example.myapplication.network.BehaviorReporter
@@ -86,7 +87,13 @@ class SearchFragment : Fragment() {
// 把搜索词放进 Bundle
val bundle = bundleOf("search_keyword" to keyword)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "search",
"element_id" to "search_submit",
"keyword" to bundle,
)
// 跳转时带上 bundle
findNavController().navigate(
R.id.action_searchFragment_to_searchResultFragment,
@@ -102,6 +109,10 @@ class SearchFragment : Fragment() {
// 清空历史记录
view.findViewById<FrameLayout>(R.id.iv_delete_history).setOnClickListener {
clearHistory()
BehaviorReporter.report(
isNewUser = false,
"page_id" to "search",
)
}
}
@@ -155,8 +166,13 @@ class SearchFragment : Fragment() {
tv.setOnClickListener {
etInput.setText(keyword)
etInput.setSelection(keyword.length)
BehaviorReporter.report(
isNewUser = false,
"page_id" to "search",
"element_id" to "history_item",
"keyword" to keyword,
)
}
return tv
}