diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index 6d27984..9afd912 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -2,92 +2,354 @@ package com.example.myapplication import android.os.Bundle import android.view.View +import android.widget.Toast +import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.bottomnavigation.BottomNavigationView import androidx.navigation.NavController -import androidx.navigation.NavDestination -import com.example.myapplication.network.AuthEventBus +import androidx.navigation.fragment.NavHostFragment import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import com.google.android.material.bottomnavigation.BottomNavigationView import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch class MainActivity : AppCompatActivity() { private lateinit var bottomNav: BottomNavigationView - private lateinit var navController: NavController + + private val TAB_HOME = "tab_home" + private val TAB_SHOP = "tab_shop" + private val TAB_MINE = "tab_mine" + private val GLOBAL_HOST = "global_host" + + private var currentTabTag = TAB_HOME + private var pendingTabAfterLogin: String? = null + + private val protectedTabs = setOf( + R.id.shop_graph, + R.id.mine_graph + ) + + private val tabMap by lazy { + mapOf( + R.id.home_graph to TAB_HOME, + R.id.shop_graph to TAB_SHOP, + R.id.mine_graph to TAB_MINE + ) + } + + private lateinit var homeHost: NavHostFragment + private lateinit var shopHost: NavHostFragment + private lateinit var mineHost: NavHostFragment + private lateinit var globalHost: NavHostFragment + private var lastGlobalDestId: Int = R.id.globalEmptyFragment + + private val currentTabHost: NavHostFragment + get() = when (currentTabTag) { + TAB_SHOP -> shopHost + TAB_MINE -> mineHost + else -> homeHost + } + + private val currentTabNavController: NavController + get() = currentTabHost.navController + + private val globalNavController: NavController + get() = globalHost.navController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + bottomNav = findViewById(R.id.bottom_nav) + bottomNav.itemIconTintList = null + bottomNav.setOnItemReselectedListener { /* ignore */ } + + // 1) 恢复当前tab + currentTabTag = savedInstanceState?.getString("current_tab_tag") ?: TAB_HOME + + // 2) 初始化/找回 3个Tab host + global host + initHosts() + + // 3) 底栏点击:show/hide 切 tab(带登录拦截 + 防 stateSaved) + bottomNav.setOnItemSelectedListener { item -> + val tabTag = tabMap[item.itemId] ?: return@setOnItemSelectedListener false + + // 登录拦截:未登录点受保护tab => 打开全局login + if (!isLoggedIn() && item.itemId in protectedTabs) { + // 强制回到当前tab的选中状态,避免底栏短暂闪一下 + bottomNav.selectedItemId = when (currentTabTag) { + TAB_SHOP -> R.id.shop_graph + TAB_MINE -> R.id.mine_graph + else -> R.id.home_graph + } + pendingTabAfterLogin = tabTag // 记录目标tab + openGlobal(R.id.loginFragment) + return@setOnItemSelectedListener false + } + + switchTab(tabTag) + true + } + + // 4) token过期:走全局 overlay,且避免重复打开 lifecycleScope.launch { AuthEventBus.events.collectLatest { event -> - if (event is AuthEvent.TokenExpired) { - val navController = (supportFragmentManager - .findFragmentById(R.id.nav_host_fragment) as NavHostFragment) - .navController - - // 避免重复跳转(比如已经在登录页) - if (navController.currentDestination?.id != R.id.loginFragment) { - navController.navigate(R.id.action_global_loginFragment) + when (event) { + is AuthEvent.TokenExpired -> { + pendingTabAfterLogin = null + // 已经在login就别重复 + if (!isGlobalVisible() || globalNavController.currentDestination?.id != R.id.loginFragment) { + openGlobal(R.id.loginFragment) + } + } + is AuthEvent.GenericError -> { + Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show() + } + // 登录成功事件处理 + is AuthEvent.LoginSuccess -> { + // 关闭 global overlay:回到 empty + globalNavController.popBackStack(R.id.globalEmptyFragment, false) + + // 如果之前想去商城/我的,登录成功后自动切过去 + pendingTabAfterLogin?.let { tag -> + switchTab(tag) + bottomNav.selectedItemId = when (tag) { + TAB_SHOP -> R.id.shop_graph + TAB_MINE -> R.id.mine_graph + else -> R.id.home_graph + } + } + pendingTabAfterLogin = null + } + // 登出事件处理 + 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) } - } else if (event is AuthEvent.GenericError) { - android.widget.Toast.makeText(this@MainActivity, "${event.message}", android.widget.Toast.LENGTH_SHORT).show() } } } - // 1. 找到 NavHostFragment - val navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment - navController = navHostFragment.navController + // 5) intent跳转(充值等)统一走全局 overlay + handleNavigationFromIntent() - // 2. 找到 BottomNavigationView - bottomNav = findViewById(R.id.bottom_nav) + // 6) 返回键规则:优先关闭global,其次pop当前tab + setupBackPress() - // 3. 绑定导航控制器(负责切换 Fragment、保持选中状态) - bottomNav.setupWithNavController(navController) - - // 4. 取消图标颜色 tint —— 使用原图标颜色 - bottomNav.itemIconTintList = null - - // 5. 添加导航监听(用于某些 Fragment 隐藏底部导航栏) - navController.addOnDestinationChangedListener { _, destination, _ -> - - // 只有这些页面显示 BottomNav - val pagesWithBottomNav = setOf( - R.id.mineFragment, - R.id.homeFragment, - R.id.shopFragment - ) - - if (destination.id in pagesWithBottomNav) { - bottomNav.visibility = View.VISIBLE - } else { - bottomNav.visibility = View.GONE + // 7) 初始选中正确tab(不会触发二次创建) + bottomNav.post { + bottomNav.selectedItemId = when (currentTabTag) { + TAB_SHOP -> R.id.shop_graph + TAB_MINE -> R.id.mine_graph + else -> R.id.home_graph } } - + } - // 6. 检查是否有导航参数,处理从键盘跳转过来的请求 - handleNavigationFromIntent() + private fun initHosts() { + val fm = supportFragmentManager + + homeHost = fm.findFragmentByTag(TAB_HOME) as? NavHostFragment + ?: NavHostFragment.create(R.navigation.home_graph) + shopHost = fm.findFragmentByTag(TAB_SHOP) as? NavHostFragment + ?: NavHostFragment.create(R.navigation.shop_graph) + mineHost = fm.findFragmentByTag(TAB_MINE) as? NavHostFragment + ?: NavHostFragment.create(R.navigation.mine_graph) + + globalHost = fm.findFragmentByTag(GLOBAL_HOST) as? NavHostFragment + ?: NavHostFragment.create(R.navigation.global_graph) + + // 第一次创建时 add;后续进程重建会自动恢复,无需重复 add + if (fm.findFragmentByTag(TAB_HOME) == null) { + fm.beginTransaction() + .setReorderingAllowed(true) + .add(R.id.tab_container, homeHost, TAB_HOME) + .add(R.id.tab_container, shopHost, TAB_SHOP).hide(shopHost) + .add(R.id.tab_container, mineHost, TAB_MINE).hide(mineHost) + .commitNow() + } + + if (fm.findFragmentByTag(GLOBAL_HOST) == null) { + fm.beginTransaction() + .setReorderingAllowed(true) + .add(R.id.global_container, globalHost, GLOBAL_HOST) + .commitNow() + } + + // 确保当前tab可见 + switchTab(currentTabTag, force = true) + + // 绑定全局导航可见性监听 + bindGlobalVisibility() + + // 绑定底部导航栏可见性监听 + bindBottomNavVisibilityForTabs() + } + + private fun bindGlobalVisibility() { + globalNavController.addOnDestinationChangedListener { _, dest, _ -> + val isEmpty = dest.id == R.id.globalEmptyFragment + + findViewById(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) + lastGlobalDestId = dest.id + + if (justClosedOverlay) { + val currentTabGraphId = when (currentTabTag) { + TAB_SHOP -> R.id.shop_graph + 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 + } + } + } + } + + private fun switchTab(targetTag: String, force: Boolean = false) { + if (!force && targetTag == currentTabTag) return + + val fm = supportFragmentManager + if (fm.isStateSaved) return // ✅ 防崩:stateSaved 时不做事务 + + currentTabTag = targetTag + + fm.beginTransaction() + .setReorderingAllowed(true) + .hide(homeHost) + .hide(shopHost) + .hide(mineHost) + .also { ft -> + when (targetTag) { + TAB_SHOP -> ft.show(shopHost) + TAB_MINE -> ft.show(mineHost) + else -> ft.show(homeHost) + } + } + .commit() + } + + /** 打开全局页(login/recharge等) */ + private fun openGlobal(destId: Int, bundle: Bundle? = null) { + val fm = supportFragmentManager + if (fm.isStateSaved) return // ✅ 防崩 + + try { + if (bundle != null) { + globalNavController.navigate(destId, bundle) + } else { + globalNavController.navigate(destId) + } + } catch (e: IllegalArgumentException) { + // 可选:防止偶发重复 navigate 崩溃 + e.printStackTrace() + } + } + + /** 关闭全局页:pop到 empty */ + 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 { _, 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) + } + + private fun closeGlobalIfPossible(): Boolean { + if (!isGlobalVisible()) return false + + val popped = globalNavController.popBackStack() + val stillVisible = globalNavController.currentDestination?.id != R.id.globalEmptyFragment + return popped || stillVisible + } + + private fun isGlobalVisible(): Boolean { + return findViewById(R.id.global_container).visibility == View.VISIBLE + } + + private fun setupBackPress() { + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + // 1) 优先关 global + if (closeGlobalIfPossible()) return + + // 2) 再 pop 当前tab + val popped = currentTabNavController.popBackStack() + if (popped) return + + // 3) 当前tab到根了:如果不是home,切回home;否则退出 + if (currentTabTag != TAB_HOME) { + bottomNav.post { + bottomNav.selectedItemId = R.id.home_graph + } + switchTab(TAB_HOME) + } else { + finish() + } + } + }) } private fun handleNavigationFromIntent() { val navigateTo = intent.getStringExtra("navigate_to") if (navigateTo == "recharge_fragment") { - // 延迟执行导航,确保导航控制器已经准备好 bottomNav.post { - try { - navController.navigate(R.id.action_global_rechargeFragment) - } catch (e: Exception) { - // 如果导航失败,记录错误日志 - android.util.Log.e("MainActivity", "Failed to navigate to recharge fragment", e) + if (!isLoggedIn()) { + openGlobal(R.id.loginFragment) + return@post } + openGlobal(R.id.rechargeFragment) } } } -} + + private fun isLoggedIn(): Boolean { + return EncryptedSharedPreferencesUtil.contains(this, "user") + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString("current_tab_tag", currentTabTag) + super.onSaveInstanceState(outState) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt index 05781d7..6530edb 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -44,7 +44,8 @@ import android.content.Intent import android.view.inputmethod.ExtractedTextRequest import android.graphics.drawable.GradientDrawable import kotlin.math.abs - +import java.text.BreakIterator +import android.widget.EditText class MyInputMethodService : InputMethodService(), KeyboardEnvironment { @@ -85,6 +86,10 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { private const val TAG = "MyIME" private const val NOTIFICATION_CHANNEL_ID = "input_method_channel" private const val NOTIFICATION_ID = 1 + + private const val ZWJ = 0x200D // ZERO WIDTH JOINER + private const val VS16 = 0xFE0F // VARIATION SELECTOR-16 (emoji) + private const val VS15 = 0xFE0E // VARIATION SELECTOR-15 (text) } // ================= 表情 ================= private var emojiKeyboardView: View? = null @@ -604,6 +609,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showMainKeyboard() { + clearEditorState() val kb = ensureMainKeyboard() currentKeyboardView = kb.rootView setInputView(kb.rootView) @@ -611,6 +617,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showNumberKeyboard() { + clearEditorState() val kb = ensureNumberKeyboard() currentKeyboardView = kb.rootView setInputView(kb.rootView) @@ -618,6 +625,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showSymbolKeyboard() { + clearEditorState() val kb = ensureSymbolKeyboard() currentKeyboardView = kb.rootView setInputView(kb.rootView) @@ -625,6 +633,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showAiKeyboard() { + clearEditorState() val kb = ensureAiKeyboard() currentKeyboardView = kb.rootView setInputView(kb.rootView) @@ -632,6 +641,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } override fun showEmojiKeyboard() { + clearEditorState() val kb = ensureEmojiKeyboard() currentKeyboardView = kb.rootView setInputView(kb.rootView) @@ -704,24 +714,94 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { playKeyClick() } - // 删除一个字符(原 handleBackspace) + // 删除 override fun deleteOne() { val ic = currentInputConnection ?: return - // 删除时少做 IPC:selectedText 也可能慢,所以只在需要时取 + // 记录删除前的极小上下文,用来判断“是否真的删掉了东西” + // 不要取多,避免开销 + val before1_pre = ic.getTextBeforeCursor(1, 0)?.toString().orEmpty() + val after1_pre = ic.getTextAfterCursor(1, 0)?.toString().orEmpty() + + // 1) 先处理选区:和你原来一致 val selected = ic.getSelectedText(0) if (!selected.isNullOrEmpty()) { - // 删选区 ic.commitText("", 1) - } else { - // 删光标前一个字符(更同步) - ic.deleteSurroundingText(1, 0) + scheduleRefreshSuggestions() + playKeyClick() + return } - scheduleRefreshSuggestions() + // 2) 无选区:优先按 code point 删除(解决“Emoji 要删两下”) + val deletedByCodePoint = try { + ic.deleteSurroundingTextInCodePoints(1, 0) + true + } catch (_: Throwable) { + false + } + + if (!deletedByCodePoint) { + // 兼容兜底(老实现) + ic.deleteSurroundingText(1, 0) + } else { + // 3) 兜底清理:处理 ZWJ/变体选择符/组合符,保证“一个可见 Emoji 一次删干净” + val before = ic.getTextBeforeCursor(32, 0)?.toString().orEmpty() + if (before.isNotEmpty()) { + var s = before + var extra = 0 + + fun endsWithJoinerOrVariationOrMark(str: String): Boolean { + if (str.isEmpty()) return false + val cp = Character.codePointBefore(str, str.length) + return cp == ZWJ || + cp == VS16 || + cp == VS15 || + Character.getType(cp) == Character.NON_SPACING_MARK.toInt() || + Character.getType(cp) == Character.COMBINING_SPACING_MARK.toInt() || + Character.getType(cp) == Character.ENCLOSING_MARK.toInt() + } + + while (s.isNotEmpty() && extra < 8 && endsWithJoinerOrVariationOrMark(s)) { + try { + ic.deleteSurroundingTextInCodePoints(1, 0) + } catch (_: Throwable) { + ic.deleteSurroundingText(1, 0) + break + } + extra++ + s = ic.getTextBeforeCursor(32, 0)?.toString().orEmpty() + } + + if (s.isNotEmpty() && Character.codePointBefore(s, s.length) == ZWJ) { + try { + ic.deleteSurroundingTextInCodePoints(1, 0) + } catch (_: Throwable) { + ic.deleteSurroundingText(1, 0) + } + } + } + } + + // ==== 关键:判断“删完后是否真的发生变化” ==== + val before1_post = ic.getTextBeforeCursor(1, 0)?.toString().orEmpty() + val after1_post = ic.getTextAfterCursor(1, 0)?.toString().orEmpty() + + val changed = (before1_pre != before1_post) || (after1_pre != after1_post) + + if (!changed) { + // ✅ 删不动(常见于验证码空格子继续按删除) + // 补发一个 KEYCODE_DEL,让宿主 EditText 的 OnKeyListener 能收到,执行“跳到前一格并删” + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)) + ic.sendKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL)) + } else { + // 只有真的改动了文本,才刷新联想(避免空删触发刷新抖动) + scheduleRefreshSuggestions() + } playKeyClick() } + + private fun refreshSuggestionsAfterEdit() { val ic = currentInputConnection ?: return @@ -1061,6 +1141,35 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { } } + private fun deleteOneGrapheme(editText: EditText) { + val text = editText.text ?: return + + // 优先删选中内容(和系统行为一致) + val startSel = editText.selectionStart + val endSel = editText.selectionEnd + if (startSel != -1 && endSel != -1 && startSel != endSel) { + val s = minOf(startSel, endSel) + val e = maxOf(startSel, endSel) + text.delete(s, e) + editText.setSelection(s) + return + } + + val cursor = startSel + if (cursor <= 0) return + + val s = text.toString() + + // 找到 cursor 前一个“字符边界” + val it = BreakIterator.getCharacterInstance() + it.setText(s) + + val prev = it.preceding(cursor) + if (prev != BreakIterator.DONE && prev >= 0 && prev < cursor) { + text.delete(prev, cursor) + editText.setSelection(prev) + } + } private fun stopRepeatDelete() { diff --git a/app/src/main/java/com/example/myapplication/network/ApiService.kt b/app/src/main/java/com/example/myapplication/network/ApiService.kt index 72993bb..03895e1 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiService.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiService.kt @@ -26,6 +26,35 @@ interface ApiService { @Body body: LoginRequest ): ApiResponse + //退出登录 + @GET("user/logout") + suspend fun logout( + ): ApiResponse + + //发送验证嘛 + @POST("user/sendVerifyMail") + suspend fun sendVerifyCode( + @Body body: SendVerifyCodeRequest + ): ApiResponse + + //注册 + @POST("user/register") + suspend fun register( + @Body body: RegisterRequest + ): ApiResponse + + //验证验证码 + @POST("user/verifyMailCode") + suspend fun verifyCode( + @Body body: VerifyCodeRequest + ): ApiResponse + + //重置密码 + @POST("user/resetPassWord") + suspend fun resetPassword( + @Body body: ResetPasswordRequest + ): ApiResponse + // =========================================用户================================= //获取用户详情 @GET("user/detail") diff --git a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt index 4e1fdbb..d051603 100644 --- a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt +++ b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt @@ -1,5 +1,6 @@ package com.example.myapplication.network +import android.os.Bundle import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -21,4 +22,7 @@ object AuthEventBus { sealed class AuthEvent { data class TokenExpired(val message: String? = null) : 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() } diff --git a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt index 62b3cf5..8e88907 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -3,12 +3,32 @@ package com.example.myapplication.network import android.util.Log import com.google.gson.Gson +import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import android.content.Context + +// * 不需要登录的接口路径(相对完整路径) +// * 只写 /api/ 后面的部分 +// * 例如真实 URL: https://xxx.com/api/home/banner +// * 这里写: /home/banner +private val NO_LOGIN_REQUIRED_PATHS = setOf( + "/themes/listByStyle", + "/wallet/balance", +) + +private fun noLoginRequired(url: HttpUrl): Boolean { + val path = url.encodedPath // 例:/api/home/banner + + // 统一裁掉 /api 前缀 + val apiPath = path.substringAfter("/api", path) + + return NO_LOGIN_REQUIRED_PATHS.contains(apiPath) +} + /** * 请求拦截器:统一加 Header、token 等 */ @@ -102,14 +122,24 @@ val responseInterceptor = Interceptor { chain -> val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java) if (errorResponse.code == 40102) { - Log.w("1314520-HTTP", "token 过期: ${errorResponse.message}") + val isNoLoginApi = noLoginRequired(request.url) + + Log.w( + "1314520-HTTP", + "40102 path=${request.url.encodedPath}, noLogin=$isNoLoginApi" + ) + + // ✅ 只有"需要登录"的接口,才触发全局跳转 + if (!isNoLoginApi) { + AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message)) + } - // 只发事件,UI 层去跳转 - AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message)) - return@Interceptor response.newBuilder() .code(401) - .message("Login expired: ${errorResponse.message}") + .message( + if (isNoLoginApi) response.message + else "Login required: ${errorResponse.message}" + ) .body(bodyString.toResponseBody(mediaType)) .build() } diff --git a/app/src/main/java/com/example/myapplication/network/Models.kt b/app/src/main/java/com/example/myapplication/network/Models.kt index 52b9e08..1c2a916 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -33,6 +33,34 @@ data class LoginResponse( val token: String ) +//验证码发送邮箱 +data class SendVerifyCodeRequest( + val mailAddress: String +) + +//注册 +data class RegisterRequest( + val mailAddress: String, + val password: String, + val passwordConfirm: String, + val gender: Int?, + val verifyCode: String, + val inviteCode: String? +) + +//验证验证码 +data class VerifyCodeRequest( + val mailAddress: String, + val verifyCode: String, +) + +//重置密码 +data class ResetPasswordRequest( + val mailAddress: String, + val password: String, + val confirmPassword: String +) + // ======================================用户=================================== //获取用户详情 data class User( diff --git a/app/src/main/java/com/example/myapplication/ui/EmptyFragment.kt b/app/src/main/java/com/example/myapplication/ui/EmptyFragment.kt new file mode 100644 index 0000000..40b9284 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/EmptyFragment.kt @@ -0,0 +1,16 @@ +package com.example.myapplication.ui + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import com.example.myapplication.R + +class EmptyFragment : Fragment(R.layout.fragment_empty) { + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // 设置透明背景,确保不遮挡底层内容 + view.setBackgroundResource(android.R.color.transparent) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt b/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt index 09415f4..6de4209 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/HomeFragment.kt @@ -15,27 +15,24 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.ContextCompat -import androidx.core.widget.NestedScrollView import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.ImeGuideActivity import com.example.myapplication.R -import com.example.myapplication.network.ApiResponse -import com.example.myapplication.network.RetrofitClient -import com.example.myapplication.network.listByTagWithNotLogin -import com.example.myapplication.network.PersonaClick +import com.example.myapplication.network.* 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 kotlin.math.abs -import com.example.myapplication.network.AddPersonaClick class HomeFragment : Fragment() { @@ -49,21 +46,17 @@ class HomeFragment : Fragment() { private lateinit var tabList1: TextView private lateinit var tabList2: TextView private lateinit var backgroundImage: ImageView - private var preloadJob: kotlinx.coroutines.Job? = null + private var lastList1RenderKey: String? = null + + private var preloadJob: Job? = null private var allPersonaCache: List = emptyList() - private val sharedPool = RecyclerView.RecycledViewPool() + private val personaCache = mutableMapOf>() private var parentWidth = 0 private var parentHeight = 0 - - // 你点了哪个 tag(列表二) private var clickedTagId: Int? = null - // ✅ 列表二:每个 tagId 对应一份 persona 数据,避免串页 - private val personaCache = mutableMapOf>() - data class Tag(val id: Int, val tagName: String) - private val tags = mutableListOf() private val dragToCloseThreshold by lazy { @@ -71,11 +64,9 @@ class HomeFragment : Fragment() { (dp * resources.displayMetrics.density) } - private val list1Adapter: List1Adapter by lazy { - List1Adapter { item: String -> - Log.d("HomeFragment", "list1 click: $item") - } - } + // ---------------- ViewPager2 + Tabs ---------------- + private var sheetAdapter: SheetPagerAdapter? = null + private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null override fun onDestroyView() { preloadJob?.cancel() @@ -89,23 +80,56 @@ class HomeFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_home, container, false) - } + ): View? = inflater.inflate(R.layout.fragment_home, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 充值按钮点击 + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + AuthEventBus.events.collect { event -> + when (event) { + AuthEvent.LoginSuccess -> { + // 1) 清掉未登录缓存 + preloadJob?.cancel() + personaCache.clear() + allPersonaCache = emptyList() + lastList1RenderKey = null + + // 2) 重新拉列表1(登录态接口会变) + viewLifecycleOwner.lifecycleScope.launch { + allPersonaCache = fetchAllPersonaList() + notifyPageChangedOnMain(0) + } + + // 3) 如果当前在某个 tag 页,也建议重新拉当前页数据 + val pos = viewPager.currentItem + if (pos > 0) { + val tagId = tags.getOrNull(pos - 1)?.id + if (tagId != null) { + viewLifecycleOwner.lifecycleScope.launch { + personaCache[tagId] = fetchPersonaByTag(tagId) + notifyPageChangedOnMain(pos) + } + } + } + } + else -> Unit + } + } + } + } + + + // 充值按钮点击 - 使用事件总线打开全局页面 view.findViewById(R.id.rechargeButton).setOnClickListener { - findNavController().navigate(R.id.action_global_rechargeFragment) + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.rechargeFragment)) } // 输入法激活跳转 view.findViewById(R.id.floatingImage).setOnClickListener { - if (isAdded) { - startActivity(Intent(requireActivity(), ImeGuideActivity::class.java)) - } + if (!isAdded) return@setOnClickListener + startActivity(Intent(requireActivity(), ImeGuideActivity::class.java)) } scrim = view.findViewById(R.id.view_scrim) @@ -117,11 +141,14 @@ class HomeFragment : Fragment() { tabList2 = view.findViewById(R.id.tab_list2) viewPager = view.findViewById(R.id.viewPager) viewPager.isSaveEnabled = false + viewPager.offscreenPageLimit = 2 backgroundImage = bottomSheet.findViewById(R.id.backgroundImage) + val root = view.findViewById(R.id.rootCoordinator) val floatingImage = view.findViewById(R.id.floatingImage) root.post { + if (!isAdded) return@post parentWidth = root.width parentHeight = root.height } @@ -130,43 +157,96 @@ class HomeFragment : Fragment() { setupBottomSheet(view) setupTopTabs() - // 先把 ViewPager / Tags 初始化为空(避免你下面网络回来前被调用多次) - setupViewPager() + // ✅ setupViewPager 只初始化一次 + setupViewPagerOnce() + + // 标签 UI 初始为空 setupTags() - //刚进来强制显示列表1 + // 默认显示列表1 viewPager.setCurrentItem(0, false) updateTabsAndTags(0) - // 加载标签列表(列表一) + // 加载列表一 viewLifecycleOwner.lifecycleScope.launch { try { val list = fetchAllPersonaList() + if (!isAdded) return@launch allPersonaCache = list - viewPager.adapter?.notifyItemChanged(0) // 只刷新第一页 + + // ✅ 关键:数据变了就清 renderKey,允许重建一次 UI + lastList1RenderKey = null + + notifyPageChangedOnMain(0) } catch (e: Exception) { Log.e("1314520-HomeFragment", "获取列表一失败", e) } } - // 拉标签 + 默认加载第一个 tag 的 persona(列表二第一个页) + + // 拉标签 + 预加载 viewLifecycleOwner.lifecycleScope.launch { try { val response = RetrofitClient.apiService.tagList() + if (!isAdded) return@launch + tags.clear() response.data?.let { networkTags -> tags.addAll(networkTags.map { Tag(it.id, it.tagName) }) } - // 刷新:页数和标签栏 - setupViewPager() + + // ✅ 只更新页数(不重建 adapter/callback) + sheetAdapter?.updatePageCount(1 + tags.size) + + // 重新画 tags setupTags() - startPreloadAllTags() + + // ✅ 预加载只填缓存,不刷 UI + startPreloadAllTagsFillCacheOnly() } catch (e: Exception) { Log.e("1314520-HomeFragment", "获取标签失败", e) } } } + // ================== 你要求的核心优化:setupViewPager 只初始化一次 ================== + + private fun setupViewPagerOnce() { + if (sheetAdapter != null) return + + sheetAdapter = SheetPagerAdapter(1 + tags.size) + viewPager.adapter = sheetAdapter + + // ✅ 禁止 itemAnimator(减少 layout 抖动) + (viewPager.getChildAt(0) as? RecyclerView)?.itemAnimator = null + + pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + if (!isAdded) return + updateTabsAndTags(position) + + // ✅ 修复:当切换到标签页且缓存已有数据时,强制刷新UI + if (position > 0) { + val tagIndex = position - 1 + val tagId = tags.getOrNull(tagIndex)?.id + if (tagId != null && personaCache.containsKey(tagId)) { + notifyPageChangedOnMain(position) + } + } + } + } + viewPager.registerOnPageChangeCallback(pageChangeCallback!!) + } + + // ✅ 统一:确保 notifyItemChanged 只在主线程 + private fun notifyPageChangedOnMain(position: Int) { + viewPager.post { + if (!isAdded) return@post + viewPager.adapter?.notifyItemChanged(position) + } + } + // ---------------- 拖拽效果 ---------------- + private fun initDrag(target: View, parent: ViewGroup) { var dX = 0f var dY = 0f @@ -245,6 +325,7 @@ class HomeFragment : Fragment() { } // ---------------- BottomSheet 行为 ---------------- + private fun setupBottomSheet(root: View) { bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet) @@ -254,6 +335,7 @@ class HomeFragment : Fragment() { bottomSheetBehavior.halfExpandedRatio = 0.7f root.post { + if (!isAdded) return@post val coordinatorHeight = root.height - 40 val button = root.findViewById(R.id.rechargeButton) val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200) @@ -296,69 +378,17 @@ class HomeFragment : Fragment() { } } - // ---------------- ViewPager2 + Tabs ---------------- - private var sheetAdapter: SheetPagerAdapter? = null - private var pageChangeCallback: ViewPager2.OnPageChangeCallback? = null - - private fun setupViewPager() { - if (sheetAdapter == null) { - sheetAdapter = SheetPagerAdapter(1 + tags.size) - viewPager.adapter = sheetAdapter - - pageChangeCallback = object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - updateTabsAndTags(position) - } - } - viewPager.registerOnPageChangeCallback(pageChangeCallback!!) - } else { - // tags 数量变了,只更新 pageCount 并刷新一次即可 - sheetAdapter!!.updatePageCount(1 + tags.size) - } - } - - - private fun startPreloadAllTags() { - preloadJob?.cancel() - - // 限制并发,避免一下子打爆网络/主线程调度抖动 - val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2) - - preloadJob = viewLifecycleOwner.lifecycleScope.launch { - // tags 还没拿到就别跑 - if (tags.isEmpty()) return@launch - - // 逐个 tag 预拉取(并发=2) - tags.forEachIndexed { index, tag -> - // 已经有缓存就跳过 - if (personaCache.containsKey(tag.id)) return@forEachIndexed - launch { - semaphore.acquire() - try { - val list = fetchPersonaByTag(tag.id) - personaCache[tag.id] = list - - val pagePos = 1 + index - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Main) { - viewPager.adapter?.notifyItemChanged(pagePos) - } - } finally { - semaphore.release() - } - } - } - } - } - + // ---------------- Tabs ---------------- private fun setupTopTabs() { tabList1.setOnClickListener { viewPager.currentItem = 0 } tabList2.setOnClickListener { - // 没有标签就别切 if (tags.isNotEmpty()) viewPager.currentItem = 1 } } + // ---------------- Tags ---------------- + private fun setupTags() { tagContainer.removeAllViews() @@ -369,28 +399,26 @@ class HomeFragment : Fragment() { tv.setOnClickListener { clickedTagId = tag.id val pagePos = 1 + index - - // ✅ 先切页:用户体感立刻响应 + + // 先切页:响应快 viewPager.setCurrentItem(pagePos, true) - - // ✅ 有缓存就不阻塞(可选:同时后台刷新) + + // ✅ Tag 切换不触发多余 notify: + // 1) 有缓存:不 notify(onBind 会直接显示缓存) val cached = personaCache[tag.id] - if (cached != null) { - viewPager.adapter?.notifyItemChanged(pagePos) - return@setOnClickListener - } - - // ✅ 没缓存:页内显示 loading(你 onBind 已经处理 cached==null 的 loading) - viewPager.adapter?.notifyItemChanged(pagePos) - - // 后台拉取,回来只刷新这一页 + if (cached != null) return@setOnClickListener + + // 2) 没缓存:只 notify 一次,让页显示 loading + notifyPageChangedOnMain(pagePos) + + // 后台拉取,回来再 notify 一次 viewLifecycleOwner.lifecycleScope.launch { val list = fetchPersonaByTag(tag.id) + if (!isAdded) return@launch personaCache[tag.id] = list - viewPager.adapter?.notifyItemChanged(pagePos) // ✅ 只刷新这一页 + notifyPageChangedOnMain(pagePos) } } - tagContainer.addView(tv) } @@ -399,14 +427,16 @@ class HomeFragment : Fragment() { } private fun updateTabsAndTags(position: Int) { + val ctx = context ?: return + if (position == 0) { - tabList1.setTextColor(requireContext().getColor(R.color.black)) - tabList2.setTextColor(requireContext().getColor(R.color.light_black)) + tabList1.setTextColor(ctx.getColor(R.color.black)) + tabList2.setTextColor(ctx.getColor(R.color.light_black)) tagScroll.isVisible = false fadeImage(backgroundImage, R.drawable.option_background) } else { - tabList1.setTextColor(requireContext().getColor(R.color.light_black)) - tabList2.setTextColor(requireContext().getColor(R.color.black)) + tabList1.setTextColor(ctx.getColor(R.color.light_black)) + tabList2.setTextColor(ctx.getColor(R.color.black)) tagScroll.isVisible = true fadeImage(backgroundImage, R.drawable.option_background_two) @@ -416,8 +446,9 @@ class HomeFragment : Fragment() { } private fun fadeImage(imageView: ImageView, newImageRes: Int) { + val ctx = context ?: return val oldDrawable = imageView.drawable - val newDrawable = ContextCompat.getDrawable(requireContext(), newImageRes) ?: return + val newDrawable = ContextCompat.getDrawable(ctx, newImageRes) ?: return if (oldDrawable == null) { imageView.setImageDrawable(newDrawable) @@ -430,14 +461,17 @@ class HomeFragment : Fragment() { imageView.setImageDrawable(transitionDrawable) transitionDrawable.startTransition(300) } - + private fun highlightTag(index: Int) { + val ctx = context ?: return + for (i in 0 until tagContainer.childCount) { val child = tagContainer.getChildAt(i) as TextView if (i == index) { child.setBackgroundResource(R.drawable.tag_selected_bg) - child.setTextColor(requireContext().getColor(android.R.color.white)) + child.setTextColor(ctx.getColor(android.R.color.white)) tagScroll.post { + if (!isAdded) return@post val scrollViewWidth = tagScroll.width val childCenter = child.left + child.width / 2 val targetScrollX = childCenter - scrollViewWidth / 2 @@ -445,25 +479,71 @@ class HomeFragment : Fragment() { } } else { child.setBackgroundResource(R.drawable.tag_unselected_bg) - child.setTextColor(requireContext().getColor(R.color.light_black)) + child.setTextColor(ctx.getColor(R.color.light_black)) } } } + // ---------------- 预加载:只填缓存,不刷新 UI ---------------- + + 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 { + semaphore.release() + } + } + } + } + } + + // ---------------- ViewPager Adapter ---------------- + inner class SheetPagerAdapter( private var pageCount: Int ) : RecyclerView.Adapter() { - + inner class PageViewHolder(val root: View) : RecyclerView.ViewHolder(root) - + fun updatePageCount(newCount: Int) { + if (newCount == pageCount) return + + val old = pageCount pageCount = newCount - notifyDataSetChanged() + + if (newCount > old) { + notifyItemRangeInserted(old, newCount - old) + } else { + notifyItemRangeRemoved(newCount, old - newCount) + } } - + override fun getItemViewType(position: Int): Int = if (position == 0) 0 else 1 - + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PageViewHolder { val layoutId = if (viewType == 0) { R.layout.bottom_page_list1 @@ -473,42 +553,50 @@ class HomeFragment : Fragment() { val root = LayoutInflater.from(parent.context).inflate(layoutId, parent, false) return PageViewHolder(root) } - + override fun onBindViewHolder(holder: PageViewHolder, position: Int) { val root = holder.root - + if (position == 0) { renderList1(root, allPersonaCache) } else { val rv2 = root.findViewById(R.id.recyclerView) val loadingView = root.findViewById(R.id.loadingView) - + rv2.setHasFixedSize(true) + + // ✅ 禁止 itemAnimator(减少 layout 抖动) rv2.itemAnimator = null + rv2.isNestedScrollingEnabled = false - + var adapter = rv2.adapter as? PersonaAdapter if (adapter == null) { adapter = PersonaAdapter { click -> when (click) { is PersonaClick.Item -> { val id = click.persona.id + if (!isAdded || childFragmentManager.isStateSaved) return@PersonaAdapter PersonaDetailDialogFragment .newInstance(id) .show(childFragmentManager, "persona_detail") } + is PersonaClick.Add -> { - lifecycleScope.launch { - if (click.persona.added == true) { - click.persona.id?.let { id -> - RetrofitClient.apiService.delUserCharacter(id.toInt()) + viewLifecycleOwner.lifecycleScope.launch { + try { + if (click.persona.added == true) { + click.persona.id?.let { id -> + RetrofitClient.apiService.delUserCharacter(id.toInt()) + } + } else { + val req = AddPersonaClick( + characterId = click.persona.id?.toInt() ?: 0, + emoji = click.persona.emoji ?: "" + ) + RetrofitClient.apiService.addUserCharacter(req) } - } else { - val req = AddPersonaClick( - characterId = click.persona.id?.toInt() ?: 0, - emoji = click.persona.emoji ?: "" - ) - RetrofitClient.apiService.addUserCharacter(req) + } catch (_: Exception) { } } } @@ -517,17 +605,17 @@ class HomeFragment : Fragment() { rv2.layoutManager = GridLayoutManager(root.context, 2) rv2.adapter = adapter } - + val tagIndex = position - 1 if (tagIndex !in tags.indices) { loadingView.isVisible = false adapter.submitList(emptyList()) return } - + val tagId = tags[tagIndex].id val cached = personaCache[tagId] - + if (cached == null) { loadingView.isVisible = true adapter.submitList(emptyList()) @@ -537,175 +625,147 @@ class HomeFragment : Fragment() { } } } - + override fun getItemCount(): Int = pageCount } - - // 通过 tagIndex 取出该页要显示的数据 - private fun getPersonaListByTagIndex(tagIndex: Int): List { - if (tagIndex !in tags.indices) return emptyList() - val tagId = tags[tagIndex].id - return personaCache[tagId] ?: emptyList() - } + // ---------------- 列表一渲染(原逻辑不动) ---------------- private fun renderList1(root: View, list: List) { - // 1) 排序:rank 小的排前面 + val key = buildString { + list.forEach { p -> + append(p.id).append('_') + append(p.added).append('_') + append(p.rank).append('|') + } + } + 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() - - // 2) 绑定前三名(注意:你的 UI 排列是:第二/第一/第三) - bindTopItem(root, + + bindTopItem( + root, avatarId = R.id.avatar_first, nameId = R.id.name_first, addBtnId = R.id.btn_add_first, - container = R.id.container_first, - item = top3.getOrNull(0) // rank 最小 = 第一名 + containerId = R.id.container_first, + item = top3.getOrNull(0) ) - - bindTopItem(root, + + bindTopItem( + root, avatarId = R.id.avatar_second, nameId = R.id.name_second, addBtnId = R.id.btn_add_second, - container = R.id.container_second, - item = top3.getOrNull(1) // 第二名 + containerId = R.id.container_second, + item = top3.getOrNull(1) ) - - bindTopItem(root, + + bindTopItem( + root, avatarId = R.id.avatar_third, nameId = R.id.name_third, addBtnId = R.id.btn_add_third, - container = R.id.container_third, - item = top3.getOrNull(2) // 第三名 + containerId = R.id.container_third, + item = top3.getOrNull(2) ) - - // 3) 渲染后面的内容卡片 + val container = root.findViewById(R.id.container_others) container.removeAllViews() - + val inflater = LayoutInflater.from(root.context) others.forEach { p -> val itemView = inflater.inflate(R.layout.item_rank_other, container, false) - - itemView.findViewById(R.id.tv_rank).text = (p.rank ?: "--").toString() itemView.findViewById(R.id.tv_name).text = p.characterName ?: "" itemView.findViewById(R.id.tv_desc).text = p.characterBackground ?: "" - - // 头像 + val iv = itemView.findViewById(R.id.iv_avatar) - // Glide 示例 com.bumptech.glide.Glide.with(iv).load(p.avatarUrl).into(iv) - + itemView.setOnClickListener { - val id = p.id - Log.d("HomeFragment", "list1 others click id=$id") + if (!isAdded || childFragmentManager.isStateSaved) return@setOnClickListener PersonaDetailDialogFragment - .newInstance(id) + .newInstance(p.id) .show(childFragmentManager, "persona_detail") } - // 只点“添加”按钮 itemView.findViewById(R.id.btn_add).setOnClickListener { - val id = p.id - lifecycleScope.launch { - if(p.added == true){ - //取消收藏 - p.id?.let { id -> - try { - RetrofitClient.apiService.delUserCharacter(id.toInt()) - } catch (e: Exception) { - // 处理错误 - } - } - }else{ - val addPersonaRequest = AddPersonaClick( - characterId = p.id?.toInt() ?: 0, - emoji = p.emoji ?: "" - ) - try { - RetrofitClient.apiService.addUserCharacter(addPersonaRequest) - } catch (e: Exception) { - // 处理错误 + 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) } } - + private fun bindTopItem( root: View, avatarId: Int, nameId: Int, addBtnId: Int, - container: Int, + containerId: Int, item: listByTagWithNotLogin? ) { val avatar = root.findViewById(avatarId) val name = root.findViewById(nameId) val addBtn = root.findViewById(addBtnId) - val container = root.findViewById(container) - + val container = root.findViewById(containerId) + if (item == null) { - // 没数据就隐藏(或者显示占位) - // avatar.isVisible = false name.isVisible = false addBtn.isVisible = false return } - - avatar.isVisible = true + name.isVisible = true addBtn.isVisible = true - + name.text = item.characterName ?: "" - - // 头像 com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar) - + addBtn.setOnClickListener { - val id = item.id - lifecycleScope.launch { - if(item.added == true){ - //取消收藏 - item.id?.let { id -> - try { - RetrofitClient.apiService.delUserCharacter(id.toInt()) - } catch (e: Exception) { - // 处理错误 - } - } - }else{ - val addPersonaRequest = AddPersonaClick( - characterId = item.id?.toInt() ?: 0, - emoji = item.emoji ?: "" - ) - try { - RetrofitClient.apiService.addUserCharacter(addPersonaRequest) - } catch (e: Exception) { - // 处理错误 + viewLifecycleOwner.lifecycleScope.launch { + try { + if (item.added == true) { + item.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) } + } else { + val req = AddPersonaClick( + characterId = item.id?.toInt() ?: 0, + emoji = item.emoji ?: "" + ) + RetrofitClient.apiService.addUserCharacter(req) } + } catch (_: Exception) { } } } container.setOnClickListener { - val id = item.id - Log.d("HomeFragment", "list1 top click id=$id rank=${item.rank}") + if (!isAdded || childFragmentManager.isStateSaved) return@setOnClickListener PersonaDetailDialogFragment - .newInstance(id) + .newInstance(item.id) .show(childFragmentManager, "persona_detail") } } - - + // ---------------- 网络请求 ---------------- + private suspend fun fetchPersonaByTag(tagId: Int): List { return try { val resp = if (!isLoggedIn()) { @@ -715,54 +775,36 @@ class HomeFragment : Fragment() { } resp.data ?: emptyList() } catch (e: Exception) { - if(!isLoggedIn()){ - //未登录用户获取人设列表 - Log.e("1314520-HomeFragment", "未登录根据标签获取人设列表", e) - }else{ - Log.e("1314520-HomeFragment", "登录根据标签获取人设列表", e) - } + Log.e("1314520-HomeFragment", "按标签获取人设列表失败 tagId=$tagId", e) emptyList() } } private suspend fun fetchAllPersonaList(): List { return try { - val personaData = if (!isLoggedIn()) { + val resp = if (!isLoggedIn()) { RetrofitClient.apiService.personaListWithNotLogin() } else { RetrofitClient.apiService.personaByTag() } - personaData.data ?: emptyList() + resp.data ?: emptyList() } catch (e: Exception) { - if(!isLoggedIn()){ - //未登录用户获取人设列表 - Log.e("1314520-HomeFragment", "未登录用户人设列表", e) - }else{ - Log.e("1314520-HomeFragment", "登录用户人设列表", e) - } + Log.e("1314520-HomeFragment", "获取列表一失败", e) emptyList() } } - suspend fun getpersonaLis(id: Int): ApiResponse>? { - return try { - RetrofitClient.apiService.personaListByTag(id) - } catch (e: Exception) { - Log.e("1314520-HomeFragment", "未登录用户按标签查询人设列表", e) - null - } - } - - suspend fun loggedInGetpersonaLis(id: Int): ApiResponse>? { - return try { - RetrofitClient.apiService.loggedInPersonaListByTag(id) - } catch (e: Exception) { - Log.e("1314520-HomeFragment", "登录用户按标签查询人设列表", e) - null - } - } - + // ✅ 不再 requireContext(),避免 detach 直接崩 private fun isLoggedIn(): Boolean { - return EncryptedSharedPreferencesUtil.contains(requireContext(), "user") + val ctx = context ?: return false + return EncryptedSharedPreferencesUtil.contains(ctx, "user") + } + + // ✅ 统一安全导航:stateSaved 防护(切很快/后台回来时很重要) + private fun safeNavigate(actionId: Int) { + if (!isAdded) return + if (parentFragmentManager.isStateSaved) return + runCatching { findNavController().navigate(actionId) } + .onFailure { Log.e("HomeFragment", "navigate fail: $actionId", it) } } } diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt index 1d5b1e7..3cdda23 100644 --- a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt @@ -40,6 +40,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.BufferedInputStream import java.io.FileInputStream +import com.example.myapplication.ui.shop.ShopEvent +import com.example.myapplication.ui.shop.ShopEventBus class KeyboardDetailFragment : Fragment() { @@ -291,6 +293,8 @@ class KeyboardDetailFragment : Fragment() { if (response?.code == 0) { loadData() } + + ShopEventBus.post(ShopEvent.ThemePurchased) } catch (e: Exception) { Log.e("KeyboardDetailFragment", "购买主题失败", e) } diff --git a/app/src/main/java/com/example/myapplication/ui/login/CodeEditText.kt b/app/src/main/java/com/example/myapplication/ui/login/CodeEditText.kt new file mode 100644 index 0000000..bf78a0a --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/CodeEditText.kt @@ -0,0 +1,43 @@ +package com.example.myapplication.ui.common + +import android.content.Context +import android.util.AttributeSet +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import androidx.appcompat.widget.AppCompatEditText + +class CodeEditText @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = android.R.attr.editTextStyle +) : AppCompatEditText(context, attrs, defStyleAttr) { + + var onDelPressed: (() -> Unit)? = null + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection { + val ic = super.onCreateInputConnection(outAttrs) + return object : InputConnectionWrapper(ic, true) { + + // 软键盘删除通常走这个 + override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean { + if (beforeLength == 1 && afterLength == 0) { + onDelPressed?.invoke() + return true + } + return super.deleteSurroundingText(beforeLength, afterLength) + } + + // 一些键盘会直接发 KeyEvent DEL + override fun sendKeyEvent(event: android.view.KeyEvent): Boolean { + if (event.action == android.view.KeyEvent.ACTION_DOWN && + event.keyCode == android.view.KeyEvent.KEYCODE_DEL + ) { + onDelPressed?.invoke() + return true + } + return super.sendKeyEvent(event) + } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt index fb3c8b8..a4935e6 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt @@ -5,14 +5,25 @@ 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 -import com.google.android.material.textfield.TextInputLayout +import android.widget.EditText import android.widget.TextView +import android.widget.Toast +import android.widget.FrameLayout +import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController +import com.example.myapplication.R +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import com.example.myapplication.network.SendVerifyCodeRequest +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import android.util.Log class ForgetPasswordEmailFragment : Fragment() { + private lateinit var emailEditText: EditText // 邮箱输入框 + private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null // 加载遮罩层 + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -20,12 +31,63 @@ class ForgetPasswordEmailFragment : Fragment() { ): View? { return inflater.inflate(R.layout.fragment_forget_password_email, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - //验证码页面 + // 初始化加载遮罩层 + loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup) + + emailEditText = view.findViewById(R.id.et_email) + + view.findViewById(R.id.iv_close).setOnClickListener { + parentFragmentManager.popBackStack() + } + + // 下一步按钮点击事件 view.findViewById(R.id.nextstep).setOnClickListener { - findNavController().navigate(R.id.action_forgetPasswordEmailFragment_to_forgetPasswordVerifyFragment) - } + // 对输入框去除首尾空格 + val email = emailEditText.text.toString().trim() + + // 判断邮箱是否为空 + if (email.isEmpty()) { + Toast.makeText(activity, "Please enter your email address", Toast.LENGTH_SHORT).show() + } else if (!isValidEmail(email)) { + Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show() + } else { + loadingOverlay?.show() + viewLifecycleOwner.lifecycleScope.launch { + try { + val body = SendVerifyCodeRequest(email) + val response = RetrofitClient.apiService.sendVerifyCode(body) + if (response.code == 0) { + EncryptedSharedPreferencesUtil.save(requireContext(), "forget_email", email) + findNavController().navigate(R.id.action_forgetPasswordEmailFragment_to_forgetPasswordVerifyFragment) + Toast.makeText(activity, "A verification email has been sent to ${email}. Please check your inbox to complete the verification.", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Log.e("1314520-MineFragment", "发送验证码失败", e) + } finally { + loadingOverlay?.hide() + } + } + } + } + } + + override fun onDestroyView() { + loadingOverlay?.remove() + loadingOverlay = null + super.onDestroyView() + } + + /** + * 验证邮箱格式是否有效 + * @param email 要验证的邮箱地址 + * @return 如果邮箱格式有效返回true,否则返回false + */ + private fun isValidEmail(email: String): Boolean { + val emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + return email.matches(emailRegex.toRegex()) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt index 04d0a96..f7351e1 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt @@ -2,13 +2,36 @@ package com.example.myapplication.ui.login import android.os.Bundle +import android.text.InputType +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController import com.example.myapplication.R +import com.example.myapplication.network.ResetPasswordRequest +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.ui.common.LoadingOverlay +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import kotlinx.coroutines.launch class ForgetPasswordResetFragment : Fragment() { + + private lateinit var passwordEditText: EditText // 密码输入框 + private lateinit var confirmPasswordEditText: EditText // 确认密码输入框 + private lateinit var toggleImageView: android.widget.ImageView // 密码显示/隐藏按钮 + private lateinit var confirmtoggleImageView: android.widget.ImageView // 确认密码显示/隐藏按钮 + private var loadingOverlay: LoadingOverlay? = null // 加载遮罩层 + private var isPasswordVisible = false // 密码可见性状态 + private var isConfirmPasswordVisible = false // 确认密码可见性状态 + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -17,5 +40,98 @@ class ForgetPasswordResetFragment : Fragment() { return inflater.inflate(R.layout.fragment_forget_password_reset, container, false) } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.findViewById(R.id.iv_close).setOnClickListener { + parentFragmentManager.popBackStack() + } + + passwordEditText = view.findViewById(R.id.et_password) + confirmPasswordEditText = view.findViewById(R.id.et_confirm_password) + toggleImageView = view.findViewById(R.id.iv_toggle) + confirmtoggleImageView = view.findViewById(R.id.iv_confirm_toggle) + + // 初始化加载遮罩层 + loadingOverlay = LoadingOverlay.attach(view as ViewGroup) + + // 密码显示/隐藏按钮点击事件 + passwordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + toggleImageView.setOnClickListener { + isPasswordVisible = !isPasswordVisible + + if (isPasswordVisible) { + // 显示密码 + passwordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + toggleImageView.setImageResource(R.drawable.display) + } else { + // 隐藏密码 + passwordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + toggleImageView.setImageResource(R.drawable.hide) + } + // 保持光标在末尾 + passwordEditText.setSelection(passwordEditText.text?.length ?: 0) + } + + // 确认密码显示/隐藏按钮点击事件 + confirmPasswordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + confirmtoggleImageView.setOnClickListener { + isConfirmPasswordVisible = !isConfirmPasswordVisible + + if (isConfirmPasswordVisible) { + // 显示密码 + confirmPasswordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + confirmtoggleImageView.setImageResource(R.drawable.display) + } else { + // 隐藏密码 + confirmPasswordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + confirmtoggleImageView.setImageResource(R.drawable.hide) + } + // 保持光标在末尾 + confirmPasswordEditText.setSelection(confirmPasswordEditText.text?.length ?: 0) + } + + + val savedEmail = EncryptedSharedPreferencesUtil.get(requireContext(), "forget_email", String::class.java) + + view.findViewById(R.id.nextstep).setOnClickListener { + val password = passwordEditText.text.toString().trim() + val confirmPassword = confirmPasswordEditText.text.toString().trim() + + //判断密码,邮箱,确认密码是否为空 + if (password.isEmpty() || confirmPassword.isEmpty()) { + Toast.makeText(activity, "Please fill in the complete information", Toast.LENGTH_SHORT).show() + } else if (password != confirmPassword) { + Toast.makeText(activity, "The two password entries are inconsistent", Toast.LENGTH_SHORT).show() + } else { + loadingOverlay?.show() + viewLifecycleOwner.lifecycleScope.launch { + try { + val body = ResetPasswordRequest( + mailAddress = savedEmail!!, + password = password, + confirmPassword = confirmPassword + ) + val response = RetrofitClient.apiService.resetPassword(body) + if (response.code == 0) { + EncryptedSharedPreferencesUtil.remove(requireContext(), "forget_email") + Toast.makeText(activity, "Password reset successful. Please log in again.", Toast.LENGTH_LONG).show() + // 使用忘记密码专用的action跳转到登录页面 + findNavController().navigate(R.id.action_global_loginFragment_from_forget_password) + } + } catch (e: Exception) { + Log.e("1314520-MineFragment", "重置密码失败", e) + } finally { + loadingOverlay?.hide() + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt index 15d3047..b36050f 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt @@ -9,15 +9,23 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.EditText +import android.widget.FrameLayout import androidx.fragment.app.Fragment import com.example.myapplication.R import android.widget.TextView import androidx.navigation.fragment.findNavController - +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.VerifyCodeRequest +import android.util.Log class ForgetPasswordVerifyFragment : Fragment() { private lateinit var codeInputs: List + private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null//加载遮罩层 override fun onCreateView( inflater: LayoutInflater, @@ -29,12 +37,47 @@ class ForgetPasswordVerifyFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup) - //验证码页面 - view.findViewById< - TextView>(R.id.nextstep).setOnClickListener { - findNavController().navigate(R.id.action_forgetPasswordVerifyFragment_to_forgetPasswordResetFragment) - } + val savedEmail = EncryptedSharedPreferencesUtil.get(requireContext(), "forget_email", String::class.java) + + view.findViewById(R.id.tv_code_hint).setText("A verification email has been sent to ${savedEmail}. Please check your inbox to complete the verification.") + + + view.findViewById(R.id.iv_close).setOnClickListener { + parentFragmentManager.popBackStack() + } + + view.findViewById(R.id.nextstep).setOnClickListener { + // 验证验证码 + val verifyCode = getVerifyCode() + if (verifyCode.length != 6) { + Toast.makeText(activity, "The verification code format is incorrect", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + // 显示加载遮罩层 + loadingOverlay?.show() + + viewLifecycleOwner.lifecycleScope.launch { + try { + val body = VerifyCodeRequest( + mailAddress = savedEmail!!, + verifyCode = verifyCode + ) + val response = RetrofitClient.apiService.verifyCode(body) + if (response.code == 0 && response.data == true){ + Toast.makeText(activity, "The verification code has been verified successfully", Toast.LENGTH_SHORT).show() + findNavController().navigate(R.id.action_forgetPasswordVerifyFragment_to_forgetPasswordResetFragment) + } + } catch (e: Exception) { + Log.e("1314520-MineFragment", "验证码验证失败", e) + } finally { + // 隐藏加载遮罩层 + loadingOverlay?.hide() + } + } + } codeInputs = listOf( view.findViewById(R.id.et_code_1), diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt index 9d8a473..fba0d87 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt @@ -21,6 +21,11 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import com.google.android.material.button.MaterialButton import android.widget.Toast import kotlinx.coroutines.launch +import androidx.fragment.app.setFragmentResult +import com.example.myapplication.ui.mine.MineFragment +import androidx.core.os.bundleOf +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.network.AuthEvent class LoginFragment : Fragment() { @@ -52,18 +57,14 @@ class LoginFragment : Fragment() { // 注册 view.findViewById(R.id.tv_signup).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_registerFragment) + findNavController().navigate(R.id.action_loginFragment_to_registerFragment) } // 忘记密码 view.findViewById(R.id.tv_forgot_password).setOnClickListener { findNavController().navigate(R.id.action_loginFragment_to_forgetPasswordEmailFragment) } - // 返回按钮 - view.findViewById(R.id.iv_close).setOnClickListener { - findNavController().previousBackStackEntry - ?.savedStateHandle - ?.set("from_login", true) - + // 返回 - 在global_graph中,直接popBackStack回到globalEmptyFragment + view.findViewById(R.id.iv_close).setOnClickListener { findNavController().popBackStack() } // 绑定控件(id 必须和 xml 里的一样) @@ -122,7 +123,9 @@ class LoginFragment : Fragment() { if (response.code == 0) { EncryptedSharedPreferencesUtil.save(requireContext(), "user", response.data) EncryptedSharedPreferencesUtil.save(requireContext(), "email",email) - findNavController().popBackStack() + // 触发登录成功事件,让MainActivity关闭全局overlay + AuthEventBus.emit(AuthEvent.LoginSuccess) + // 不在这里popBackStack,让MainActivity的LoginSuccess事件处理关闭全局overlay } else { Toast.makeText(requireContext(), "Login failed: ${response.message}", Toast.LENGTH_SHORT).show() } @@ -136,4 +139,8 @@ class LoginFragment : Fragment() { } } } + + private fun isLoggedIn(): Boolean { + return EncryptedSharedPreferencesUtil.contains(requireContext(), "user") + } } diff --git a/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt index 54aa6c1..b1eeb8c 100644 --- a/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt @@ -1,17 +1,40 @@ package com.example.myapplication.ui.login import android.os.Bundle +import android.text.InputType import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController -import android.widget.FrameLayout -import android.widget.TextView import com.example.myapplication.R +import androidx.lifecycle.lifecycleScope +import com.example.myapplication.network.SendVerifyCodeRequest +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import com.example.myapplication.ui.common.LoadingOverlay +import kotlinx.coroutines.launch +import android.util.Log class RegisterFragment : Fragment() { + private lateinit var passwordEditText: EditText // 密码输入框 + private lateinit var confirmPasswordEditText: EditText // 确认密码输入框 + private lateinit var emailEditText: EditText // 邮箱输入框 + private lateinit var toggleImageView: ImageView // 密码显示/隐藏按钮 + private lateinit var confirmtoggleImageView: ImageView // 确认密码显示/隐藏按钮 + private lateinit var nextStepButton: TextView // 下一步按钮 + + private var isPasswordVisible = false + private var isConfirmPasswordVisible = false + private var loadingOverlay: LoadingOverlay? = null // 加载遮罩层 + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -23,9 +46,114 @@ class RegisterFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + toggleImageView = view.findViewById(R.id.iv_toggle) + confirmtoggleImageView = view.findViewById(R.id.iv_confirm_toggle) + passwordEditText = view.findViewById(R.id.et_password) + confirmPasswordEditText = view.findViewById(R.id.et_confirm_password) + emailEditText = view.findViewById(R.id.et_email) + nextStepButton = view.findViewById(R.id.btn_next_step) + + // 初始化加载遮罩层 + loadingOverlay = LoadingOverlay.attach(view as ViewGroup) + + // 密码显示/隐藏按钮点击事件 + passwordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + toggleImageView.setOnClickListener { + isPasswordVisible = !isPasswordVisible + + if (isPasswordVisible) { + // 显示密码 + passwordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + toggleImageView.setImageResource(R.drawable.display) + } else { + // 隐藏密码 + passwordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + toggleImageView.setImageResource(R.drawable.hide) + } + // 保持光标在末尾 + passwordEditText.setSelection(passwordEditText.text?.length ?: 0) + } + + // 确认密码显示/隐藏按钮点击事件 + confirmPasswordEditText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + + confirmtoggleImageView.setOnClickListener { + isConfirmPasswordVisible = !isConfirmPasswordVisible + + if (isConfirmPasswordVisible) { + // 显示密码 + confirmPasswordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + confirmtoggleImageView.setImageResource(R.drawable.display) + } else { + // 隐藏密码 + confirmPasswordEditText.inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + confirmtoggleImageView.setImageResource(R.drawable.hide) + } + // 保持光标在末尾 + confirmPasswordEditText.setSelection(confirmPasswordEditText.text?.length ?: 0) + } + + // 下一步按钮点击事件 + nextStepButton.setOnClickListener { + // 对输入框去除首尾空格 + val email = emailEditText.text.toString().trim() + val password = passwordEditText.text.toString().trim() + val confirmPassword = confirmPasswordEditText.text.toString().trim() + + //判断密码,邮箱,确认密码是否为空 + if (password.isEmpty() || email.isEmpty() || confirmPassword.isEmpty()) { + Toast.makeText(activity, "Please fill in the complete information", Toast.LENGTH_SHORT).show() + } else if (password != confirmPassword) { + Toast.makeText(activity, "The two password entries are inconsistent", Toast.LENGTH_SHORT).show() + } else if (!isValidEmail(email)) { + Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show() + } else { + loadingOverlay?.show() + viewLifecycleOwner.lifecycleScope.launch { + try { + val body = SendVerifyCodeRequest(email) + val response = RetrofitClient.apiService.sendVerifyCode(body) + if (response.code == 0) { + EncryptedSharedPreferencesUtil.save(requireContext(), "register_email", email) + EncryptedSharedPreferencesUtil.save(requireContext(), "register_password", password) + EncryptedSharedPreferencesUtil.save(requireContext(), "register_confirm_password", confirmPassword) + findNavController().navigate(R.id.action_registerFragment_to_registerVerifyFragment) + Toast.makeText(activity, "A verification email has been sent to ${email}. Please check your inbox to complete the verification.", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Log.e("1314520-MineFragment", "获取失败", e) + } finally { + loadingOverlay?.hide() + } + } + } + } + + + // 返回按钮 view.findViewById(R.id.iv_close).setOnClickListener { - parentFragmentManager.popBackStack() - } + parentFragmentManager.popBackStack() + } + } + + override fun onDestroyView() { + loadingOverlay?.remove() + super.onDestroyView() + } + + + // 验证邮箱格式是否有效 + // @param email 要验证的邮箱地址 + // @return 如果邮箱格式有效返回true,否则返回false + + private fun isValidEmail(email: String): Boolean { + val emailRegex = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$" + return email.matches(emailRegex.toRegex()) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt new file mode 100644 index 0000000..830e9d1 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt @@ -0,0 +1,175 @@ +// 忘记密码验证码输入页面 +package com.example.myapplication.ui.login + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.Toast +import android.util.Log +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import com.example.myapplication.R +import com.example.myapplication.network.RegisterRequest +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.ui.common.CodeEditText +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import kotlinx.coroutines.launch +import android.widget.TextView + +class RegisterVerifyFragment : Fragment() { + + private lateinit var codeInputs: List + private var loadingOverlay: com.example.myapplication.ui.common.LoadingOverlay? = null // 加载遮罩层 + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_register_verify, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // 初始化加载遮罩层 + loadingOverlay = com.example.myapplication.ui.common.LoadingOverlay.attach(view as ViewGroup) + + view.findViewById(R.id.iv_close).setOnClickListener { + parentFragmentManager.popBackStack() + } + + // 注意:布局里的 6 个输入框需要是 CodeEditText + codeInputs = listOf( + view.findViewById(R.id.et_code_1), + view.findViewById(R.id.et_code_2), + view.findViewById(R.id.et_code_3), + view.findViewById(R.id.et_code_4), + view.findViewById(R.id.et_code_5), + view.findViewById(R.id.et_code_6) + ) + + setupVerifyCodeInputs() + + val savedEmail = EncryptedSharedPreferencesUtil.get(requireContext(), "register_email", String::class.java) + val savedPassword = EncryptedSharedPreferencesUtil.get(requireContext(), "register_password", String::class.java) + val savedConfirmPassword = EncryptedSharedPreferencesUtil.get(requireContext(), "register_confirm_password", String::class.java) + val savedGender = EncryptedSharedPreferencesUtil.get(requireContext(), "gender", String::class.java) + val savedInviteCode = EncryptedSharedPreferencesUtil.get(requireContext(), "inviteCode", String::class.java) + + view.findViewById(R.id.tv_code_hint).text = + "A verification email has been sent to ${savedEmail}. Please check your inbox to complete the verification." + + view.findViewById(R.id.nextstep).setOnClickListener { + val verifyCode = getVerifyCode() + if (verifyCode.length != 6) { + Toast.makeText(activity, "The verification code format is incorrect", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + loadingOverlay?.show() + + viewLifecycleOwner.lifecycleScope.launch { + try { + val body = RegisterRequest( + mailAddress = savedEmail!!, + password = savedPassword!!, + passwordConfirm = savedConfirmPassword!!, + gender = savedGender?.toIntOrNull() ?: 0, + verifyCode = verifyCode, + inviteCode = savedInviteCode + ) + + val response = RetrofitClient.apiService.register(body) + if (response.code == 0) { + Toast.makeText(activity, "Registration successful", Toast.LENGTH_SHORT).show() + EncryptedSharedPreferencesUtil.remove(requireContext(), "register_email") + EncryptedSharedPreferencesUtil.remove(requireContext(), "register_password") + EncryptedSharedPreferencesUtil.remove(requireContext(), "register_confirm_password") + findNavController().navigate(R.id.action_global_loginFragment) + } + } catch (e: Exception) { + Log.e("1314520-MineFragment", "注册失败", e) + } finally { + loadingOverlay?.hide() + } + } + } + } + + override fun onDestroyView() { + loadingOverlay?.remove() + super.onDestroyView() + } + + private fun setupVerifyCodeInputs() { + codeInputs.forEachIndexed { index, editText -> + + // 输入监听:自动跳到下一格;多字符只取最后一位(可应付部分粘贴/联想) + editText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) { + val text = s?.toString().orEmpty() + + if (text.length > 1) { + editText.setText(text.last().toString()) + editText.setSelection(1) + } + + if (text.isNotEmpty() && index < codeInputs.size - 1) { + codeInputs[index + 1].requestFocus() + } + } + }) + + // ✅ 删除统一走这里:软键盘 deleteSurroundingText + 硬件 DEL 都能触发 + editText.onDelPressed = { + handleDeleteAction(index, editText) + } + } + } + + // 获取完整验证码 + private fun getVerifyCode(): String { + return codeInputs.joinToString("") { it.text?.toString().orEmpty() } + } + + /** + * 删除逻辑:当前有内容先删当前;当前为空就跳到前面最近有内容的格子并删除 + */ + private fun handleDeleteAction(index: Int, currentEditText: CodeEditText) { + // 1) 当前格有内容:清空当前格 + if (!currentEditText.text.isNullOrEmpty()) { + currentEditText.text?.clear() + return + } + + // 2) 当前为空:向前找最近一个有内容的格子,跳过去并清空 + var targetIndex = index - 1 + while (targetIndex >= 0) { + val targetEditText = codeInputs[targetIndex] + if (!targetEditText.text.isNullOrEmpty()) { + targetEditText.requestFocus() + targetEditText.text?.clear() + targetEditText.post { targetEditText.setSelection(0) } + return + } + targetIndex-- + } + + // 3) 前面也都空:就停在第一个 + if (index > 0) { + val first = codeInputs[0] + first.requestFocus() + first.post { first.setSelection(0) } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt index 171078e..fb0602f 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/MineFragment.kt @@ -1,23 +1,32 @@ package com.example.myapplication.ui.mine import android.os.Bundle +import android.util.Log 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 android.widget.Toast +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.NavController import androidx.navigation.fragment.findNavController import com.example.myapplication.R -import android.widget.LinearLayout -import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus import com.example.myapplication.network.LoginResponse -import de.hdodenhof.circleimageview.CircleImageView -import android.util.Log -import kotlinx.coroutines.launch -import android.widget.TextView +import com.example.myapplication.network.RetrofitClient import com.example.myapplication.utils.EncryptedSharedPreferencesUtil -import androidx.navigation.navOptions +import de.hdodenhof.circleimageview.CircleImageView +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch class MineFragment : Fragment() { @@ -25,129 +34,179 @@ class MineFragment : Fragment() { private lateinit var time: TextView private lateinit var logout: TextView + private var loadUserJob: Job? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_mine, container, false) + ): View = inflater.inflate(R.layout.fragment_mine, container, false) + + override fun onDestroyView() { + loadUserJob?.cancel() + super.onDestroyView() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 判断是否登录(门禁) - if (!isLoggedIn()) { - val nav = findNavController() - - // 改用 savedStateHandle 的标记:LoginFragment 返回时写入 - val fromLogin = nav.currentBackStackEntry - ?.savedStateHandle - ?.get("from_login") == true - - // 用完就清掉 - nav.currentBackStackEntry?.savedStateHandle?.remove("from_login") - - view?.post { - try { - if (fromLogin) { - // 从登录页回来仍未登录:跳首页 - nav.navigate(R.id.action_global_homeFragment) - } else { - // 不是从登录页来:跳登录 - nav.navigate(R.id.action_mineFragment_to_loginFragment) - } - } catch (e: IllegalArgumentException) { - // 万一你的导航框架在当前时机解析 action 有问题,兜底:直接去目标 Fragment id - if (fromLogin) { - nav.navigate(R.id.homeFragment) - } else { - nav.navigate(R.id.loginFragment) - } - } - } - - return - } - - nickname = view.findViewById(R.id.nickname) time = view.findViewById(R.id.time) logout = view.findViewById(R.id.logout) + // 1) 先用本地缓存秒出首屏 + renderFromCache() - // 获取用户信息, 并显示 - val user = EncryptedSharedPreferencesUtil.get(requireContext(), "Personal_information", LoginResponse::class.java) - nickname.text = user?.nickName ?: "" - time.text = user?.vipExpiry?.let { "Due on November $it" } ?: "" - - // 2) 下一帧再请求网络(让首帧先出来) - view.post { - viewLifecycleOwner.lifecycleScope.launch { - try { - val response = RetrofitClient.apiService.getUser() - nickname.text = response.data?.nickName ?: "" - time.text = response.data?.vipExpiry?.let { "Due on November $it" } ?: "" - EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", response.data) - } catch (e: Exception) { - Log.e("1314520-MineFragment", "获取失败", e) + // 2) 首次进入不刷新,由onResume处理 + + // // ✅ 手动刷新:不改布局也能用 + // // - 点昵称刷新 + // nickname.setOnClickListener { refreshUser(force = true, showToast = true) } + // // - 长按 time 刷新 + // time.setOnLongClickListener { + // refreshUser(force = true, showToast = true) + // true + // } + + logout.setOnClickListener { + LogoutDialogFragment { doLogout() } + .show(parentFragmentManager, "logout_dialog") + } + + view.findViewById(R.id.imgLeft).setOnClickListener { + // 使用事件总线打开充值页面 + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.rechargeFragment)) + } + view.findViewById(R.id.imgRight).setOnClickListener { + // 使用事件总线打开金币充值页面 + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment)) + } + view.findViewById(R.id.avatar).setOnClickListener { + safeNavigate(R.id.action_mineFragment_to_personalSettings) + } + view.findViewById(R.id.keyboard_settings).setOnClickListener { + safeNavigate(R.id.action_mineFragment_to_mykeyboard) + } + view.findViewById(R.id.click_Feedback).setOnClickListener { + safeNavigate(R.id.action_mineFragment_to_feedbackFragment) + } + view.findViewById(R.id.click_Notice).setOnClickListener { + safeNavigate(R.id.action_mineFragment_to_notificationFragment) + } + + // ✅ 监听登录成功/登出事件(跨 NavHost 可靠) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + AuthEventBus.events.collect { event -> + when (event) { + AuthEvent.LoginSuccess -> { + // 先从本地秒渲染,再强制打接口更新 + renderFromCache() + refreshUser(force = true, showToast = false) + } + else -> Unit + } } } } - - // 退出登录(先确认) - logout.setOnClickListener { - LogoutDialogFragment { - // ✅ 用户确认后才执行 - EncryptedSharedPreferencesUtil.remove(requireContext(), "Personal_information") - EncryptedSharedPreferencesUtil.remove(requireContext(), "user") - - // ⚠️ 建议用 popUpTo 清栈,避免按返回回到已登录页面 - findNavController().navigate(R.id.action_mineFragment_to_loginFragment) - }.show(parentFragmentManager, "logout_dialog") - } - - - // 会员充值按钮点击 - view.findViewById(R.id.imgLeft).setOnClickListener { - findNavController().navigate(R.id.action_global_rechargeFragment) - } - - // 金币充值按钮点击 - view.findViewById(R.id.imgRight).setOnClickListener { - findNavController().navigate(R.id.action_global_goldCoinRechargeFragment) - } - - // 头像点击 - view.findViewById(R.id.avatar).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_personalSettings) - } - - //我的键盘 - view.findViewById(R.id.keyboard_settings).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_mykeyboard) - } - - // 反馈按钮点击 - view.findViewById(R.id.click_Feedback).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_feedbackFragment) - } - - // 反馈按钮点击 - view.findViewById(R.id.click_Notice).setOnClickListener { - findNavController().navigate(R.id.action_mineFragment_to_notificationFragment) - } - - //隐私政策 - // view.findViewById(R.id.click_Privacy).setOnClickListener { - // findNavController().navigate(R.id.action_mineFragment_to_loginFragment) - // } - } + + override fun onResume() { + super.onResume() + // ✅ 回到页面先用缓存渲染一遍,再尝试刷新(不强制) + renderFromCache() + refreshUser(force = false) + } + + private fun renderFromCache() { + if (!isAdded) return + val cached = EncryptedSharedPreferencesUtil.get( + requireContext(), + "Personal_information", + LoginResponse::class.java + ) + nickname.text = cached?.nickName ?: "" + time.text = cached?.vipExpiry?.let { "Due on November $it" } ?: "" + } + + /** + * 刷新用户信息: + * - force=true:无条件打接口 + * - force=false:只要已登录就打一次(你可以后续加防抖,这里先保证“必能拿到数据”) + */ + private fun refreshUser(force: Boolean, showToast: Boolean = false) { + if (!isLoggedIn()) { + if (showToast && isAdded) Toast.makeText(requireContext(), "未登录", Toast.LENGTH_SHORT).show() + return + } + + loadUserJob?.cancel() + loadUserJob = viewLifecycleOwner.lifecycleScope.launch { + try { + val response = RetrofitClient.apiService.getUser() + if (!isAdded) return@launch + + val u = response.data + Log.d(TAG, "getUser ok: nick=${u?.nickName} vip=${u?.vipExpiry}") + + nickname.text = u?.nickName ?: "" + time.text = u?.vipExpiry?.let { "Due on November $it" } ?: "" + + EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", u) + + if (showToast) Toast.makeText(requireContext(), "已刷新", 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() + } + } + } + + private fun doLogout() { + viewLifecycleOwner.lifecycleScope.launch { + try { + val response = RetrofitClient.apiService.logout() + if (!isAdded) return@launch + + if (response.code == 0) { + EncryptedSharedPreferencesUtil.remove(requireContext(), "Personal_information") + EncryptedSharedPreferencesUtil.remove(requireContext(), "user") + + // 清空 UI + nickname.text = "" + time.text = "" + + // 触发登出事件,让MainActivity打开登录页面 + AuthEventBus.emit(AuthEvent.Logout(returnTabTag = "tab_mine")) + } else { + Log.e(TAG, "logout fail code=${response.code}") + } + } catch (e: Exception) { + Log.e(TAG, "logout exception", e) + } + } + } + private fun isLoggedIn(): Boolean { - return EncryptedSharedPreferencesUtil.contains(requireContext(), "user") + val ctx = context ?: return false + return EncryptedSharedPreferencesUtil.contains(ctx, "user") + } + + private fun safeNavigate(actionId: Int) { + if (!isAdded) return + if (parentFragmentManager.isStateSaved) return + + val navController: NavController = findNavController() + runCatching { navController.navigate(actionId) } + .onFailure { Log.e(TAG, "navigate error: $actionId", it) } + } + + companion object { + private const val TAG = "1314520-MineFragment" } } diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ShopEvent.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopEvent.kt new file mode 100644 index 0000000..a2b9f15 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopEvent.kt @@ -0,0 +1,20 @@ +package com.example.myapplication.ui.shop + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +sealed class ShopEvent { + object ThemePurchased : ShopEvent() +} + +object ShopEventBus { + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1 + ) + val events = _events.asSharedFlow() + + fun post(event: ShopEvent) { + _events.tryEmit(event) + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt index 45be2a1..0b14ad5 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt @@ -1,6 +1,5 @@ package com.example.myapplication.ui.shop -import android.annotation.SuppressLint import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.graphics.Color @@ -14,41 +13,391 @@ import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.R -import com.example.myapplication.network.ApiResponse -import com.example.myapplication.network.RetrofitClient -import com.example.myapplication.network.Theme -import com.example.myapplication.network.Wallet -import com.example.myapplication.network.themeStyle +import com.example.myapplication.network.* +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import com.google.android.material.appbar.AppBarLayout +import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean class ShopFragment : Fragment(R.layout.fragment_shop) { + // ===== View ===== private lateinit var viewPager: ViewPager2 private lateinit var tagScroll: HorizontalScrollView private lateinit var tagContainer: LinearLayout private lateinit var balance: TextView private lateinit var swipeRefreshLayout: SwipeRefreshLayout - // 风格 tabs + // ===== Data ===== private var tabTitles: List = emptyList() private var styleIds: List = emptyList() - // ✅ 共享数据/缓存/加载都交给 VM - private val vm: ShopViewModel by viewModels() + // ===== ViewModel ===== + private lateinit var vm: ShopViewModel + + // ===== 状态控制 ===== + private var uiInited = false // ⭐ 核心:UI 是否已初始化 + private var loadJob: Job? = null + private val refreshing = AtomicBoolean(false) + private var pageCallback: ViewPager2.OnPageChangeCallback? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + vm = ViewModelProvider(this)[ShopViewModel::class.java] + } + + override fun onDestroyView() { + loadJob?.cancel() + pageCallback?.let { viewPager.unregisterOnPageChangeCallback(it) } + pageCallback = null + uiInited = false + super.onDestroyView() + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + ShopEventBus.events.collect { event -> + when (event) { + ShopEvent.ThemePurchased -> { + vm.clearCache() + refreshData() // ✅ 直接走你现有的刷新逻辑 + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + AuthEventBus.events.collect { event -> + when (event) { + AuthEvent.LoginSuccess -> { + // 登录态变化会影响钱包、主题列表、已购/可用状态:建议全量刷新 + if (!uiInited) { + uiInited = true + loadInitialData() + } else { + vm.clearCache() + refreshData() // 你已有:会 updateBalance + 重新拉主题 + forceLoadStyle + } + } + else -> Unit + } + } + } + } + + bindViews(view) + bindClicks(view) + setupSwipe() + fixViewPager2SwipeConflict() + + if (!uiInited) { + uiInited = true + loadInitialData() + } else { + // ⭐ 再切回来:只刷新轻量数据 + refreshBalanceOnly() + } + } + + // ========================== load ========================== + + private fun loadInitialData() { + loadJob?.cancel() + loadJob = viewLifecycleOwner.lifecycleScope.launch { + + // 钱包 + updateBalance(getwalletBalance()) + + // 主题 + val themeResp = getThemeList() + tabTitles = themeResp?.data ?: emptyList() + styleIds = tabTitles.map { it.id } + + setupViewPagerOnce() + setupTagsOnce() + + styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) } + } + } + + private fun refreshBalanceOnly() { + viewLifecycleOwner.lifecycleScope.launch { + updateBalance(getwalletBalance()) + } + } + + private fun refreshData() { + loadJob?.cancel() + loadJob = viewLifecycleOwner.lifecycleScope.launch { + try { + updateBalance(getwalletBalance()) + + val newThemes = getThemeList()?.data ?: emptyList() + if (newThemes != tabTitles) { + tabTitles = newThemes + styleIds = tabTitles.map { it.id } + + setupViewPagerOnce(force = true) + setupTagsOnce(force = true) + + vm.clearCache() + styleIds.forEach { vm.forceLoadStyle(it) } + } else { + styleIds.getOrNull(viewPager.currentItem) + ?.let { vm.forceLoadStyle(it) } + } + } catch (e: Exception) { + Log.e("ShopFragment", "refresh error", e) + } finally { + refreshing.set(false) + swipeRefreshLayout.isRefreshing = false + } + } + } + + // ========================== ViewPager ========================== + + private fun setupViewPagerOnce(force: Boolean = false) { + if (viewPager.adapter != null && !force) return + + pageCallback?.let { viewPager.unregisterOnPageChangeCallback(it) } + + viewPager.adapter = ShopPagerAdapter(this, styleIds) + viewPager.offscreenPageLimit = 1 + + pageCallback = object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + updateTagState(position) + styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) } + } + } + viewPager.registerOnPageChangeCallback(pageCallback!!) + } + + private fun setupTagsOnce(force: Boolean = false) { + if (tagContainer.childCount > 0 && !force) return + + tagContainer.removeAllViews() + val density = resources.displayMetrics.density + + tabTitles.forEachIndexed { index, theme -> + val tv = TextView(requireContext()).apply { + text = theme.styleName + textSize = 12f + setPadding( + (16 * density).toInt(), + (6 * density).toInt(), + (16 * density).toInt(), + (6 * density).toInt() + ) + background = createCapsuleBackground() + isSelected = index == 0 + updateTagStyleNoAnim(this, isSelected) + setOnClickListener { viewPager.currentItem = index } + } + val gap = (6 * density).toInt() // 标签间距 6dp + + val lp = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + marginEnd = if (index == tabTitles.lastIndex) 0 else gap + } + tagContainer.addView(tv, lp) + } + } + + private fun updateTagState(position: Int) { + for (i in 0 until tagContainer.childCount) { + val tv = tagContainer.getChildAt(i) as TextView + val selected = i == position + if (tv.isSelected != selected) { + tv.isSelected = selected + updateTagStyleWithAnim(tv, selected) + + if (selected) { + tv.post { + if (!isAdded) return@post + val scrollX = tv.left - (tagScroll.width - tv.width) / 2 + tagScroll.smoothScrollTo(scrollX, 0) + } + } + } + } + } + + // ========================== UI helpers ========================== + + private fun updateBalance(walletResp: ApiResponse?) { + val value = walletResp?.data?.balanceDisplay ?: 0 + val text = value.toString() + balance.text = text + adjustBalanceTextSize(text) + } + + private fun adjustBalanceTextSize(text: String) { + balance.textSize = when (text.length) { + in 0..3 -> 40f + 4 -> 36f + 5 -> 32f + 6 -> 28f + 7 -> 24f + 8 -> 22f + 9 -> 20f + else -> 16f + } + } + + private fun createCapsuleBackground(selected: Boolean = false): GradientDrawable = + GradientDrawable().apply { + val d = resources.displayMetrics.density + cornerRadius = 50f * d + if (selected) { + setColor(Color.WHITE) + setStroke((1 * d).toInt().coerceAtLeast(1), Color.parseColor("#02BEAC")) + } else { + setColor(Color.parseColor("#F1F1F1")) + setStroke(0, Color.TRANSPARENT) + } + } + + private fun updateTagStyleNoAnim(tv: TextView, selected: Boolean) { + tv.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) + tv.setTextColor(if (selected) Color.parseColor("#1B1F1A") else Color.parseColor("#9F9F9F")) + tv.background = createCapsuleBackground(selected) + } + + private fun updateTagStyleWithAnim(tv: TextView, selected: Boolean) { + val start = if (selected) Color.parseColor("#9F9F9F") else Color.parseColor("#1B1F1A") + val end = if (selected) Color.parseColor("#1B1F1A") else Color.parseColor("#9F9F9F") + ValueAnimator.ofObject(ArgbEvaluator(), start, end).apply { + duration = 150 + addUpdateListener { tv.setTextColor(it.animatedValue as Int) } + start() + } + tv.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) + tv.background = createCapsuleBackground(selected) + } + + private var appBarFullyExpanded = true + + private fun setupSwipe() { + swipeRefreshLayout.setOnRefreshListener { + if (refreshing.compareAndSet(false, true)) { + refreshData() + } else { + swipeRefreshLayout.isRefreshing = false + } + } + setupSwipeRefreshConflictFix() + } + + private fun setupSwipeRefreshConflictFix() { + val appBar = requireView().findViewById(R.id.appBar) + + // 1) 监听 AppBar 是否完全展开 + appBar.addOnOffsetChangedListener { _, verticalOffset -> + appBarFullyExpanded = (verticalOffset == 0) + } + + // 2) 核心:自定义"子 View 是否能向上滚"的判断 + swipeRefreshLayout.setOnChildScrollUpCallback { _, _ -> + // AppBar 没完全展开:不要让刷新抢手势(优先展开/折叠头部) + if (!appBarFullyExpanded) return@setOnChildScrollUpCallback true + // 找到 ViewPager2 当前页的 RecyclerView + val rv = findCurrentPageRecyclerView() + // rv 能向上滚:说明列表不在顶部 -> 禁止触发刷新 + rv?.canScrollVertically(-1) ?: false + } + } + + private fun findCurrentPageRecyclerView(): RecyclerView? { + // FragmentStateAdapter 默认 tag 通常是 "f0", "f1"... + val pos = viewPager.currentItem + val f = childFragmentManager.findFragmentByTag("f$pos") + return f?.view?.findViewById(R.id.recyclerView) + } + + private fun fixViewPager2SwipeConflict() { + (viewPager.getChildAt(0) as? RecyclerView)?.setOnTouchListener { v, e -> + when (e.actionMasked) { + MotionEvent.ACTION_DOWN -> { + // 不再在 ACTION_DOWN 时就 disallow,让父容器有机会判断 + false + } + MotionEvent.ACTION_MOVE -> { + // 只对横向滑动明显时 disallow + val dx = Math.abs(e.x - v.x) + val dy = Math.abs(e.y - v.y) + if (dx > dy * 2) { // 横向滑动明显 + v.parent?.requestDisallowInterceptTouchEvent(true) + } + false + } + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + v.parent?.requestDisallowInterceptTouchEvent(false) + false + } + } + false + } + } + + // ========================== Adapter ========================== + + private inner class ShopPagerAdapter( + fragment: Fragment, + private val styleIds: List + ) : FragmentStateAdapter(fragment) { + override fun getItemCount() = styleIds.size + override fun createFragment(position: Int) = + ShopStylePageFragment.newInstance(styleIds[position]) + } + + // ========================== network ========================== + + private suspend fun getwalletBalance(): ApiResponse? = + runCatching { RetrofitClient.apiService.walletBalance() }.getOrNull() + + private suspend fun getThemeList(): ApiResponse>? = + runCatching { RetrofitClient.apiService.themeList() }.getOrNull() + + private fun bindViews(view: View) { + viewPager = view.findViewById(R.id.viewPager) + tagScroll = view.findViewById(R.id.tagScroll) + tagContainer = view.findViewById(R.id.tagContainer) + balance = view.findViewById(R.id.balance) + swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) + + swipeRefreshLayout.isEnabled = true + swipeRefreshLayout.setColorSchemeColors( + Color.parseColor("#02BEAC"), + Color.parseColor("#1B1F1A"), + Color.parseColor("#9F9F9F") + ) + } + + private fun bindClicks(view: View) { view.findViewById(R.id.rechargeButton).setOnClickListener { - findNavController().navigate(R.id.action_global_goldCoinRechargeFragment) + // 使用事件总线打开金币充值页面 + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment)) } view.findViewById(R.id.skinButton).setOnClickListener { findNavController().navigate(R.id.action_shopfragment_to_myskin) @@ -56,366 +405,7 @@ class ShopFragment : Fragment(R.layout.fragment_shop) { view.findViewById(R.id.searchButton).setOnClickListener { findNavController().navigate(R.id.action_shopfragment_to_searchfragment) } - - tagScroll = view.findViewById(R.id.tagScroll) - tagContainer = view.findViewById(R.id.tagContainer) - viewPager = view.findViewById(R.id.viewPager) - balance = view.findViewById(R.id.balance) - swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) - - // 设置下拉刷新监听器 - swipeRefreshLayout.setOnRefreshListener { - refreshData() - } - - // 设置刷新指示器颜色 - swipeRefreshLayout.setColorSchemeColors( - Color.parseColor("#02BEAC"), - Color.parseColor("#1B1F1A"), - Color.parseColor("#9F9F9F") - ) - - // 禁用默认的刷新行为,使用自定义逻辑 - swipeRefreshLayout.isEnabled = false - - // 设置 ViewPager 的子页面滚动监听 - setupViewPagerScrollListener() - - loadInitialData() - - // 修复 ViewPager2 和 SwipeRefreshLayout 的手势冲突 - fixViewPager2SwipeConflict() } - private fun loadInitialData() { - viewLifecycleOwner.lifecycleScope.launch { - val walletResp = getwalletBalance() - val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString() - balance.text = balanceText - adjustBalanceTextSize(balanceText) - - val themeListResp = getThemeList() - tabTitles = themeListResp?.data ?: emptyList() - Log.d("1314520-Shop", "风格列表: $tabTitles") - - styleIds = tabTitles.map { it.id } - - viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds) - - setupTags() - setupViewPager() - - // ✅ 默认加载第一个(交给 VM) - viewPager.post { - styleIds.firstOrNull()?.let { vm.loadStyleIfNeeded(it) } - } - } - } - - /** - * 根据字符数量调整余额文本的字体大小 - * 字符数量越多,字体越小 - */ - private fun adjustBalanceTextSize(text: String) { - val maxFontSize = 40f // 最大字体大小(sp) - val minFontSize = 16f // 最小字体大小(sp) - - // 根据字符数量计算字体大小 - val fontSize = when (text.length) { - 0, 1, 2, 3 -> maxFontSize // 0-3个字符使用最大字体 - 4 -> 36f - 5 -> 32f - 6 -> 28f - 7 -> 24f - 8 -> 22f - 9 -> 20f - else -> minFontSize // 10个字符及以上使用最小字体 - } - - balance.textSize = fontSize - } - - private fun refreshData() { - viewLifecycleOwner.lifecycleScope.launch { - try { - // 重新获取钱包余额 - val walletResp = getwalletBalance() - val balanceText = (walletResp?.data?.balanceDisplay ?: 0).toString() - balance.text = balanceText - adjustBalanceTextSize(balanceText) - - // 重新获取主题列表 - val themeListResp = getThemeList() - val newTabTitles = themeListResp?.data ?: emptyList() - - // 检查主题列表是否有变化 - if (newTabTitles != tabTitles) { - tabTitles = newTabTitles - styleIds = tabTitles.map { it.id } - - // 重新设置适配器 - viewPager.adapter = ShopPagerAdapter(this@ShopFragment, styleIds) - - // 重新设置标签 - setupTags() - - // 通知 ViewModel 清除缓存 - vm.clearCache() - - // 强制重新加载所有页面的数据 - styleIds.forEach { styleId -> - // 强制重新加载,即使有缓存也要重新获取 - vm.forceLoadStyle(styleId) - } - } else { - // 主题列表没有变化,强制重新加载当前页面的数据 - val currentPosition = viewPager.currentItem - styleIds.getOrNull(currentPosition)?.let { vm.forceLoadStyle(it) } - } - - Log.d("1314520-Shop", "下拉刷新完成") - } catch (e: Exception) { - Log.e("1314520-Shop", "下拉刷新失败", e) - } finally { - // 停止刷新动画 - swipeRefreshLayout.isRefreshing = false - } - } - } - - /** 子页读取缓存(从 VM 读) */ - fun getCachedList(styleId: Int): List = vm.getCached(styleId) - - /** 动态创建标签 */ - private fun setupTags() { - tagContainer.removeAllViews() - - val context = requireContext() - val density = context.resources.displayMetrics.density - val paddingHorizontal = (16 * density).toInt() - val paddingVertical = (6 * density).toInt() - val marginEnd = (8 * density).toInt() - - tabTitles.forEachIndexed { index, title -> - val tv = TextView(context).apply { - text = title.styleName - textSize = 12f - setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical) - - layoutParams = LinearLayout.LayoutParams( - LinearLayout.LayoutParams.WRAP_CONTENT, - LinearLayout.LayoutParams.MATCH_PARENT - ).apply { setMargins(0, 0, marginEnd, 0) } - - gravity = android.view.Gravity.CENTER - background = createCapsuleBackground() - - isSelected = index == 0 - updateTagStyleNoAnim(this, isSelected) - - setOnClickListener { - if (viewPager.currentItem != index) viewPager.currentItem = index - } - } - tagContainer.addView(tv) - } - } - - private fun createCapsuleBackground(): GradientDrawable { - val density = resources.displayMetrics.density - return GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 50f * density - setColor(Color.parseColor("#F1F1F1")) - setStroke((2 * density).toInt(), Color.parseColor("#F1F1F1")) - } - } - - private fun setupViewPager() { - // ✅ 只设置一次 - viewPager.offscreenPageLimit = 1 - - viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - updateTagState(position) - - // ✅ 切换到某页就按需加载(交给 VM) - styleIds.getOrNull(position)?.let { vm.loadStyleIfNeeded(it) } - } - }) - } - - private fun updateTagState(position: Int) { - for (i in 0 until tagContainer.childCount) { - val child = tagContainer.getChildAt(i) as TextView - val newSelected = i == position - if (child.isSelected == newSelected) continue - - child.isSelected = newSelected - updateTagStyleWithAnim(child, newSelected) - - if (newSelected) { - child.post { - val scrollX = child.left - (tagScroll.width - child.width) / 2 - tagScroll.smoothScrollTo(scrollX, 0) - } - } - } - } - - private fun setupViewPagerScrollListener() { - // 监听 AppBarLayout 的展开状态来判断是否在顶部 - view?.findViewById(R.id.appBar)?.addOnOffsetChangedListener { appBarLayout, verticalOffset -> - val isAtTop = verticalOffset == 0 - swipeRefreshLayout.isEnabled = isAtTop - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun fixViewPager2SwipeConflict() { - val rv = viewPager.getChildAt(0) as? RecyclerView ?: return - rv.setOnTouchListener { v, ev -> - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN -> v.parent?.requestDisallowInterceptTouchEvent(true) - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> - v.parent?.requestDisallowInterceptTouchEvent(false) - } - false - } - } - - private fun updateTagStyleNoAnim(textView: TextView, selected: Boolean) { - val density = resources.displayMetrics.density - val bg = (textView.background as? GradientDrawable) - ?: createCapsuleBackground().also { textView.background = it } - val strokeWidth = (2 * density).toInt() - - if (selected) { - bg.setColor(Color.parseColor("#FFFFFF")) - bg.setStroke(strokeWidth, Color.parseColor("#02BEAC")) - textView.setTextColor(Color.parseColor("#1B1F1A")) - textView.setTypeface(null, Typeface.BOLD) - } else { - bg.setColor(Color.parseColor("#F1F1F1")) - bg.setStroke(strokeWidth, Color.parseColor("#F1F1F1")) - textView.setTextColor(Color.parseColor("#9F9F9F")) - textView.setTypeface(null, Typeface.NORMAL) - } - } - - private fun updateTagStyleWithAnim(textView: TextView, selected: Boolean) { - val density = resources.displayMetrics.density - val bg = (textView.background as? GradientDrawable) - ?: createCapsuleBackground().also { textView.background = it } - val strokeWidth = (2 * density).toInt() - - val selectedTextColor = Color.parseColor("#1B1F1A") - val unselectedTextColor = Color.parseColor("#9F9F9F") - val selectedStrokeColor = Color.parseColor("#02BEAC") - val unselectedStrokeColor = Color.parseColor("#F1F1F1") - val selectedBgColor = Color.parseColor("#FFFFFF") - val unselectedBgColor = Color.parseColor("#F1F1F1") - - val colorsArray = if (selected) { - arrayOf( - unselectedTextColor, selectedTextColor, - unselectedStrokeColor, selectedStrokeColor, - unselectedBgColor, selectedBgColor - ) - } else { - arrayOf( - selectedTextColor, unselectedTextColor, - selectedStrokeColor, unselectedStrokeColor, - selectedBgColor, unselectedBgColor - ) - } - - val startTextColor = colorsArray[0] - val endTextColor = colorsArray[1] - val startStrokeColor = colorsArray[2] - val endStrokeColor = colorsArray[3] - val startBgColor = colorsArray[4] - val endBgColor = colorsArray[5] - - val evaluator = ArgbEvaluator() - ValueAnimator.ofFloat(0f, 1f).apply { - duration = 200L - addUpdateListener { va -> - val f = va.animatedFraction - textView.setTextColor(evaluator.evaluate(f, startTextColor, endTextColor) as Int) - bg.setStroke(strokeWidth, evaluator.evaluate(f, startStrokeColor, endStrokeColor) as Int) - bg.setColor(evaluator.evaluate(f, startBgColor, endBgColor) as Int) - } - start() - } - - textView.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) - } - - private inner class ShopPagerAdapter( - fragment: Fragment, - private val styleIds: List - ) : FragmentStateAdapter(fragment) { - - override fun getItemCount(): Int = styleIds.size - - override fun createFragment(position: Int): Fragment { - val styleId = styleIds[position] - return ShopStylePageFragment.newInstance(styleId) - } - } - - // ============================ 网络请求 ============================ - - private suspend fun getwalletBalance(): ApiResponse? { - return try { - RetrofitClient.apiService.walletBalance() - } catch (e: Exception) { - Log.e("1314520-ShopFragment", "获取钱包余额失败", e) - null - } - } - - private suspend fun getThemeList(): ApiResponse>? { - return try { - RetrofitClient.apiService.themeList() - } catch (e: Exception) { - Log.e("1314520-ShopFragment", "获取主题风格失败", e) - null - } - } - /** - * 根据余额值计算字体大小 - * 基础字体大小16sp,数字越大字体越小 - */ - // private fun calculateFontSize(balance: Double): Float { - // val baseSize = 40f // 基础字体大小 - // val minSize = 5f // 最小字体大小 - // val maxSize = 40f // 最大字体大小 - - // // 使用对数函数实现平滑的字体大小变化 - // // 当余额为0时使用最大字体,余额越大字体越小 - // val scaleFactor = when { - // balance <= 0 -> 1.0 - // balance < 10 -> 0.93 - // balance < 100 -> 0.86 - // balance < 1000 -> 0.79 - // balance < 10000 -> 0.72 - // balance < 100000 -> 0.65 - // balance < 1000000 -> 0.58 - // balance < 10000000 -> 0.51 - // balance < 100000000 -> 0.44 - // balance < 1000000000 -> 0.37 - // balance < 10000000000 -> 0.3 - // balance < 100000000000 -> 0.23 - // balance < 1000000000000 -> 0.16 - // else -> 0.09 - // } - - // val calculatedSize = baseSize * scaleFactor.toFloat() - - // // 确保字体大小在最小和最大限制范围内 - // return calculatedSize.coerceIn(minSize, maxSize) - // } } + diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt index 360362d..4aafab6 100644 --- a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt @@ -13,6 +13,8 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.example.myapplication.R +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus import com.example.myapplication.network.themeStyle import com.google.android.material.card.MaterialCardView @@ -54,7 +56,8 @@ class ThemeCardAdapter : ListAdapter + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mine_selector.xml b/app/src/main/res/drawable/ic_mine_selector.xml new file mode 100644 index 0000000..271dcfa --- /dev/null +++ b/app/src/main/res/drawable/ic_mine_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shop_selector.xml b/app/src/main/res/drawable/ic_shop_selector.xml new file mode 100644 index 0000000..9c9cd6e --- /dev/null +++ b/app/src/main/res/drawable/ic_shop_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/mine.png b/app/src/main/res/drawable/mine.png index b3a7137..15a9317 100644 Binary files a/app/src/main/res/drawable/mine.png and b/app/src/main/res/drawable/mine.png differ diff --git a/app/src/main/res/drawable/personalized_keyboard.png b/app/src/main/res/drawable/personalized_keyboard.png index 57ec98c..24b3767 100644 Binary files a/app/src/main/res/drawable/personalized_keyboard.png and b/app/src/main/res/drawable/personalized_keyboard.png differ diff --git a/app/src/main/res/drawable/selected_circle.png b/app/src/main/res/drawable/selected_circle.png index 195942f..cfa00ff 100644 Binary files a/app/src/main/res/drawable/selected_circle.png and b/app/src/main/res/drawable/selected_circle.png differ diff --git a/app/src/main/res/drawable/selected_home.png b/app/src/main/res/drawable/selected_home.png index 9fa464c..01c5a45 100644 Binary files a/app/src/main/res/drawable/selected_home.png and b/app/src/main/res/drawable/selected_home.png differ diff --git a/app/src/main/res/drawable/selected_mine.png b/app/src/main/res/drawable/selected_mine.png index 414e6d5..b89d509 100644 Binary files a/app/src/main/res/drawable/selected_mine.png and b/app/src/main/res/drawable/selected_mine.png differ diff --git a/app/src/main/res/drawable/selected_shop.png b/app/src/main/res/drawable/selected_shop.png index 63cd79d..5bfcf01 100644 Binary files a/app/src/main/res/drawable/selected_shop.png and b/app/src/main/res/drawable/selected_shop.png differ diff --git a/app/src/main/res/drawable/shop.png b/app/src/main/res/drawable/shop.png index 16005cb..aad5232 100644 Binary files a/app/src/main/res/drawable/shop.png and b/app/src/main/res/drawable/shop.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index aa61952..d1d4b51 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,16 +7,27 @@ android:layout_height="match_parent" tools:context=".MainActivity"> - - + android:layout_height="match_parent"> + + + + + + + + - - + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_empty.xml b/app/src/main/res/layout/fragment_empty.xml new file mode 100644 index 0000000..02cd488 --- /dev/null +++ b/app/src/main/res/layout/fragment_empty.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_forget_password_email.xml b/app/src/main/res/layout/fragment_forget_password_email.xml index c468711..17a44bd 100644 --- a/app/src/main/res/layout/fragment_forget_password_email.xml +++ b/app/src/main/res/layout/fragment_forget_password_email.xml @@ -53,7 +53,7 @@ android:textColor="#1B1F1A"/> + + + + + @@ -103,8 +103,8 @@ android:orientation="vertical"> @@ -127,8 +127,8 @@ android:orientation="vertical"> @@ -151,8 +151,8 @@ android:orientation="vertical"> diff --git a/app/src/main/res/layout/fragment_register.xml b/app/src/main/res/layout/fragment_register.xml index 11e2c87..3480ee8 100644 --- a/app/src/main/res/layout/fragment_register.xml +++ b/app/src/main/res/layout/fragment_register.xml @@ -99,7 +99,7 @@ - + + android:text="Next step" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_bottom_nav.xml b/app/src/main/res/menu/menu_bottom_nav.xml index e6e632c..f9aee97 100644 --- a/app/src/main/res/menu/menu_bottom_nav.xml +++ b/app/src/main/res/menu/menu_bottom_nav.xml @@ -2,24 +2,15 @@ + android:id="@+id/home_graph" + android:icon="@drawable/ic_home_selector"/> - - + android:id="@+id/shop_graph" + android:icon="@drawable/ic_shop_selector"/> + android:id="@+id/mine_graph" + android:icon="@drawable/ic_mine_selector" /> - diff --git a/app/src/main/res/navigation/global_graph.xml b/app/src/main/res/navigation/global_graph.xml new file mode 100644 index 0000000..25035c3 --- /dev/null +++ b/app/src/main/res/navigation/global_graph.xml @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/home_graph.xml b/app/src/main/res/navigation/home_graph.xml new file mode 100644 index 0000000..207be59 --- /dev/null +++ b/app/src/main/res/navigation/home_graph.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/navigation/mine_graph.xml b/app/src/main/res/navigation/mine_graph.xml new file mode 100644 index 0000000..864693c --- /dev/null +++ b/app/src/main/res/navigation/mine_graph.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 62278c2..0cfbfba 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -3,285 +3,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph" - app:startDestination="@id/homeFragment"> + app:startDestination="@id/home_graph"> - - - - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/app/src/main/res/navigation/shop_graph.xml b/app/src/main/res/navigation/shop_graph.xml new file mode 100644 index 0000000..c26acea --- /dev/null +++ b/app/src/main/res/navigation/shop_graph.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b9300ba..eecab10 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -19,6 +19,7 @@ center 20sp 1 + number @drawable/code_box_bg #000000