优化1
This commit is contained in:
12
app/src/main/java/com/example/myapplication/AppContext.kt
Normal file
12
app/src/main/java/com/example/myapplication/AppContext.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/签名拦截器)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
// ✅ 卡片选中的 characterId(SSE 参数)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,6 +39,8 @@ interface KeyboardEnvironment {
|
||||
fun showAiKeyboard()
|
||||
//emoji键盘
|
||||
fun showEmojiKeyboard()
|
||||
// 关闭联想
|
||||
fun associateClose()
|
||||
|
||||
// 音效
|
||||
fun playKeyClick()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = newAccount,false = 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=xxx(value 统一做 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) {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 的 OkHttpClient:readTimeout = 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 本质就是 querystring:a=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,改为后续签名阶段统一 encode(SignUtils 做了 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:打开编辑 BottomSheet(arguments 传初始值)
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user