From de48cc7900879adcc2c9b72dfaa0a6ed3dbbe152 Mon Sep 17 00:00:00 2001 From: pengxiaolong <15716207+pengxiaolong711@user.noreply.gitee.com> Date: Thu, 8 Jan 2026 14:56:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=BA=E6=9C=AC=E4=BC=98=E5=8C=96=E5=AE=8C?= =?UTF-8?q?=E6=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 22 +- .../com/example/myapplication/MainActivity.kt | 149 ++++--- .../myapplication/MyInputMethodService.kt | 67 ++-- .../myapplication/OnboardingActivity.kt | 56 +++ .../keyboard/KeyboardEnvironment.kt | 2 + .../myapplication/keyboard/MainKeyboard.kt | 5 + .../myapplication/network/ApiService.kt | 57 ++- .../myapplication/network/AuthEventBus.kt | 7 +- .../myapplication/network/HttpInterceptors.kt | 212 +++++++++- .../example/myapplication/network/Models.kt | 70 +++- .../myapplication/network/RetrofitClient.kt | 8 + .../network/security/BodyParamsExtractor.kt | 137 +++++++ .../network/security/NonceUtils.kt | 11 + .../network/security/SignUtils.kt | 38 ++ .../myapplication/ui/circle/CircleFragment.kt | 19 - .../myapplication/ui/common/LoadingOverlay.kt | 25 +- .../myapplication/ui/home/HomeFragment.kt | 328 ++++++++++++--- .../myapplication/ui/home/PersonaAdapter.kt | 45 ++- .../ui/home/PersonaDetailDialogFragment.kt | 23 +- .../keyboard/ConfirmDeleteDialogFragment.kt | 72 ++++ .../ui/keyboard/DragSortCallback.kt | 68 ++++ .../ui/keyboard/KeyboardAdapter.kt | 57 +++ .../myapplication/ui/keyboard/MyKeyboard.kt | 130 +++++- .../myapplication/ui/mine/MineFragment.kt | 79 +++- .../consumptionRecord/TransactionAdapter.kt | 174 ++++++++ .../consumptionRecordFragment.kt | 181 +++++++++ .../ui/mine/myotherpages/FeedbackFragment.kt | 66 ++- .../ui/mine/myotherpages/GenderSelectSheet.kt | 165 ++++++++ .../ui/mine/myotherpages/NicknameEditSheet.kt | 64 +++ .../ui/mine/myotherpages/PersonalSettings.kt | 378 +++++++++++++++++- app/src/main/res/drawable/associate_close.png | Bin 0 -> 2905 bytes .../main/res/drawable/complete_close_bg.xml | 9 + .../res/drawable/consumption_details_bg.xml | 19 + app/src/main/res/drawable/first_add.png | Bin 0 -> 2685 bytes .../main/res/drawable/gender_background.xml | 1 + .../res/drawable/gender_background_select.xml | 6 + app/src/main/res/drawable/gold_coin_bg.xml | 7 + .../main/res/drawable/list_two_bg_already.xml | 5 + .../main/res/drawable/my_keyboard_cancel.xml | 5 + app/src/main/res/drawable/operation_add.png | Bin 0 -> 2563 bytes app/src/main/res/drawable/pop_collapse.png | Bin 0 -> 2294 bytes app/src/main/res/drawable/record.png | Bin 0 -> 2660 bytes .../main/res/drawable/round_bg_others_add.png | Bin 0 -> 2717 bytes .../res/drawable/round_bg_others_already.xml | 7 + app/src/main/res/drawable/round_bg_three.xml | 2 +- app/src/main/res/drawable/round_bg_two.xml | 2 +- app/src/main/res/drawable/second_add.png | Bin 0 -> 2735 bytes app/src/main/res/drawable/third_add.png | Bin 0 -> 2696 bytes .../main/res/layout/activity_onboarding.xml | 7 +- app/src/main/res/layout/bottom_page_list1.xml | 61 +-- .../dialog_confirm_delete_character.xml | 83 ++++ app/src/main/res/layout/feedback_fragment.xml | 11 +- app/src/main/res/layout/fragment_circle.xml | 13 - .../layout/fragment_consumption_record.xml | 28 ++ app/src/main/res/layout/fragment_mine.xml | 57 ++- .../res/layout/item_keyboard_character.xml | 30 ++ .../main/res/layout/item_loading_footer.xml | 22 + app/src/main/res/layout/item_persona.xml | 21 +- app/src/main/res/layout/item_rank_other.xml | 17 +- .../res/layout/item_transaction_record.xml | 47 +++ app/src/main/res/layout/keyboard.xml | 33 +- .../layout_consumption_record_header.xml | 136 +++++++ app/src/main/res/layout/my_keyboard.xml | 75 +--- app/src/main/res/layout/personal_settings.xml | 6 + .../main/res/layout/sheet_edit_nickname.xml | 61 +++ .../main/res/layout/sheet_select_gender.xml | 76 ++++ app/src/main/res/navigation/global_graph.xml | 51 ++- app/src/main/res/navigation/mine_graph.xml | 1 + app/src/main/res/values/strings.xml | 7 + app/src/main/res/xml/file_paths.xml | 6 + 70 files changed, 3222 insertions(+), 405 deletions(-) create mode 100644 app/src/main/java/com/example/myapplication/network/security/BodyParamsExtractor.kt create mode 100644 app/src/main/java/com/example/myapplication/network/security/NonceUtils.kt create mode 100644 app/src/main/java/com/example/myapplication/network/security/SignUtils.kt delete mode 100644 app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/keyboard/ConfirmDeleteDialogFragment.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/keyboard/DragSortCallback.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardAdapter.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/mine/myotherpages/GenderSelectSheet.kt create mode 100644 app/src/main/java/com/example/myapplication/ui/mine/myotherpages/NicknameEditSheet.kt create mode 100644 app/src/main/res/drawable/associate_close.png create mode 100644 app/src/main/res/drawable/complete_close_bg.xml create mode 100644 app/src/main/res/drawable/consumption_details_bg.xml create mode 100644 app/src/main/res/drawable/first_add.png create mode 100644 app/src/main/res/drawable/gender_background_select.xml create mode 100644 app/src/main/res/drawable/gold_coin_bg.xml create mode 100644 app/src/main/res/drawable/list_two_bg_already.xml create mode 100644 app/src/main/res/drawable/my_keyboard_cancel.xml create mode 100644 app/src/main/res/drawable/operation_add.png create mode 100644 app/src/main/res/drawable/pop_collapse.png create mode 100644 app/src/main/res/drawable/record.png create mode 100644 app/src/main/res/drawable/round_bg_others_add.png create mode 100644 app/src/main/res/drawable/round_bg_others_already.xml create mode 100644 app/src/main/res/drawable/second_add.png create mode 100644 app/src/main/res/drawable/third_add.png create mode 100644 app/src/main/res/layout/dialog_confirm_delete_character.xml delete mode 100644 app/src/main/res/layout/fragment_circle.xml create mode 100644 app/src/main/res/layout/fragment_consumption_record.xml create mode 100644 app/src/main/res/layout/item_keyboard_character.xml create mode 100644 app/src/main/res/layout/item_loading_footer.xml create mode 100644 app/src/main/res/layout/item_transaction_record.xml create mode 100644 app/src/main/res/layout/layout_consumption_record_header.xml create mode 100644 app/src/main/res/layout/sheet_edit_nickname.xml create mode 100644 app/src/main/res/layout/sheet_select_gender.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0ced4fa..a1fb644 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,8 +3,10 @@ - - + + + + @@ -30,22 +33,26 @@ @@ -63,5 +70,16 @@ android:name="android.view.im" android:resource="@xml/method" /> + + + + + diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt index 9afd912..0c4d983 100644 --- a/app/src/main/java/com/example/myapplication/MainActivity.kt +++ b/app/src/main/java/com/example/myapplication/MainActivity.kt @@ -105,9 +105,11 @@ class MainActivity : AppCompatActivity() { openGlobal(R.id.loginFragment) } } + is AuthEvent.GenericError -> { - Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show() + Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_LONG).show() } + // 登录成功事件处理 is AuthEvent.LoginSuccess -> { // 关闭 global overlay:回到 empty @@ -123,24 +125,37 @@ class MainActivity : AppCompatActivity() { } } pendingTabAfterLogin = null + + // ✅ 登录成功后也刷新一次 + bottomNav.post { updateBottomNavVisibility() } } + // 登出事件处理 is AuthEvent.Logout -> { pendingTabAfterLogin = event.returnTabTag - + // ✅ 用户没登录按返回,应回首页,所以先切到首页 switchTab(TAB_HOME, force = true) - + bottomNav.post { bottomNav.selectedItemId = R.id.home_graph openGlobal(R.id.loginFragment) // ✅ 退出登录后立刻打开登录页 } } + // 打开全局页面事件处理 is AuthEvent.OpenGlobalPage -> { - // 打开指定的全局页面 openGlobal(event.destinationId, event.bundle) } + is AuthEvent.UserUpdated -> { + // 不需要处理 + } + is AuthEvent.CharacterDeleted -> { + // 不需要处理 + } + is AuthEvent.CharacterAdded -> { + // 不需要处理,由HomeFragment处理 + } } } } @@ -158,9 +173,16 @@ class MainActivity : AppCompatActivity() { TAB_MINE -> R.id.mine_graph else -> R.id.home_graph } + updateBottomNavVisibility() } } + override fun onResume() { + super.onResume() + // ✅ 最终兜底:从后台回来 / 某些场景没触发 listener,也能恢复底栏 + bottomNav.post { updateBottomNavVisibility() } + } + private fun initHosts() { val fm = supportFragmentManager @@ -196,22 +218,55 @@ class MainActivity : AppCompatActivity() { // 绑定全局导航可见性监听 bindGlobalVisibility() - + // 绑定底部导航栏可见性监听 bindBottomNavVisibilityForTabs() + + bottomNav.post { updateBottomNavVisibility() } + } + + /** + * 这些页面需要隐藏底部导航栏:你按需加/减 + */ + private fun shouldHideBottomNav(destId: Int): Boolean { + return destId in setOf( + R.id.searchFragment, + R.id.searchResultFragment, + R.id.MySkin, + R.id.notificationFragment, + R.id.feedbackFragment, + R.id.MyKeyboard, + R.id.PersonalSettings, + ) + } + + /** + * ✅ 统一底栏显隐逻辑:任何地方状态变化都调用它 + */ + private fun updateBottomNavVisibility() { + // ✅ 只要 global overlay 不在 empty,底栏必须隐藏(用 NavController 判断,别用 View.visibility) + if (isGlobalVisible()) { + bottomNav.visibility = View.GONE + return + } + + // 否则按“当前可见 tab 的当前目的地”判断 + val destId = currentTabNavController.currentDestination?.id + bottomNav.visibility = if (destId != null && shouldHideBottomNav(destId)) View.GONE else View.VISIBLE } private fun bindGlobalVisibility() { globalNavController.addOnDestinationChangedListener { _, dest, _ -> val isEmpty = dest.id == R.id.globalEmptyFragment - - findViewById(R.id.global_container).visibility = - if (isEmpty) View.GONE else View.VISIBLE - bottomNav.visibility = - if (isEmpty) View.VISIBLE else View.GONE - // ✅ 只在"刚从某个全局页关闭回 empty"时触发回退逻辑 - val justClosedOverlay = (dest.id == R.id.globalEmptyFragment && lastGlobalDestId != R.id.globalEmptyFragment) + findViewById(R.id.global_container).visibility = + if (isEmpty) View.GONE else View.VISIBLE + + // ✅ 底栏统一走 update + updateBottomNavVisibility() + + val justClosedOverlay = + (dest.id == R.id.globalEmptyFragment && lastGlobalDestId != R.id.globalEmptyFragment) lastGlobalDestId = dest.id if (justClosedOverlay) { @@ -220,16 +275,17 @@ class MainActivity : AppCompatActivity() { TAB_MINE -> R.id.mine_graph else -> R.id.home_graph } - // 未登录且当前处在受保护tab:强制回首页 + if (!isLoggedIn() && currentTabGraphId in protectedTabs) { switchTab(TAB_HOME, force = true) bottomNav.selectedItemId = R.id.home_graph } - // ✅ 只有"没登录就关闭登录页"才清 pending if (!isLoggedIn()) { pendingTabAfterLogin = null } + + bottomNav.post { updateBottomNavVisibility() } } } } @@ -238,7 +294,7 @@ class MainActivity : AppCompatActivity() { if (!force && targetTag == currentTabTag) return val fm = supportFragmentManager - if (fm.isStateSaved) return // ✅ 防崩:stateSaved 时不做事务 + if (fm.isStateSaved) return currentTabTag = targetTag @@ -255,42 +311,32 @@ class MainActivity : AppCompatActivity() { } } .commit() + + // ✅ 关键:hide/show 切 tab 不会触发 destinationChanged,所以手动刷新 + bottomNav.post { updateBottomNavVisibility() } } /** 打开全局页(login/recharge等) */ private fun openGlobal(destId: Int, bundle: Bundle? = null) { val fm = supportFragmentManager - if (fm.isStateSaved) return // ✅ 防崩 + if (fm.isStateSaved) return try { - if (bundle != null) { - globalNavController.navigate(destId, bundle) - } else { - globalNavController.navigate(destId) - } + if (bundle != null) globalNavController.navigate(destId, bundle) + else globalNavController.navigate(destId) } catch (e: IllegalArgumentException) { - // 可选:防止偶发重复 navigate 崩溃 e.printStackTrace() } + + bottomNav.post { updateBottomNavVisibility() } } - /** 关闭全局页:pop到 empty */ + /** Tab 内页面变化时刷新底栏显隐 */ private fun bindBottomNavVisibilityForTabs() { - fun shouldHideBottomNav(destId: Int): Boolean { - return destId in setOf( - R.id.searchFragment, - R.id.searchResultFragment, - R.id.MySkin - // 你还有其他需要全屏的页,也加在这里 - ) + val listener = NavController.OnDestinationChangedListener { _, _, _ -> + updateBottomNavVisibility() } - val listener = NavController.OnDestinationChangedListener { _, dest, _ -> - // 只要 global overlay 打开了,仍然以 overlay 为准(你已有逻辑) - if (isGlobalVisible()) return@OnDestinationChangedListener - bottomNav.visibility = if (shouldHideBottomNav(dest.id)) View.GONE else View.VISIBLE - } - homeHost.navController.addOnDestinationChangedListener(listener) shopHost.navController.addOnDestinationChangedListener(listener) mineHost.navController.addOnDestinationChangedListener(listener) @@ -300,12 +346,20 @@ class MainActivity : AppCompatActivity() { if (!isGlobalVisible()) return false val popped = globalNavController.popBackStack() - val stillVisible = globalNavController.currentDestination?.id != R.id.globalEmptyFragment - return popped || stillVisible + + // ✅ pop 后刷新一次(注意:currentDestination 可能要等一帧才更新,所以 post) + bottomNav.post { updateBottomNavVisibility() } + + // popped = true 表示确实 pop 了;即使 popped=false 也可能已经在 empty 了 + return popped || !isGlobalVisible() } + /** + * ✅ 改这里:不要再用 View.visibility 判断 overlay + * 以 NavController 的目的地为准 + */ private fun isGlobalVisible(): Boolean { - return findViewById(R.id.global_container).visibility == View.VISIBLE + return globalNavController.currentDestination?.id != R.id.globalEmptyFragment } private fun setupBackPress() { @@ -316,14 +370,15 @@ class MainActivity : AppCompatActivity() { // 2) 再 pop 当前tab val popped = currentTabNavController.popBackStack() - if (popped) return + if (popped) { + bottomNav.post { updateBottomNavVisibility() } + return + } // 3) 当前tab到根了:如果不是home,切回home;否则退出 - if (currentTabTag != TAB_HOME) { - bottomNav.post { - bottomNav.selectedItemId = R.id.home_graph - } - switchTab(TAB_HOME) + if (currentTabTag != TAB_HOME) { + bottomNav.post { bottomNav.selectedItemId = R.id.home_graph } + switchTab(TAB_HOME) } else { finish() } @@ -337,7 +392,7 @@ class MainActivity : AppCompatActivity() { bottomNav.post { if (!isLoggedIn()) { openGlobal(R.id.loginFragment) - return@post + return@post } openGlobal(R.id.rechargeFragment) } @@ -352,4 +407,4 @@ class MainActivity : AppCompatActivity() { 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 6530edb..89f316a 100644 --- a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt +++ b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt @@ -46,6 +46,8 @@ import android.graphics.drawable.GradientDrawable import kotlin.math.abs import java.text.BreakIterator import android.widget.EditText +import android.content.res.Configuration +import androidx.constraintlayout.widget.ConstraintLayout class MyInputMethodService : InputMethodService(), KeyboardEnvironment { @@ -264,23 +266,6 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { createNotificationChannelIfNeeded() tryStartForegroundSafe() - - // 监听认证事件 - // CoroutineScope(Dispatchers.Main).launch { - // AuthEventBus.events.collectLatest { event -> - // if (event is AuthEvent.TokenExpired) { - // // 启动 MainActivity 并跳转到登录页面 - // val intent = Intent(this@MyInputMethodService, MainActivity::class.java).apply { - // flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - // putExtra("navigate_to", "loginFragment") - // } - // startActivity(intent) - // } else if (event is AuthEvent.GenericError) { - // // 显示错误提示 - // android.widget.Toast.makeText(this@MyInputMethodService, "请求失败: ${event.message}", android.widget.Toast.LENGTH_SHORT).show() - // } - // } - // } } // 输入法状态变化 @@ -319,6 +304,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { val keyboard = ensureMainKeyboard() currentKeyboardView = keyboard.rootView mainKeyboardView = keyboard.rootView + (keyboard.rootView.parent as? ViewGroup)?.removeView(keyboard.rootView) return keyboard.rootView } @@ -405,7 +391,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 初始状态:隐藏联想条,显示控制面板 mainKeyboardView - ?.findViewById(R.id.completion_scroll) + ?.findViewById(R.id.completion_scroll) ?.visibility = View.GONE mainKeyboardView @@ -612,7 +598,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { clearEditorState() val kb = ensureMainKeyboard() currentKeyboardView = kb.rootView - setInputView(kb.rootView) + setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } @@ -620,7 +606,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { clearEditorState() val kb = ensureNumberKeyboard() currentKeyboardView = kb.rootView - setInputView(kb.rootView) + setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } @@ -628,7 +614,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { clearEditorState() val kb = ensureSymbolKeyboard() currentKeyboardView = kb.rootView - setInputView(kb.rootView) + setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } @@ -636,7 +622,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { clearEditorState() val kb = ensureAiKeyboard() currentKeyboardView = kb.rootView - setInputView(kb.rootView) + setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } @@ -644,10 +630,39 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { clearEditorState() val kb = ensureEmojiKeyboard() currentKeyboardView = kb.rootView - setInputView(kb.rootView) + setInputViewSafely(kb.rootView) kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor) } + override fun associateClose() { + clearEditorState() + val kb = ensureEmojiKeyboard() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + // 先清理缓存,避免复用旧 View + currentKeyboardView = null + + mainKeyboardView = null + numberKeyboardView = null + symbolKeyboardView = null + aiKeyboardView = null + emojiKeyboardView = null + + mainKeyboard = null + numberKeyboard = null + symbolKeyboard = null + aiKeyboard = null + emojiKeyboard = null + + super.onConfigurationChanged(newConfig) + } + + private fun setInputViewSafely(v: View) { + (v.parent as? ViewGroup)?.removeView(v) + super.setInputView(v) + } + // Emoji 键盘 private fun ensureEmojiKeyboard(): com.example.myapplication.keyboard.EmojiKeyboard { if (emojiKeyboard == null) { @@ -943,7 +958,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 新增:联想滚动条 & 控制面板 val completionScroll = - mainKeyboardView?.findViewById(R.id.completion_scroll) + mainKeyboardView?.findViewById(R.id.completion_scroll) val controlLayout = mainKeyboardView?.findViewById(R.id.control_layout) @@ -1006,7 +1021,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 自动滚回到最左边 private fun scrollSuggestionsToStart() { - val sv = mainKeyboardView?.findViewById(R.id.completion_scroll) + val sv = mainKeyboardView?.findViewById(R.id.completion_HorizontalScrollView) sv?.post { sv.fullScroll(View.FOCUS_LEFT) } } @@ -1402,7 +1417,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment { // 4. UI:联想条隐藏 & 控制面板显示 mainHandler.post { val completionScroll = - mainKeyboardView?.findViewById(R.id.completion_scroll) + mainKeyboardView?.findViewById(R.id.completion_scroll) val controlLayout = mainKeyboardView?.findViewById(R.id.control_layout) diff --git a/app/src/main/java/com/example/myapplication/OnboardingActivity.kt b/app/src/main/java/com/example/myapplication/OnboardingActivity.kt index b56863f..5bb2dc3 100644 --- a/app/src/main/java/com/example/myapplication/OnboardingActivity.kt +++ b/app/src/main/java/com/example/myapplication/OnboardingActivity.kt @@ -3,16 +3,64 @@ package com.example.myapplication import android.content.Intent import android.os.Bundle import android.widget.Button +import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AppCompatActivity +import com.example.myapplication.utils.EncryptedSharedPreferencesUtil +import android.view.ViewGroup +import android.widget.Toast +import com.example.myapplication.ui.common.LoadingOverlay +import android.os.Handler +import android.os.Looper class OnboardingActivity : AppCompatActivity() { + private var selectedGender = -1 // 0: male, 1: female, 2: third override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_onboarding) val btnStart = findViewById(R.id.tv_skip) + val maleLayout = findViewById(R.id.male_layout) + val femaleLayout = findViewById(R.id.female_layout) + val thirdLayout = findViewById(R.id.third_layout) + val tvDescription = findViewById(R.id.tv_description) + + // 设置性别选择点击事件 + maleLayout.setOnClickListener { + resetAllLayouts() + maleLayout.setBackgroundResource(R.drawable.gender_background_select) + selectedGender = 0 + } + + femaleLayout.setOnClickListener { + resetAllLayouts() + femaleLayout.setBackgroundResource(R.drawable.gender_background_select) + selectedGender = 1 + } + + thirdLayout.setOnClickListener { + resetAllLayouts() + thirdLayout.setBackgroundResource(R.drawable.gender_background_select) + selectedGender = 2 + } + + tvDescription.setOnClickListener { + if (selectedGender != -1) { + // 这里可以获取selectedGender的值(0,1,2) + // 标记已经不是第一次启动了 + val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE) + prefs.edit().putBoolean("is_first_launch", false).apply() + EncryptedSharedPreferencesUtil.save(this, "gender", selectedGender.toString()) + // 跳转到主界面 + val rootView = window.decorView.findViewById(android.R.id.content) + LoadingOverlay.attach(rootView).show() + startActivity(Intent(this, MainActivity::class.java)) + finish() + }else{ + Toast.makeText(this, "Please select your gender.", Toast.LENGTH_SHORT).show() + } + } btnStart.setOnClickListener { // 标记已经不是第一次启动了 @@ -20,8 +68,16 @@ class OnboardingActivity : AppCompatActivity() { prefs.edit().putBoolean("is_first_launch", false).apply() // 跳转到主界面 + val rootView = window.decorView.findViewById(android.R.id.content) + LoadingOverlay.attach(rootView).show() startActivity(Intent(this, MainActivity::class.java)) finish() } } + + private fun resetAllLayouts() { + findViewById(R.id.male_layout).setBackgroundResource(R.drawable.gender_background) + findViewById(R.id.female_layout).setBackgroundResource(R.drawable.gender_background) + findViewById(R.id.third_layout).setBackgroundResource(R.drawable.gender_background) + } } diff --git a/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt b/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt index cd6f98c..df64ef9 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/KeyboardEnvironment.kt @@ -39,6 +39,8 @@ interface KeyboardEnvironment { fun showAiKeyboard() //emoji键盘 fun showEmojiKeyboard() + // 关闭联想 + fun associateClose() // 音效 fun playKeyClick() diff --git a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt index 74d2be0..88ab4b8 100644 --- a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt @@ -15,6 +15,7 @@ import android.view.MotionEvent import android.view.View import android.widget.PopupWindow import android.widget.TextView +import android.widget.LinearLayout import com.example.myapplication.theme.ThemeManager class MainKeyboard( @@ -159,6 +160,10 @@ class MainKeyboard( updateRevokeButtonVisibility(view, res, pkg) } + view.findViewById(res.getIdentifier("associate_close", "id", pkg))?.setOnClickListener { + vibrateKey();env.associateClose() + } + view.findViewById(res.getIdentifier("key_emoji", "id", pkg))?.setOnClickListener { vibrateKey(); env.showEmojiKeyboard() } 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 03895e1..f2a380e 100644 --- a/app/src/main/java/com/example/myapplication/network/ApiService.kt +++ b/app/src/main/java/com/example/myapplication/network/ApiService.kt @@ -1,6 +1,7 @@ // 请求方法 package com.example.myapplication.network +import okhttp3.MultipartBody import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.* @@ -65,9 +66,41 @@ interface ApiService { @POST("user/updateInfo") suspend fun updateUserInfo( @Body body: updateInfoRequest - ): ApiResponse + ): ApiResponse + + //分页查询钱包交易记录 + @POST("wallet/transactions") + suspend fun transactions( + @Body body: transactionsRequest + ): ApiResponse + //用户人设列表 + @GET("character/listByUser") + suspend fun listByUser( + ): ApiResponse> + + //更新用户人设排序 + @POST("character/updateUserCharacterSort") + suspend fun updateUserCharacterSort( + @Body body: updateUserCharacterSortRequest + ): ApiResponse + + // 删除用户人设 + @GET("character/delUserCharacter") + suspend fun delUserCharacter( + @Query("id") id: Int + ): ApiResponse + + //提交反馈 + @POST("user/feedback") + suspend fun feedback( + @Body body: feedbackRequest + ): ApiResponse + + @GET("user/inviteCode") + suspend fun inviteCode( + ): ApiResponse //===========================================首页================================= // 标签列表 @GET("tag/list") @@ -102,17 +135,11 @@ interface ApiService { @Query("id") id: Int ): ApiResponse - //删除用户人设 - @GET("character/delUserCharacter") - suspend fun delUserCharacter( - @Query("id") id: Int - ): ApiResponse - //添加用户人设 @POST("character/addUserCharacter") suspend fun addUserCharacter( @Body body: AddPersonaClick - ): ApiResponse + ): ApiResponse //==========================================商城=========================================== @@ -188,4 +215,18 @@ interface ApiService { suspend fun downloadZipFromUrl( @Url url: String // 完整的下载 URL ): Response + + +} + +/** + * 文件上传服务接口 + */ +interface FileUploadService { + @Multipart + @POST("file/upload") + suspend fun uploadFile( + @Query("file") fileQuery: String, + @Part file: MultipartBody.Part + ): ApiResponse } 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 d051603..bfadeae 100644 --- a/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt +++ b/app/src/main/java/com/example/myapplication/network/AuthEventBus.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.SharedFlow object AuthEventBus { - // replay=0:不缓存历史事件;extraBufferCapacity:避免瞬时丢事件 + // replay=1:缓存最近一次事件;extraBufferCapacity=64:增加缓冲区防止瞬时事件丢失 private val _events = MutableSharedFlow( replay = 0, extraBufferCapacity = 1 @@ -21,8 +21,11 @@ object AuthEventBus { sealed class AuthEvent { data class TokenExpired(val message: String? = null) : AuthEvent() + data class CharacterAdded(val personaId: Int, val newAdded: Boolean = false) : AuthEvent() data class GenericError(val message: String) : AuthEvent() object LoginSuccess : AuthEvent() data class Logout(val returnTabTag: String) : AuthEvent() data class OpenGlobalPage(val destinationId: Int, val bundle: Bundle? = null) : AuthEvent() -} + object UserUpdated : AuthEvent() + data class CharacterDeleted(val characterId: Int) : AuthEvent() + } 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 8e88907..b229395 100644 --- a/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt +++ b/app/src/main/java/com/example/myapplication/network/HttpInterceptors.kt @@ -9,7 +9,16 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import android.content.Context - +import com.example.myapplication.network.security.BodyParamsExtractor +import com.example.myapplication.network.security.NonceUtils +import com.example.myapplication.network.security.SignUtils +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import okio.Buffer +import java.net.URLDecoder +import java.net.URLEncoder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec // * 不需要登录的接口路径(相对完整路径) // * 只写 /api/ 后面的部分 @@ -20,57 +29,92 @@ private val NO_LOGIN_REQUIRED_PATHS = setOf( "/wallet/balance", ) -private fun noLoginRequired(url: HttpUrl): Boolean { - val path = url.encodedPath // 例:/api/home/banner +private val NO_SIGN_REQUIRED_PATHS = setOf( + "/auth/login", +) - // 统一裁掉 /api 前缀 - val apiPath = path.substringAfter("/api", path) - - return NO_LOGIN_REQUIRED_PATHS.contains(apiPath) +private fun apiPath(url: HttpUrl): String { + val path = url.encodedPath + return path.substringAfter("/api", path) } +private fun noLoginRequired(url: HttpUrl): Boolean = + NO_LOGIN_REQUIRED_PATHS.contains(apiPath(url)) + +private fun noSignRequired(url: HttpUrl): Boolean = + NO_SIGN_REQUIRED_PATHS.contains(apiPath(url)) + /** * 请求拦截器:统一加 Header、token 等 */ fun requestInterceptor(appContext: Context) = Interceptor { chain -> val original = chain.request() + val url = original.url val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java) val token = user?.token.orEmpty() - val newRequest = original.newBuilder() + val builder = original.newBuilder() .apply { - if (token.isNotBlank()) { - addHeader("auth-token", "$token") - } + if (token.isNotBlank()) addHeader("auth-token", token) } .addHeader("Accept-Language", "lang") - .build() - // ===== 打印请求信息 ===== - val request = newRequest - val url = request.url + // ======= ✅ 按你规则加签名(header + query + body)======= + if (!noSignRequired(url)) { + val appId = "loveKeyboard" + val secret = "kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H" // TODO 正式环境建议下发/混淆/NDK + val timestamp = (System.currentTimeMillis() / 1000).toString() + val nonce = java.util.UUID.randomUUID().toString().replace("-", "").take(16) + + // 1) 合并成 Map(去掉 sign 本身) + val params = linkedMapOf() + params["appId"] = appId + params["timestamp"] = timestamp + params["nonce"] = nonce + + // 2) query 参数 + for (i in 0 until url.querySize) { + params[url.queryParameterName(i)] = url.queryParameterValue(i).orEmpty() + } + + // 3) body 参数(json / form) + params.putAll(extractBodyParams(original)) + + // 4) 生成 sign + val sign = calcSign(params, secret) + + builder + .addHeader("X-App-Id", appId) + .addHeader("X-Timestamp", timestamp) + .addHeader("X-Nonce", nonce) + .addHeader("X-Sign", sign) + } + + val request = builder.build() + + // ===== 打印请求信息(保留你原来的)===== val sb = StringBuilder() sb.append("\n======== HTTP Request ========\n") sb.append("Method: ${request.method}\n") - sb.append("URL: $url\n") + sb.append("URL: ${request.url}\n") sb.append("Headers:\n") for (name in request.headers.names()) { sb.append(" $name: ${request.header(name)}\n") } - if (url.querySize > 0) { + if (request.url.querySize > 0) { sb.append("Query Params:\n") - for (i in 0 until url.querySize) { - sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n") + for (i in 0 until request.url.querySize) { + sb.append(" ${request.url.queryParameterName(i)} = ${request.url.queryParameterValue(i)}\n") } } val requestBody = request.body if (requestBody != null) { - val buffer = okio.Buffer() + val buffer = Buffer() requestBody.writeTo(buffer) sb.append("Body:\n") sb.append(buffer.readUtf8()) @@ -83,6 +127,132 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain -> chain.proceed(request) } +// +// ================== 签名工具(严格按你描述规则) ================== +// + +private fun calcSign(params: Map, secret: String): String { + // 去空值 + 去 sign + val filtered = params + .filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) } + + // 按 key 字典序排序 + val sorted = filtered.toSortedMap() + + // 拼接:k=v&...&secret=xxx(value 统一做 URL encode 防止 & = 破坏结构) + val sb = StringBuilder() + sorted.forEach { (k, v) -> + if (sb.isNotEmpty()) sb.append("&") + sb.append(k).append("=").append(urlEncode(v)) + } + sb.append("&secret=").append(urlEncode(secret)) + + // HMAC-SHA256 -> hex小写 + return hmacSha256Hex(sb.toString(), secret) +} + +private fun hmacSha256Hex(data: String, secret: String): String { + val mac = Mac.getInstance("HmacSHA256") + val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(keySpec) + val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } +} + +private fun urlEncode(v: String): String = + URLEncoder.encode(v, "UTF-8") + +// +// ================== Body 参数提取:json / form ================== +// + +private fun extractBodyParams(request: okhttp3.Request): Map { + val body = request.body ?: return emptyMap() + val ct = body.contentType()?.toString()?.lowercase().orEmpty() + + return when { + ct.contains("application/json") -> extractJsonBody(body) + ct.contains("application/x-www-form-urlencoded") -> extractFormBody(body) + else -> emptyMap() // multipart / stream 等默认不签 body(如需可再扩展) + } +} + +private fun extractJsonBody(body: okhttp3.RequestBody): Map { + val raw = bodyToString(body).trim() + if (raw.isBlank()) return emptyMap() + + return try { + val root: JsonElement = JsonParser.parseString(raw) + val out = linkedMapOf() + flattenJson(root, "", out) + out + } catch (_: Exception) { + emptyMap() + } +} + +private fun extractFormBody(body: okhttp3.RequestBody): Map { + val raw = bodyToString(body) + if (raw.isBlank()) return emptyMap() + + val map = linkedMapOf() + raw.split("&") + .filter { it.isNotBlank() } + .forEach { pair -> + val idx = pair.indexOf("=") + if (idx > 0) { + val k = pair.substring(0, idx) + val v = pair.substring(idx + 1) + // form 这里解码回“原值”,后续签名阶段再统一 encode + map[k] = URLDecoder.decode(v, "UTF-8") + } else { + map[pair] = "" + } + } + return map +} + +private fun bodyToString(body: okhttp3.RequestBody): String { + return try { + val buffer = Buffer() + body.writeTo(buffer) + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + buffer.readString(charset) + } catch (_: Exception) { + "" + } +} + +/** + * JSON 扁平化规则: + * object: a.b.c + * array : items[0].id + */ +private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap) { + when { + elem.isJsonNull -> { + // null 不参与签名(服务端也要一致) + } + elem.isJsonPrimitive -> { + if (prefix.isNotBlank()) out[prefix] = elem.asJsonPrimitive.toString().trim('"') + } + elem.isJsonObject -> { + val obj = elem.asJsonObject + for ((k, v) in obj.entrySet()) { + val newKey = if (prefix.isBlank()) k else "$prefix.$k" + flattenJson(v, newKey, out) + } + } + elem.isJsonArray -> { + val arr = elem.asJsonArray + for (i in 0 until arr.size()) { + val newKey = "$prefix[$i]" + flattenJson(arr[i], newKey, out) + } + } + } +} + /** * 响应拦截器:统一打印日志、做一些简单的错误处理 @@ -121,7 +291,7 @@ val responseInterceptor = Interceptor { chain -> val gson = Gson() val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java) - if (errorResponse.code == 40102) { + if (errorResponse.code == 40102|| errorResponse.code == 40103) { val isNoLoginApi = noLoginRequired(request.url) Log.w( 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 1c2a916..9a6f668 100644 --- a/app/src/main/java/com/example/myapplication/network/Models.kt +++ b/app/src/main/java/com/example/myapplication/network/Models.kt @@ -71,16 +71,70 @@ data class User( val email: String, val emailVerified: Boolean, val isVip: Boolean, - val vipExpiry: String, - val token: String + val vipExpiry: String?, + val token: String?, ) //更新用户 data class updateInfoRequest( - val uid: Long, - val nickName: String, - val gender: Int, - val avatarUrl: String?, + val uid: Long? = null, + val nickName: String? = null, + val gender: Int? = null, + val avatarUrl: String? = null, +) + +//分页查询钱包交易记录 +data class transactionsRequest( + val pageNum: Int, + val pageSize: Int, +) + +//分页查询钱包交易记录响应 +data class transactionsResponse( + val records: List, + val total: Int, + val size: Int, + val current: Int, + val pages: Int +) + +//分页查询钱包交易记录响应 +data class TransactionRecord( + val id: Long, + val type: Int, + val amount: Number, + val beforeBalance: Number, + val afterBalance: Number, + val description: String, + val createdAt: String, +) + +//用户人设列表响应 +data class ListByUserWithNot( + val id: Int, + val characterName: String, + val emoji: String, + val characterId: Int, +) + +//更新用户人设排序 +data class updateUserCharacterSortRequest( + val sort: List +) + +//提交反馈 +data class feedbackRequest( + val content: String, +) + +//分享响应 +data class ShareResponse( + val code: String, + val status: Int, + val usedCount: Int, + val maxUses: Int, + val expiresAt: String, + val h5Link: String, ) // =======================================首页====================================== @@ -115,7 +169,7 @@ data class listByTagWithNotLogin( // 人设详情响应 data class CharacterDetailResponse( - val id: Long? = null, + val id: Int? = null, val characterName: String? = null, val characterBackground: String? = null, val avatarUrl: String? = null, @@ -189,4 +243,4 @@ data class deleteThemeRequest( //购买主题 data class purchaseThemeRequest( val themeId: Int, -) \ No newline at end of file +) diff --git a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt index dc599bd..8812eac 100644 --- a/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt +++ b/app/src/main/java/com/example/myapplication/network/RetrofitClient.kt @@ -6,6 +6,7 @@ import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.util.concurrent.TimeUnit +import com.example.myapplication.network.FileUploadService object RetrofitClient { @@ -50,6 +51,13 @@ object RetrofitClient { retrofit.create(ApiService::class.java) } + /** + * 创建文件上传服务 + */ + fun createFileUploadService(): FileUploadService { + return retrofit.create(FileUploadService::class.java) + } + /** * 创建支持完整 URL 下载的 Retrofit 实例 * @param baseUrl 完整的下载 URL diff --git a/app/src/main/java/com/example/myapplication/network/security/BodyParamsExtractor.kt b/app/src/main/java/com/example/myapplication/network/security/BodyParamsExtractor.kt new file mode 100644 index 0000000..0f47d75 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/security/BodyParamsExtractor.kt @@ -0,0 +1,137 @@ +package com.example.myapplication.network.security + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import okhttp3.MultipartBody +import okhttp3.Request +import okhttp3.RequestBody +import okio.Buffer +import java.nio.charset.Charset + +object BodyParamsExtractor { + + /** + * 抽取 Request 的 body 参数到 map + * - JSON: 扁平化(a.b[0].c) + * - Form: 直接 key=value + * - Multipart: 只签文本字段(文件字段跳过) + */ + fun extractBodyParams(request: Request): Map { + val body = request.body ?: return emptyMap() + val contentType = body.contentType()?.toString()?.lowercase().orEmpty() + + return when { + contentType.contains("application/json") -> extractJsonBody(body) + contentType.contains("application/x-www-form-urlencoded") -> extractFormBody(body) + contentType.contains("multipart/form-data") -> extractMultipartBody(body) + else -> { + // 其他类型(例如 stream、protobuf、octet-stream) + // 建议不签或签一个摘要(需要服务端同样实现) + emptyMap() + } + } + } + + private fun extractJsonBody(body: RequestBody): Map { + val json = bodyToString(body).trim() + if (json.isBlank()) return emptyMap() + + return try { + val root: JsonElement = JsonParser.parseString(json) + val out = linkedMapOf() + flattenJson(root, "", out) + out + } catch (_: Exception) { + // JSON 解析失败就不签 body(也可以选择直接把原文作为 bodyRaw 参与签名) + emptyMap() + } + } + + private fun extractFormBody(body: RequestBody): Map { + // x-www-form-urlencoded 本质就是 querystring:a=1&b=2 + val raw = bodyToString(body) + if (raw.isBlank()) return emptyMap() + + val map = linkedMapOf() + raw.split("&") + .filter { it.isNotBlank() } + .forEach { pair -> + val idx = pair.indexOf("=") + if (idx > 0) { + val k = pair.substring(0, idx) + val v = pair.substring(idx + 1) + // 注意:这里 raw 是已经 urlencoded 的内容 + // 为了与服务端一致,推荐:服务端拿到 form 参数的“解码后值”再参与签名 + // 客户端这里可以不 decode,改为后续签名阶段统一 encode(SignUtils 做了 encode) + map[k] = java.net.URLDecoder.decode(v, "UTF-8") + } else { + map[pair] = "" + } + } + return map + } + + private fun extractMultipartBody(body: RequestBody): Map { + if (body !is MultipartBody) return emptyMap() + + val map = linkedMapOf() + for (i in 0 until body.parts.size) { + val part = body.parts[i] + val headers = part.headers + val disp = headers?.get("Content-Disposition").orEmpty() + + // 取 name="xxx" + val name = Regex("""name="([^"]+)"""").find(disp)?.groupValues?.getOrNull(1) ?: continue + val filename = Regex("""filename="([^"]+)"""").find(disp)?.groupValues?.getOrNull(1) + + // 有 filename 认为是文件字段:默认不签(避免读文件流/超大) + if (!filename.isNullOrBlank()) continue + + // 文本字段:读出内容 + val value = bodyToString(part.body).trim() + map[name] = value + } + return map + } + + private fun bodyToString(body: RequestBody): String { + return try { + val buffer = Buffer() + body.writeTo(buffer) + val charset: Charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + buffer.readString(charset) + } catch (_: Exception) { + "" + } + } + + /** + * JSON 扁平化规则: + * object: a.b.c + * array: items[0].id + */ + private fun flattenJson(elem: JsonElement, prefix: String, out: MutableMap) { + when { + elem.isJsonNull -> { + // null 不参与(也可以 out[prefix] = "null" 但需要服务端一致) + } + elem.isJsonPrimitive -> { + if (prefix.isNotBlank()) out[prefix] = elem.asJsonPrimitive.toString().trim('"') + } + elem.isJsonObject -> { + val obj = elem.asJsonObject + for ((k, v) in obj.entrySet()) { + val newKey = if (prefix.isBlank()) k else "$prefix.$k" + flattenJson(v, newKey, out) + } + } + elem.isJsonArray -> { + val arr = elem.asJsonArray + for (i in 0 until arr.size()) { + val newKey = "$prefix[$i]" + flattenJson(arr[i], newKey, out) + } + } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/network/security/NonceUtils.kt b/app/src/main/java/com/example/myapplication/network/security/NonceUtils.kt new file mode 100644 index 0000000..8167499 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/security/NonceUtils.kt @@ -0,0 +1,11 @@ +package com.example.myapplication.network.security + +import java.util.UUID + +object NonceUtils { + fun genNonce(): String = + UUID.randomUUID().toString().replace("-", "").take(16) + + fun genTimestampSeconds(): String = + (System.currentTimeMillis() / 1000).toString() +} diff --git a/app/src/main/java/com/example/myapplication/network/security/SignUtils.kt b/app/src/main/java/com/example/myapplication/network/security/SignUtils.kt new file mode 100644 index 0000000..35bfb1b --- /dev/null +++ b/app/src/main/java/com/example/myapplication/network/security/SignUtils.kt @@ -0,0 +1,38 @@ +package com.example.myapplication.network.security + +import java.net.URLEncoder +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +object SignUtils { + + fun calcSign(params: Map, secret: String): String { + val signStr = buildSignString(params, secret) + return hmacSha256Hex(signStr, secret) + } + + fun buildSignString(params: Map, secret: String): String { + val filtered = params + .filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) } + .toSortedMap() + + val sb = StringBuilder() + filtered.forEach { (k, v) -> + if (sb.isNotEmpty()) sb.append("&") + sb.append(k).append("=").append(urlEncode(v)) + } + sb.append("&secret=").append(urlEncode(secret)) + return sb.toString() + } + + private fun hmacSha256Hex(data: String, secret: String): String { + val mac = Mac.getInstance("HmacSHA256") + val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256") + mac.init(keySpec) + val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8)) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun urlEncode(v: String): String = + URLEncoder.encode(v, "UTF-8") +} diff --git a/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt b/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt deleted file mode 100644 index 7df92d3..0000000 --- a/app/src/main/java/com/example/myapplication/ui/circle/CircleFragment.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.example.myapplication.ui.circle - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.example.myapplication.R - -class CircleFragment : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_circle, container, false) - } -} diff --git a/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt b/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt index c5f562f..65e789a 100644 --- a/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt +++ b/app/src/main/java/com/example/myapplication/ui/common/LoadingOverlay.kt @@ -1,5 +1,7 @@ package com.example.myapplication.ui.common +import android.os.Looper +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -12,20 +14,33 @@ class LoadingOverlay private constructor( companion object { fun attach(parent: ViewGroup): LoadingOverlay { val overlay = LayoutInflater.from(parent.context) - .inflate(R.layout.view_fullscreen_loading, parent, false) + .inflate(R.layout.view_fullscreen_loading, parent, false).apply { + visibility = View.GONE + bringToFront() + elevation = 100f // 确保在其它视图之上 + } - overlay.visibility = View.GONE - parent.addView(overlay) // 加到最上层(最后添加的在最上面) + parent.addView(overlay) return LoadingOverlay(parent, overlay) } } fun show() { - overlay.visibility = View.VISIBLE + if (Looper.getMainLooper().thread == Thread.currentThread()) { + overlay.visibility = View.VISIBLE + } else { + overlay.post { overlay.visibility = View.VISIBLE } + } + Log.d("LoadingOverlay", "Show loading") } fun hide() { - overlay.visibility = View.GONE + if (Looper.getMainLooper().thread == Thread.currentThread()) { + overlay.visibility = View.GONE + } else { + overlay.post { overlay.visibility = View.GONE } + } + Log.d("LoadingOverlay", "Hide loading") } fun remove() { 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 6de4209..74b9ec6 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 @@ -25,13 +25,20 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.example.myapplication.ImeGuideActivity +import com.example.myapplication.ui.common.LoadingOverlay import com.example.myapplication.R -import com.example.myapplication.network.* +import com.example.myapplication.network.AddPersonaClick +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.listByTagWithNotLogin import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.card.MaterialCardView import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import com.example.myapplication.network.PersonaClick +import com.example.myapplication.ui.home.PersonaAdapter import kotlin.math.abs class HomeFragment : Fragment() { @@ -47,6 +54,7 @@ class HomeFragment : Fragment() { private lateinit var tabList2: TextView private lateinit var backgroundImage: ImageView private var lastList1RenderKey: String? = null + private lateinit var loadingOverlay: LoadingOverlay private var preloadJob: Job? = null private var allPersonaCache: List = emptyList() @@ -84,7 +92,7 @@ class HomeFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { AuthEventBus.events.collect { event -> @@ -95,13 +103,13 @@ class HomeFragment : Fragment() { personaCache.clear() allPersonaCache = emptyList() lastList1RenderKey = null - + // 2) 重新拉列表1(登录态接口会变) viewLifecycleOwner.lifecycleScope.launch { allPersonaCache = fetchAllPersonaList() notifyPageChangedOnMain(0) } - + // 3) 如果当前在某个 tag 页,也建议重新拉当前页数据 val pos = viewPager.currentItem if (pos > 0) { @@ -114,12 +122,81 @@ class HomeFragment : Fragment() { } } } + + is AuthEvent.CharacterAdded -> { + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + // 1) 列表一:重新拉 + allPersonaCache = fetchAllPersonaList() + lastList1RenderKey = null + notifyPageChangedOnMain(0) + + // 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”) + personaCache.clear() + + // 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新) + val pos = viewPager.currentItem + if (pos > 0) { + val tagId = tags.getOrNull(pos - 1)?.id + if (tagId != null) { + // 先刷新一次,让页面进入 loading(因为缓存被清了) + notifyPageChangedOnMain(pos) + + // 再拉当前 tag 的新数据 + val list = fetchPersonaByTag(tagId) + personaCache[tagId] = list + notifyPageChangedOnMain(pos) + } + } + + // 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍 + startPreloadAllTagsFillCacheOnly() + } finally { + loadingOverlay.hide() + } + } + } + + is AuthEvent.CharacterDeleted -> { + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + // 1) 列表一:重新拉 + allPersonaCache = fetchAllPersonaList() + lastList1RenderKey = null + notifyPageChangedOnMain(0) + + // 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”) + personaCache.clear() + + // 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新) + val pos = viewPager.currentItem + if (pos > 0) { + val tagId = tags.getOrNull(pos - 1)?.id + if (tagId != null) { + // 先刷新一次,让页面进入 loading(因为缓存被清了) + notifyPageChangedOnMain(pos) + + // 再拉当前 tag 的新数据 + val list = fetchPersonaByTag(tagId) + personaCache[tagId] = list + notifyPageChangedOnMain(pos) + } + } + + // 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍 + startPreloadAllTagsFillCacheOnly() + } finally { + loadingOverlay.hide() + } + } + } else -> Unit } } } } - // 充值按钮点击 - 使用事件总线打开全局页面 view.findViewById(R.id.rechargeButton).setOnClickListener { @@ -141,11 +218,13 @@ class HomeFragment : Fragment() { tabList2 = view.findViewById(R.id.tab_list2) viewPager = view.findViewById(R.id.viewPager) viewPager.isSaveEnabled = false - viewPager.offscreenPageLimit = 2 + viewPager.offscreenPageLimit = 2 backgroundImage = bottomSheet.findViewById(R.id.backgroundImage) val root = view.findViewById(R.id.rootCoordinator) val floatingImage = view.findViewById(R.id.floatingImage) + loadingOverlay = LoadingOverlay.attach(root) + Log.d("HomeFragment", "LoadingOverlay initialized") root.post { if (!isAdded) return@post @@ -169,22 +248,26 @@ class HomeFragment : Fragment() { // 加载列表一 viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() try { val list = fetchAllPersonaList() if (!isAdded) return@launch allPersonaCache = list - + // ✅ 关键:数据变了就清 renderKey,允许重建一次 UI lastList1RenderKey = null - + notifyPageChangedOnMain(0) } catch (e: Exception) { Log.e("1314520-HomeFragment", "获取列表一失败", e) + } finally { + loadingOverlay.hide() } } // 拉标签 + 预加载 viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() try { val response = RetrofitClient.apiService.tagList() if (!isAdded) return@launch @@ -204,11 +287,13 @@ class HomeFragment : Fragment() { startPreloadAllTagsFillCacheOnly() } catch (e: Exception) { Log.e("1314520-HomeFragment", "获取标签失败", e) + } finally { + loadingOverlay.hide() } } } - // ================== 你要求的核心优化:setupViewPager 只初始化一次 ================== + // ================== 核心:setupViewPager 只初始化一次 ================== private fun setupViewPagerOnce() { if (sheetAdapter != null) return @@ -223,7 +308,7 @@ class HomeFragment : Fragment() { override fun onPageSelected(position: Int) { if (!isAdded) return updateTabsAndTags(position) - + // ✅ 修复:当切换到标签页且缓存已有数据时,强制刷新UI if (position > 0) { val tagIndex = position - 1 @@ -245,6 +330,52 @@ class HomeFragment : Fragment() { } } + // ---------------- 方案A:成功后“造新数据(copy)替换缓存”并刷新 ---------------- + + private fun applyAddedToggle(personaId: Int, newAdded: Boolean) { + // 1) 更新列表一缓存 + run { + val oldAll = allPersonaCache + val idxAll = oldAll.indexOfFirst { it.id == personaId } + if (idxAll >= 0) { + val newList = oldAll.toMutableList() + val oldItem = newList[idxAll] + newList[idxAll] = oldItem.copy(added = newAdded) + allPersonaCache = newList + + // renderList1 有 renderKey,必须清一下 + lastList1RenderKey = null + notifyPageChangedOnMain(0) + } + } + + // 2) 更新所有 tag 缓存(personaCache) + val keys = personaCache.keys.toList() + var changedCurrentTagPage = false + + for (tagId in keys) { + val old = personaCache[tagId] ?: continue + val idx = old.indexOfFirst { it.id == personaId } + if (idx >= 0) { + val newList = old.toMutableList() + val oldItem = newList[idx] + newList[idx] = oldItem.copy(added = newAdded) + personaCache[tagId] = newList + + // 如果当前就在这个 tag 页,标记需要刷新 + val pos = viewPager.currentItem + val currentTagId = tags.getOrNull(pos - 1)?.id + if (pos > 0 && currentTagId == tagId) { + changedCurrentTagPage = true + } + } + } + + if (changedCurrentTagPage) { + notifyPageChangedOnMain(viewPager.currentItem) + } + } + // ---------------- 拖拽效果 ---------------- private fun initDrag(target: View, parent: ViewGroup) { @@ -488,28 +619,28 @@ class HomeFragment : Fragment() { private fun startPreloadAllTagsFillCacheOnly() { preloadJob?.cancel() - + val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2) - + preloadJob = viewLifecycleOwner.lifecycleScope.launch { if (tags.isEmpty()) return@launch - + tags.forEach { tag -> if (personaCache.containsKey(tag.id)) return@forEach - + launch { semaphore.acquire() try { val list = fetchPersonaByTag(tag.id) personaCache[tag.id] = list - + // ✅ 只在用户正在看的页时刷新一次(不算乱刷 UI) val idx = tags.indexOfFirst { it.id == tag.id } val thisPos = 1 + idx if (idx >= 0 && viewPager.currentItem == thisPos) { notifyPageChangedOnMain(thisPos) } - + } catch (e: Exception) { Log.e("HomeFragment", "preload tag=${tag.id} fail", e) } finally { @@ -519,7 +650,6 @@ class HomeFragment : Fragment() { } } } - // ---------------- ViewPager Adapter ---------------- @@ -531,10 +661,10 @@ class HomeFragment : Fragment() { fun updatePageCount(newCount: Int) { if (newCount == pageCount) return - + val old = pageCount pageCount = newCount - + if (newCount > old) { notifyItemRangeInserted(old, newCount - old) } else { @@ -564,10 +694,7 @@ class HomeFragment : Fragment() { val loadingView = root.findViewById(R.id.loadingView) rv2.setHasFixedSize(true) - - // ✅ 禁止 itemAnimator(减少 layout 抖动) rv2.itemAnimator = null - rv2.isNestedScrollingEnabled = false var adapter = rv2.adapter as? PersonaAdapter @@ -584,22 +711,31 @@ class HomeFragment : Fragment() { is PersonaClick.Add -> { viewLifecycleOwner.lifecycleScope.launch { + val personaId = click.persona.id?: return@launch + val oldAdded = click.persona.added + val newAdded = !oldAdded try { - if (click.persona.added == true) { - click.persona.id?.let { id -> - RetrofitClient.apiService.delUserCharacter(id.toInt()) - } - } else { + if (oldAdded) { + // RetrofitClient.apiService.delUserCharacter(personaId) + // // ✅ 成功后替换缓存并刷新 + // applyAddedToggle(personaId, newAdded) + } + else { + Log.d("1314520-HomeFragment", "add persona id=${click.persona.id}") val req = AddPersonaClick( - characterId = click.persona.id?.toInt() ?: 0, + characterId = personaId, emoji = click.persona.emoji ?: "" ) RetrofitClient.apiService.addUserCharacter(req) + // ✅ 成功后替换缓存并刷新 + applyAddedToggle(personaId, newAdded) } - } catch (_: Exception) { + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "grid toggle add failed id=$personaId", e) } } } + else -> Unit } } rv2.layoutManager = GridLayoutManager(root.context, 2) @@ -629,7 +765,7 @@ class HomeFragment : Fragment() { override fun getItemCount(): Int = pageCount } - // ---------------- 列表一渲染(原逻辑不动) ---------------- + // ---------------- 列表一渲染 ---------------- private fun renderList1(root: View, list: List) { val key = buildString { @@ -641,6 +777,7 @@ class HomeFragment : Fragment() { } if (key == lastList1RenderKey) return lastList1RenderKey = key + val sorted = list.sortedBy { it.rank ?: Int.MAX_VALUE } val top3 = sorted.take(3) val others = if (sorted.size > 3) sorted.drop(3) else emptyList() @@ -650,6 +787,7 @@ class HomeFragment : Fragment() { avatarId = R.id.avatar_first, nameId = R.id.name_first, addBtnId = R.id.btn_add_first, + addBtnIcon = R.id.add_first_icon, containerId = R.id.container_first, item = top3.getOrNull(0) ) @@ -659,6 +797,7 @@ class HomeFragment : Fragment() { avatarId = R.id.avatar_second, nameId = R.id.name_second, addBtnId = R.id.btn_add_second, + addBtnIcon = R.id.add_second_icon, containerId = R.id.container_second, item = top3.getOrNull(1) ) @@ -668,6 +807,7 @@ class HomeFragment : Fragment() { avatarId = R.id.avatar_third, nameId = R.id.name_third, addBtnId = R.id.btn_add_third, + addBtnIcon = R.id.add_third_icon, containerId = R.id.container_third, item = top3.getOrNull(2) ) @@ -686,6 +826,62 @@ class HomeFragment : Fragment() { val iv = itemView.findViewById(R.id.iv_avatar) com.bumptech.glide.Glide.with(iv).load(p.avatarUrl).into(iv) + // ---------------- add 按钮(失败回滚 + 防连点) ---------------- + val addBtn = itemView.findViewById(R.id.btn_add) + val addIcon = itemView.findViewById(R.id.add_icon) + + val originBg = addBtn.background + val originIcon = addIcon.drawable + + fun renderAddState(added: Boolean) { + if (added) { + addBtn.setBackgroundResource(R.drawable.round_bg_others_already) + addIcon.setImageResource(R.drawable.ime_guide_activity_btn_completed_img) + } else { + addBtn.background = originBg + addIcon.setImageDrawable(originIcon) + } + } + + // ✅ 首次渲染 + renderAddState(p.added == true) + + addBtn.setOnClickListener { + if (!addBtn.isEnabled) return@setOnClickListener + + viewLifecycleOwner.lifecycleScope.launch { + val personaId = p.id?: return@launch + val oldAdded = p.added + val newAdded = !oldAdded + + addBtn.isEnabled = false + renderAddState(newAdded) + try { + if (oldAdded) { + // RetrofitClient.apiService.delUserCharacter(personaId) + // // ✅ 只有成功才更新缓存 + 更新UI(失败则保持原样) + // applyAddedToggle(personaId, newAdded) + } + else { + Log.d("1314520-HomeFragment", "add persona id=${p.id}") + val req = AddPersonaClick( + characterId = personaId, + emoji = p.emoji ?: "" + ) + RetrofitClient.apiService.addUserCharacter(req) + // ✅ 只有成功才更新缓存 + 更新UI(失败则保持原样) + applyAddedToggle(personaId, newAdded) + } + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "others toggle add failed id=$personaId", e) + renderAddState(oldAdded) + } finally { + addBtn.isEnabled = true + } + } + } + + // ---------------- item 点击 ---------------- itemView.setOnClickListener { if (!isAdded || childFragmentManager.isStateSaved) return@setOnClickListener PersonaDetailDialogFragment @@ -693,23 +889,6 @@ class HomeFragment : Fragment() { .show(childFragmentManager, "persona_detail") } - itemView.findViewById(R.id.btn_add).setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - try { - if (p.added == true) { - p.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) } - } else { - val req = AddPersonaClick( - characterId = p.id?.toInt() ?: 0, - emoji = p.emoji ?: "" - ) - RetrofitClient.apiService.addUserCharacter(req) - } - } catch (_: Exception) { - } - } - } - container.addView(itemView) } } @@ -719,12 +898,14 @@ class HomeFragment : Fragment() { avatarId: Int, nameId: Int, addBtnId: Int, + addBtnIcon: Int, containerId: Int, item: listByTagWithNotLogin? ) { val avatar = root.findViewById(avatarId) val name = root.findViewById(nameId) - val addBtn = root.findViewById(addBtnId) + val addBtn = root.findViewById(addBtnId) + val addIcon = root.findViewById(addBtnIcon) val container = root.findViewById(containerId) if (item == null) { @@ -739,19 +920,60 @@ class HomeFragment : Fragment() { name.text = item.characterName ?: "" com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar) + // ✅ 记录“原始背景/原始icon”,用于 added=false 时恢复 + val originBg = addBtn.background + val originIconRes = when (addBtnId) { + R.id.btn_add_first -> R.drawable.first_add + R.id.btn_add_second -> R.drawable.second_add + R.id.btn_add_third -> R.drawable.third_add + else -> 0 + } + + fun renderAddState(added: Boolean) { + if (added) { + addBtn.setBackgroundResource(R.drawable.round_bg_others_already) + addIcon.setImageResource(R.drawable.ime_guide_activity_btn_completed_img) + } else { + addBtn.background = originBg + if (originIconRes != 0) addIcon.setImageResource(originIconRes) + } + } + + // ✅ 首次渲染 + renderAddState(item.added == true) + + // ✅ 点击:失败回滚 + 防连点(请求中禁用按钮) addBtn.setOnClickListener { + if (!addBtn.isEnabled) return@setOnClickListener + viewLifecycleOwner.lifecycleScope.launch { + val personaId = item.id?: return@launch + val oldAdded = item.added + val newAdded = !oldAdded + + addBtn.isEnabled = false + renderAddState(newAdded) try { - if (item.added == true) { - item.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) } - } else { + if (oldAdded) { + // RetrofitClient.apiService.delUserCharacter(personaId) + // // ✅ 只有成功才更新缓存 + 更新UI + // applyAddedToggle(personaId, newAdded) + } + else { + Log.d("1314520-HomeFragment", "add persona id=${item.id}") val req = AddPersonaClick( - characterId = item.id?.toInt() ?: 0, + characterId = personaId, emoji = item.emoji ?: "" ) RetrofitClient.apiService.addUserCharacter(req) + // ✅ 只有成功才更新缓存 + 更新UI + applyAddedToggle(personaId, newAdded) } - } catch (_: Exception) { + } catch (e: Exception) { + Log.e("1314520-HomeFragment", "others toggle add failed id=$personaId", e) + renderAddState(oldAdded) + } finally { + addBtn.isEnabled = true } } } diff --git a/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt b/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt index 1a479f6..d2a0f2e 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaAdapter.kt @@ -3,14 +3,15 @@ package com.example.myapplication.ui.home import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.example.myapplication.R -import com.example.myapplication.network.listByTagWithNotLogin import com.example.myapplication.network.PersonaClick +import com.example.myapplication.network.listByTagWithNotLogin import de.hdodenhof.circleimageview.CircleImageView -import android.util.Log class PersonaAdapter( private val onClick: (PersonaClick) -> Unit @@ -26,35 +27,37 @@ class PersonaAdapter( inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { - val ivAvatar: CircleImageView = itemView.findViewById(R.id.ivAvatar) - val tvName: TextView = itemView.findViewById(R.id.tvName) - val characterBackground: TextView = - itemView.findViewById(R.id.characterBackground) - val download: TextView = itemView.findViewById(R.id.download) - val operation: TextView = itemView.findViewById(R.id.operation) + private val ivAvatar: CircleImageView = itemView.findViewById(R.id.ivAvatar) + private val tvName: TextView = itemView.findViewById(R.id.tvName) + private val characterBackground: TextView = itemView.findViewById(R.id.characterBackground) + private val download: TextView = itemView.findViewById(R.id.download) + private val operation: LinearLayout = itemView.findViewById(R.id.operation) + private val operationIcon: ImageView = itemView.findViewById(R.id.operation_add_icon) - /** ✅ 统一绑定 + 点击逻辑 */ fun bind(item: listByTagWithNotLogin) { - tvName.text = item.characterName characterBackground.text = item.characterBackground download.text = item.download - + Glide.with(itemView.context) .load(item.avatarUrl) .placeholder(R.drawable.default_avatar) .error(R.drawable.default_avatar) .into(ivAvatar) - - // ✅ 整个 item:跳详情 - itemView.setOnClickListener { - onClick(PersonaClick.Item(item)) - } - - // ✅ 添加 / 下载按钮 - operation.setOnClickListener { - onClick(PersonaClick.Add(item)) - } + + val isAdded = item.added + + // ✅ 背景改 operation(外层容器) + operation.setBackgroundResource( + if (isAdded) R.drawable.list_two_bg_already else R.drawable.list_two_bg + ) + + // ✅ 图标改 operationIcon(中间图) + operationIcon.setImageResource( + if (isAdded) R.drawable.ime_guide_activity_btn_completed_img else R.drawable.operation_add + ) + itemView.setOnClickListener { onClick(PersonaClick.Item(item)) } + operation.setOnClickListener { onClick(PersonaClick.Add(item)) } } } diff --git a/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt b/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt index b8a2663..6c7ea28 100644 --- a/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/home/PersonaDetailDialogFragment.kt @@ -15,6 +15,9 @@ import com.example.myapplication.network.RetrofitClient import com.example.myapplication.network.CharacterDetailResponse import kotlinx.coroutines.launch import com.example.myapplication.network.AddPersonaClick +import android.util.Log +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus class PersonaDetailDialogFragment : DialogFragment() { @@ -61,6 +64,7 @@ class PersonaDetailDialogFragment : DialogFragment() { tvBackground.text = data.characterBackground ?: "" btnAdd.text = data.added?.let { "Added" } ?: "Add" btnAdd.setBackgroundResource(data.added?.let { R.drawable.ic_added } ?: R.drawable.keyboard_ettings) + val newAdded = !(data.added ?: false) Glide.with(requireContext()) .load(data.avatarUrl) @@ -72,13 +76,13 @@ class PersonaDetailDialogFragment : DialogFragment() { lifecycleScope.launch { if(data.added == true){ //取消收藏 - data.id?.let { id -> - try { - RetrofitClient.apiService.delUserCharacter(id.toInt()) - } catch (e: Exception) { - // 处理错误 - } - } + // data.id?.let { id -> + // try { + // RetrofitClient.apiService.delUserCharacter(id.toInt()) + // } catch (e: Exception) { + // // 处理错误 + // } + // } }else{ val addPersonaRequest = AddPersonaClick( characterId = data.id?.toInt() ?: 0, @@ -86,8 +90,11 @@ class PersonaDetailDialogFragment : DialogFragment() { ) try { RetrofitClient.apiService.addUserCharacter(addPersonaRequest) + data.id?.let { personaId -> + AuthEventBus.emit(AuthEvent.CharacterAdded(personaId,newAdded)) + } } catch (e: Exception) { - // 处理错误 + Log.e("1314520-PersonaDetailDialogFragment", "addUserCharacter error", e) } } dismissAllowingStateLoss() diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/ConfirmDeleteDialogFragment.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/ConfirmDeleteDialogFragment.kt new file mode 100644 index 0000000..98801d9 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/ConfirmDeleteDialogFragment.kt @@ -0,0 +1,72 @@ +package com.example.myapplication.ui.keyboard + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Window +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import com.example.myapplication.R + +class ConfirmDeleteDialogFragment : DialogFragment() { + + companion object { + private const val ARG_NAME = "arg_name" + private const val ARG_EMOJI = "arg_emoji" + + fun newInstance( + characterName: String?, + emoji: String?, + onConfirm: () -> Unit + ): ConfirmDeleteDialogFragment { + return ConfirmDeleteDialogFragment().apply { + this.onConfirm = onConfirm + arguments = Bundle().apply { + putString(ARG_NAME, characterName ?: "") + putString(ARG_EMOJI, emoji ?: "🙂") + } + } + } + } + + private var onConfirm: (() -> Unit)? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = Dialog(requireContext()) + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + + val v = LayoutInflater.from(requireContext()) + .inflate(R.layout.dialog_confirm_delete_character, null, false) + dialog.setContentView(v) + + dialog.setCancelable(true) + dialog.setCanceledOnTouchOutside(true) + + val name = arguments?.getString(ARG_NAME).orEmpty() + val emoji = arguments?.getString(ARG_EMOJI) ?: "🙂" + + v.findViewById(R.id.tv_name).text = name + v.findViewById(R.id.tv_emoji).text = emoji + + v.findViewById(R.id.btn_cancel).setOnClickListener { + dismissAllowingStateLoss() + } + + v.findViewById(R.id.btn_confirm).setOnClickListener { + dismissAllowingStateLoss() + onConfirm?.invoke() + } + + // 宽度更贴合弹窗 + dialog.window?.setLayout( + (resources.displayMetrics.widthPixels * 0.86f).toInt(), + android.view.ViewGroup.LayoutParams.WRAP_CONTENT + ) + return dialog + } + + override fun onDestroyView() { + super.onDestroyView() + onConfirm = null + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/DragSortCallback.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/DragSortCallback.kt new file mode 100644 index 0000000..46b066f --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/DragSortCallback.kt @@ -0,0 +1,68 @@ +package com.example.myapplication.ui.keyboard + +import android.view.HapticFeedbackConstants +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView + +class DragSortCallback( + private val onMove: (from: Int, to: Int) -> Unit +) : ItemTouchHelper.Callback() { + + private var didHaptic = false + + override fun isLongPressDragEnabled(): Boolean = true + override fun isItemViewSwipeEnabled(): Boolean = false + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + return makeMovementFlags(dragFlags, 0) + } + + override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { + super.onSelectedChanged(viewHolder, actionState) + + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) { + // ✅ 触觉反馈(只触发一次) + if (!didHaptic) { + viewHolder.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + didHaptic = true + } + // ✅ 视觉反馈:放大 + 半透明 + viewHolder.itemView.animate() + .scaleX(1.03f).scaleY(1.03f) + .alpha(0.85f) + .setDuration(120) + .start() + } + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + didHaptic = false + + // ✅ 结束拖动,恢复视觉 + viewHolder.itemView.animate() + .scaleX(1f).scaleY(1f) + .alpha(1f) + .setDuration(120) + .start() + } + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val from = viewHolder.adapterPosition + val to = target.adapterPosition + if (from == RecyclerView.NO_POSITION || to == RecyclerView.NO_POSITION) return false + onMove(from, to) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} +} diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardAdapter.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardAdapter.kt new file mode 100644 index 0000000..5688e5d --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardAdapter.kt @@ -0,0 +1,57 @@ +package com.example.myapplication.ui.keyboard + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R +import com.example.myapplication.network.ListByUserWithNot + +class KeyboardAdapter( + private val onItemClick: (ListByUserWithNot) -> Unit +) : RecyclerView.Adapter() { + + private val items = mutableListOf() + + fun submitList(newList: List) { + items.clear() + items.addAll(newList) + notifyDataSetChanged() + } + + fun getCurrentIdsInOrder(): List = items.map { it.id } + + fun moveItem(from: Int, to: Int) { + if (from == to) return + val item = items.removeAt(from) + items.add(to, item) + notifyItemMoved(from, to) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val v = LayoutInflater.from(parent.context) + .inflate(R.layout.item_keyboard_character, parent, false) + return VH(v) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size + + inner class VH(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val root: View = itemView.findViewById(R.id.item_root) + private val tvEmoji: TextView = itemView.findViewById(R.id.tv_emoji) + private val tvName: TextView = itemView.findViewById(R.id.tv_name) + + fun bind(item: ListByUserWithNot) { + tvEmoji.text = item.emoji ?: "🙂" + tvName.text = item.characterName ?: "" + + // ✅ 点击整卡(不直接删,交给外层弹窗确认) + root.setOnClickListener { onItemClick(item) } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt index fbbfb72..1cc4e76 100644 --- a/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt +++ b/app/src/main/java/com/example/myapplication/ui/keyboard/MyKeyboard.kt @@ -1,28 +1,144 @@ package com.example.myapplication.ui.keyboard import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.Fragment import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.ui.common.LoadingOverlay +import com.example.myapplication.network.ListByUserWithNot +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.updateUserCharacterSortRequest +import kotlinx.coroutines.launch class MyKeyboard : Fragment() { - + + private lateinit var rv: RecyclerView + private lateinit var btnSave: TextView + private lateinit var adapter: KeyboardAdapter + private lateinit var loadingOverlay: LoadingOverlay + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.my_keyboard, container, false) - } + ): View = inflater.inflate(R.layout.my_keyboard, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - + view.findViewById(R.id.iv_close).setOnClickListener { - parentFragmentManager.popBackStack() + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + rv = view.findViewById(R.id.rv_keyboard) + btnSave = view.findViewById(R.id.btn_keyboard) + + adapter = KeyboardAdapter( + onItemClick = { item -> + // ✅ 点击卡片:弹自定义确认弹窗 + ConfirmDeleteDialogFragment + .newInstance(item.characterName, item.emoji) { + // ✅ 用户确认后才删除 + viewLifecycleOwner.lifecycleScope.launch { + val resp = setdelUserCharacter(item.id) + if (resp?.code == 0 && resp.data == true) { + Toast.makeText(requireContext(),"Deleted successfully", Toast.LENGTH_SHORT).show() + AuthEventBus.emit(AuthEvent.CharacterDeleted(item.id)) + loadList() + } else { + Toast.makeText(requireContext(), resp?.message ?: "Delete failed", Toast.LENGTH_SHORT).show() + } + } + } + .show(parentFragmentManager, "confirm_delete") + } + ) + + rv.layoutManager = GridLayoutManager(requireContext(), 2) + rv.adapter = adapter + + // ✅ 长按拖动排序 + 反馈 + ItemTouchHelper( + DragSortCallback { from, to -> + adapter.moveItem(from, to) + } + ).attachToRecyclerView(rv) + + loadingOverlay = LoadingOverlay.attach(view as ViewGroup) + loadList() + + // ✅ Save:上传当前排序(id数组) + btnSave.setOnClickListener { + val sortIds = adapter.getCurrentIdsInOrder() + Log.d("MyKeyboard-sort", sortIds.toString()) + + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + val body = updateUserCharacterSortRequest(sort = sortIds) + val resp = setupdateUserCharacterSort(body) + if (resp?.code == 0 && resp.data == true) { + requireActivity().onBackPressedDispatcher.onBackPressed() + Toast.makeText(requireContext(), "Sorting has been successfully modified.", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(requireContext(), resp?.message ?: "Save failed", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(requireContext(), "Network error: ${e.message}", Toast.LENGTH_SHORT).show() + } finally { + loadingOverlay.hide() + } + } + } + } + + private fun loadList() { + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + val resp = getlistByUser() + if (resp?.code == 0 && resp.data != null) { + adapter.submitList(resp.data) + Log.d("1314520-list", resp.data.toString()) + } else { + Toast.makeText(requireContext(), resp?.message ?: "Load failed", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(requireContext(), "Load failed: ${e.message}", Toast.LENGTH_SHORT).show() + } finally { + loadingOverlay.hide() + } + } + } + + // 获取用户人设列表 + private suspend fun getlistByUser(): ApiResponse>? = + runCatching { RetrofitClient.apiService.listByUser() }.getOrNull() + + // 更新用户人设排序 + private suspend fun setupdateUserCharacterSort(body: updateUserCharacterSortRequest): ApiResponse? = + runCatching { RetrofitClient.apiService.updateUserCharacterSort(body) }.getOrNull() + + // 删除用户人设 + private suspend fun setdelUserCharacter(id: Int): ApiResponse? { + loadingOverlay.show() + return try { + runCatching { RetrofitClient.apiService.delUserCharacter(id) }.getOrNull() + } finally { + loadingOverlay.hide() } } } 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 fb0602f..74434e5 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,5 +1,8 @@ package com.example.myapplication.ui.mine +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.os.Bundle import android.util.Log import android.view.LayoutInflater @@ -17,11 +20,15 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.fragment.findNavController +import com.bumptech.glide.Glide import com.example.myapplication.R import com.example.myapplication.network.AuthEvent import com.example.myapplication.network.AuthEventBus import com.example.myapplication.network.LoginResponse +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.ShareResponse import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.ui.common.LoadingOverlay import com.example.myapplication.utils.EncryptedSharedPreferencesUtil import de.hdodenhof.circleimageview.CircleImageView import kotlinx.coroutines.Job @@ -33,6 +40,9 @@ class MineFragment : Fragment() { private lateinit var nickname: TextView private lateinit var time: TextView private lateinit var logout: TextView + private lateinit var avatar: CircleImageView + private lateinit var share: LinearLayout + private lateinit var loadingOverlay: LoadingOverlay private var loadUserJob: Job? = null @@ -48,6 +58,7 @@ class MineFragment : Fragment() { override fun onDestroyView() { loadUserJob?.cancel() + loadingOverlay.remove() super.onDestroyView() } @@ -57,20 +68,29 @@ class MineFragment : Fragment() { nickname = view.findViewById(R.id.nickname) time = view.findViewById(R.id.time) logout = view.findViewById(R.id.logout) + avatar = view.findViewById(R.id.avatar) + share = view.findViewById(R.id.click_Share) + loadingOverlay = LoadingOverlay.attach(view.findViewById(R.id.rootCoordinator)) // 1) 先用本地缓存秒出首屏 renderFromCache() - // 2) 首次进入不刷新,由onResume处理 - - // // ✅ 手动刷新:不改布局也能用 - // // - 点昵称刷新 - // nickname.setOnClickListener { refreshUser(force = true, showToast = true) } - // // - 长按 time 刷新 - // time.setOnLongClickListener { - // refreshUser(force = true, showToast = true) - // true - // } + share.setOnClickListener { + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + val response = getinviteCode() + response?.data?.h5Link?.let { link -> + val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("h5Link", link) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "The sharing link has been copied to the clipboard.", Toast.LENGTH_LONG).show() + } + } finally { + loadingOverlay.hide() + } + } + } logout.setOnClickListener { LogoutDialogFragment { doLogout() } @@ -85,17 +105,20 @@ class MineFragment : Fragment() { // 使用事件总线打开金币充值页面 AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment)) } - view.findViewById(R.id.avatar).setOnClickListener { - safeNavigate(R.id.action_mineFragment_to_personalSettings) + avatar.setOnClickListener { + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.PersonalSettings)) } view.findViewById(R.id.keyboard_settings).setOnClickListener { - safeNavigate(R.id.action_mineFragment_to_mykeyboard) + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.MyKeyboard)) + } + view.findViewById(R.id.click_record).setOnClickListener { + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.consumptionRecordFragment)) } view.findViewById(R.id.click_Feedback).setOnClickListener { - safeNavigate(R.id.action_mineFragment_to_feedbackFragment) + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.feedbackFragment)) } view.findViewById(R.id.click_Notice).setOnClickListener { - safeNavigate(R.id.action_mineFragment_to_notificationFragment) + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.notificationFragment)) } // ✅ 监听登录成功/登出事件(跨 NavHost 可靠) @@ -108,6 +131,10 @@ class MineFragment : Fragment() { renderFromCache() refreshUser(force = true, showToast = false) } + AuthEvent.UserUpdated -> { + renderFromCache() + refreshUser(force = true, showToast = false) + } else -> Unit } } @@ -131,6 +158,11 @@ class MineFragment : Fragment() { ) nickname.text = cached?.nickName ?: "" time.text = cached?.vipExpiry?.let { "Due on November $it" } ?: "" + cached?.avatarUrl?.let { url -> + Glide.with(requireContext()) + .load(url) + .into(avatar) + } } /** @@ -154,15 +186,22 @@ class MineFragment : Fragment() { Log.d(TAG, "getUser ok: nick=${u?.nickName} vip=${u?.vipExpiry}") nickname.text = u?.nickName ?: "" + time.text = u?.vipExpiry?.let { "Due on November $it" } ?: "" + u?.avatarUrl?.let { url -> + Glide.with(requireContext()) + .load(url) + .into(avatar) + } + EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", u) - if (showToast) Toast.makeText(requireContext(), "已刷新", Toast.LENGTH_SHORT).show() + if (showToast) Toast.makeText(requireContext(), "Refreshed", Toast.LENGTH_SHORT).show() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) return@launch Log.e(TAG, "getUser failed", e) - if (showToast && isAdded) Toast.makeText(requireContext(), "刷新失败", Toast.LENGTH_SHORT).show() + if (showToast && isAdded) Toast.makeText(requireContext(), "Refresh failed", Toast.LENGTH_SHORT).show() } } } @@ -180,6 +219,9 @@ class MineFragment : Fragment() { // 清空 UI nickname.text = "" time.text = "" + Glide.with(requireContext()) + .load(R.drawable.default_avatar) + .into(avatar) // 触发登出事件,让MainActivity打开登录页面 AuthEventBus.emit(AuthEvent.Logout(returnTabTag = "tab_mine")) @@ -209,4 +251,7 @@ class MineFragment : Fragment() { companion object { private const val TAG = "1314520-MineFragment" } + + private suspend fun getinviteCode(): ApiResponse? = + runCatching> { RetrofitClient.apiService.inviteCode() }.getOrNull() } diff --git a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt new file mode 100644 index 0000000..0f6c51b --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/TransactionAdapter.kt @@ -0,0 +1,174 @@ +package com.example.myapplication.ui.mine.consumptionRecord + +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ProgressBar +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R +import com.example.myapplication.network.TransactionRecord + +class TransactionAdapter( + private val data: MutableList, + private val onCloseClick: () -> Unit, + private val onRechargeClick: () -> Unit +) : RecyclerView.Adapter() { + + companion object { + private const val TYPE_HEADER = 0 + private const val TYPE_ITEM = 1 + private const val TYPE_FOOTER = 2 + } + + // Header: balance + private var headerBalanceText: String = "0.00" + + // Footer state + private var showFooter: Boolean = false + private var footerNoMore: Boolean = false + + fun updateHeaderBalance(text: Any?) { + headerBalanceText = (text ?: "0.00").toString() + notifyItemChanged(0) + } + + fun setFooterLoading() { + showFooter = true + footerNoMore = false + notifyDataSetChanged() + } + + fun setFooterNoMore() { + showFooter = true + footerNoMore = true + notifyDataSetChanged() + } + + fun hideFooter() { + showFooter = false + footerNoMore = false + notifyDataSetChanged() + } + + fun replaceAll(list: List) { + data.clear() + data.addAll(list) + notifyDataSetChanged() + } + + fun append(list: List) { + if (list.isEmpty()) return + val start = 1 + data.size // header占1 + data.addAll(list) + notifyItemRangeInserted(start, list.size) + } + + override fun getItemCount(): Int { + // header + items + optional footer + return 1 + data.size + if (showFooter) 1 else 0 + } + + override fun getItemViewType(position: Int): Int { + return when { + position == 0 -> TYPE_HEADER + showFooter && position == itemCount - 1 -> TYPE_FOOTER + else -> TYPE_ITEM + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val inflater = LayoutInflater.from(parent.context) + return when (viewType) { + TYPE_HEADER -> { + val v = inflater.inflate(R.layout.layout_consumption_record_header, parent, false) + HeaderVH(v, onCloseClick, onRechargeClick) + } + TYPE_FOOTER -> { + val v = inflater.inflate(R.layout.item_loading_footer, parent, false) + FooterVH(v) + } + else -> { + val v = inflater.inflate(R.layout.item_transaction_record, parent, false) + ItemVH(v) + } + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + when (holder) { + is HeaderVH -> holder.bind(headerBalanceText) + is FooterVH -> holder.bind(footerNoMore) + is ItemVH -> holder.bind(data[position - 1]) // position-1 because header + } + } + + class HeaderVH( + itemView: View, + onCloseClick: () -> Unit, + onRechargeClick: () -> Unit + ) : RecyclerView.ViewHolder(itemView) { + + private val balance: TextView = itemView.findViewById(R.id.balance) + + init { + itemView.findViewById(R.id.iv_close).setOnClickListener { onCloseClick() } + itemView.findViewById(R.id.rechargeButton).setOnClickListener { onRechargeClick() } + } + + fun bind(balanceText: String) { + balance.text = balanceText + adjustBalanceTextSize(balance, balanceText) + } + + private fun adjustBalanceTextSize(tv: TextView, text: String) { + tv.textSize = when (text.length) { + in 0..3 -> 40f + 4 -> 36f + 5 -> 32f + 6 -> 28f + 7 -> 24f + 8 -> 22f + 9 -> 20f + else -> 16f + } + } + } + + class ItemVH(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tvTime: TextView = itemView.findViewById(R.id.tvTime) + private val tvDesc: TextView = itemView.findViewById(R.id.tvDesc) + private val tvAmount: TextView = itemView.findViewById(R.id.tvAmount) + + fun bind(item: TransactionRecord) { + tvTime.text = item.createdAt + tvDesc.text = item.description + tvAmount.text = "${item.amount}" + + // 根据type设置字体颜色 + val color = when (item.type) { + 1 -> Color.parseColor("#CD2853") // 收入 - 红色 + 2 -> Color.parseColor("#66CD7C") // 支出 - 绿色 + else -> tvAmount.currentTextColor // 保持当前颜色 + } + tvAmount.setTextColor(color) + } + } + + class FooterVH(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val progress: ProgressBar = itemView.findViewById(R.id.progress) + private val tv: TextView = itemView.findViewById(R.id.tvLoading) + + fun bind(noMore: Boolean) { + if (noMore) { + progress.visibility = View.GONE + tv.text = "No more" + } else { + progress.visibility = View.VISIBLE + tv.text = "Loading..." + } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt new file mode 100644 index 0000000..d429110 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/mine/consumptionRecord/consumptionRecordFragment.kt @@ -0,0 +1,181 @@ +package com.example.myapplication.ui.mine.consumptionRecord + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.TransactionRecord +import com.example.myapplication.network.Wallet +import com.example.myapplication.network.transactionsRequest +import com.example.myapplication.network.transactionsResponse +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus +import kotlinx.coroutines.launch + +class ConsumptionRecordFragment : BottomSheetDialogFragment() { + + private lateinit var swipeRefresh: SwipeRefreshLayout + private lateinit var rv: RecyclerView + private lateinit var adapter: TransactionAdapter + + private val listData = arrayListOf() + + private var pageNum = 1 + private val pageSize = 10 + private var totalPages = Int.MAX_VALUE + private var isLoading = false + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return inflater.inflate(R.layout.fragment_consumption_record, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + swipeRefresh = view.findViewById(R.id.swipeRefresh) + rv = view.findViewById(R.id.rvTransactions) + + setupRecycler() + setupRefresh() + + refreshAll() + } + + /** + * ✅ 重点:关闭必须走 NavController popBackStack + * 不要 dismiss(),否则 global_nav 栈不会变,底部导航就会一直被隐藏 + */ + private fun closeByNav() { + runCatching { + findNavController().popBackStack() + }.onFailure { + // 万一不是走 nav 打开的(极少情况),再兜底 dismiss + dismissAllowingStateLoss() + } + } + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + // ✅ 用户手势下拉/点外部取消,也要 pop 返回栈 + closeByNav() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + // ✅ 有些机型/场景只走 onDismiss,不走 onCancel,双保险 + closeByNav() + } + + private fun setupRecycler() { + adapter = TransactionAdapter( + data = listData, + onCloseClick = { closeByNav() }, // ✅ 改这里:不要 dismiss() + onRechargeClick = { + AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment)) + } + ) + + rv.layoutManager = LinearLayoutManager(requireContext()) + rv.adapter = adapter + + rv.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + super.onScrollStateChanged(recyclerView, newState) + if (newState != RecyclerView.SCROLL_STATE_IDLE) return + + val reachedBottom = !recyclerView.canScrollVertically(1) + if (!reachedBottom) return + + if (!isLoading && pageNum < totalPages) { + loadMore() + } else if (!isLoading && pageNum >= totalPages) { + adapter.setFooterNoMore() + } + } + }) + } + + private fun setupRefresh() { + swipeRefresh.setOnRefreshListener { refreshAll() } + } + + private fun refreshAll() { + lifecycleScope.launch { + swipeRefresh.isRefreshing = true + + pageNum = 1 + totalPages = Int.MAX_VALUE + isLoading = false + + adapter.hideFooter() + adapter.replaceAll(emptyList()) + + val walletResp = getwalletBalance() + val balanceText = walletResp?.data?.balanceDisplay ?: "0.00" + adapter.updateHeaderBalance(balanceText) + + loadPage(targetPage = 1, isRefresh = true) + + swipeRefresh.isRefreshing = false + } + } + + private fun loadMore() { + lifecycleScope.launch { loadPage(targetPage = pageNum + 1, isRefresh = false) } + } + + private suspend fun loadPage(targetPage: Int, isRefresh: Boolean) { + if (isLoading) return + isLoading = true + + if (!isRefresh) adapter.setFooterLoading() + + val body = transactionsRequest(pageNum = targetPage, pageSize = pageSize) + val resp = gettransactions(body) + val data = resp?.data + + if (data != null) { + totalPages = data.pages + pageNum = data.current + + val records = data.records + + if (isRefresh) adapter.replaceAll(records) else adapter.append(records) + + if (pageNum >= totalPages) adapter.setFooterNoMore() else adapter.hideFooter() + + rv.post { + val notScrollableYet = !rv.canScrollVertically(1) + if (!isLoading && notScrollableYet && pageNum < totalPages) { + loadMore() + } + } + } else { + adapter.hideFooter() + } + + isLoading = false + } + + // ========================网络请求=========================================== + private suspend fun getwalletBalance(): ApiResponse? = + runCatching { RetrofitClient.apiService.walletBalance() }.getOrNull() + + private suspend fun gettransactions(body: transactionsRequest): ApiResponse? = + runCatching { RetrofitClient.apiService.transactions(body) }.getOrNull() +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/FeedbackFragment.kt b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/FeedbackFragment.kt index 8579ba6..c1a6efb 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/FeedbackFragment.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/FeedbackFragment.kt @@ -2,19 +2,23 @@ package com.example.myapplication.ui.mine.myotherpages import android.os.Bundle import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import com.example.myapplication.R import android.widget.FrameLayout -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.button.MaterialButton -import com.google.android.material.textfield.TextInputLayout -import java.util.* +import android.widget.Toast +import androidx.fragment.app.Fragment +import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.feedbackRequest +import com.google.android.material.textfield.TextInputEditText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class FeedbackFragment : Fragment() { - + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -26,9 +30,51 @@ class FeedbackFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 设置关闭按钮点击事件 + // 关闭按钮 view.findViewById(R.id.iv_close).setOnClickListener { parentFragmentManager.popBackStack() } + + // 让多行输入框:不聚焦也能上下滑动内容 + val etFeedback = view.findViewById(R.id.et_feedback) + etFeedback.apply { + // 可选:让它本身可滚动(你 XML 已经写了也没问题) + isVerticalScrollBarEnabled = true + + setOnTouchListener { v, event -> + // 告诉父布局(NestedScrollView)先别抢这个触摸事件 + v.parent?.requestDisallowInterceptTouchEvent(true) + + // 手指抬起/取消时,把控制权还给父布局(页面还能继续滚) + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + v.parent?.requestDisallowInterceptTouchEvent(false) + } + + // false:不吞事件,让 EditText 自己处理滚动/光标 + false + } + } + + // 提交反馈按钮点击事件 + view.findViewById(R.id.btn_keyboard).setOnClickListener { + val feedbackText = etFeedback.text.toString().trim() + if (feedbackText.isEmpty()) { + Toast.makeText(context, "Please enter your feedback", Toast.LENGTH_SHORT).show() + return@setOnClickListener + } + + CoroutineScope(Dispatchers.Main).launch { + val response = submitFeedback(feedbackRequest(content = feedbackText)) + if (response?.code == 0) { + Toast.makeText(context, "Feedback submitted successfully", Toast.LENGTH_SHORT).show() + parentFragmentManager.popBackStack() + } else { + Toast.makeText(context, "Failed to submit feedback", Toast.LENGTH_SHORT).show() + } + } + } } -} \ No newline at end of file + //提交反馈 + private suspend fun submitFeedback(body:feedbackRequest): ApiResponse? = + runCatching> { RetrofitClient.apiService.feedback(body) }.getOrNull() +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/GenderSelectSheet.kt b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/GenderSelectSheet.kt new file mode 100644 index 0000000..abc3f16 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/GenderSelectSheet.kt @@ -0,0 +1,165 @@ +package com.example.myapplication.ui.mine.myotherpages + +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSnapHelper +import androidx.recyclerview.widget.RecyclerView +import com.example.myapplication.R +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlin.math.abs + +class GenderSelectSheet : BottomSheetDialogFragment() { + + private val values = listOf("Male", "Female", "The third gender") + private var selectedIndex = 0 + + private val itemHeightDp = 48f // 每行高度(和 Adapter 里一致) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.sheet_select_gender, container, false) + + selectedIndex = (arguments?.getInt(ARG_INITIAL) ?: 0).coerceIn(0, values.lastIndex) + + val rv = view.findViewById(R.id.gender_wheel) + + val layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false) + rv.layoutManager = layoutManager + rv.adapter = WheelAdapter(values, dpToPx(itemHeightDp)) + rv.overScrollMode = View.OVER_SCROLL_NEVER + rv.isNestedScrollingEnabled = true + rv.clipToPadding = false + + val snapHelper = LinearSnapHelper() + snapHelper.attachToRecyclerView(rv) + + // ✅ 关键:触摸滚轮时不让 BottomSheet 抢手势(否则会拖动弹窗) + rv.setOnTouchListener { v, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + setSheetDraggable(false) + v.parent?.requestDisallowInterceptTouchEvent(true) + } + MotionEvent.ACTION_MOVE -> v.parent?.requestDisallowInterceptTouchEvent(true) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + setSheetDraggable(true) + v.parent?.requestDisallowInterceptTouchEvent(false) + } + } + false + } + + rv.addOnScrollListener(object : RecyclerView.OnScrollListener() { + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + updateChildColors(recyclerView) + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + val snapView = snapHelper.findSnapView(layoutManager) ?: return + val pos = layoutManager.getPosition(snapView).coerceIn(0, values.lastIndex) + selectedIndex = pos + updateChildColors(recyclerView) + } + } + }) + + // ✅ 关键:给上下 padding,让 3 条内容也能滚动/居中吸附 + rv.post { + val itemPx = dpToPx(itemHeightDp) + val pad = (rv.height / 2 - itemPx / 2).coerceAtLeast(0) + rv.setPadding(rv.paddingLeft, pad, rv.paddingRight, pad) + + layoutManager.scrollToPositionWithOffset(selectedIndex, pad) + rv.post { updateChildColors(rv) } + } + + view.findViewById(R.id.btn_close).setOnClickListener { dismiss() } + + view.findViewById(R.id.btn_save).setOnClickListener { + parentFragmentManager.setFragmentResult( + REQ_KEY, + Bundle().apply { putInt(BUNDLE_KEY_GENDER, selectedIndex) } + ) + dismiss() + } + + return view + } + + private fun setSheetDraggable(draggable: Boolean) { + val d = dialog as? BottomSheetDialog ?: return + val sheet = d.findViewById(com.google.android.material.R.id.design_bottom_sheet) ?: return + BottomSheetBehavior.from(sheet).isDraggable = draggable + } + + /** 根据距离中心点决定文字颜色 */ + private fun updateChildColors(rv: RecyclerView) { + val centerY = rv.height / 2f + val selectedColor = Color.parseColor("#02BEAC") + val normalColor = Color.parseColor("#B5B5B5") + + for (i in 0 until rv.childCount) { + val child = rv.getChildAt(i) + val tv = child as? TextView ?: continue + + val childCenterY = (child.top + child.bottom) / 2f + val distance = abs(childCenterY - centerY) + + val isSelected = distance < dpToPx(8f) + tv.setTextColor(if (isSelected) selectedColor else normalColor) + } + } + + companion object { + const val REQ_KEY = "req_select_gender" + const val BUNDLE_KEY_GENDER = "bundle_gender" + private const val ARG_INITIAL = "arg_initial_gender" + + fun newInstance(initialGender: Int) = GenderSelectSheet().apply { + arguments = Bundle().apply { putInt(ARG_INITIAL, initialGender) } + } + } + + private fun dpToPx(dp: Float): Int = + (dp * resources.displayMetrics.density + 0.5f).toInt() + + private class WheelAdapter( + private val items: List, + private val itemHeightPx: Int + ) : RecyclerView.Adapter() { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { + val tv = TextView(parent.context).apply { + layoutParams = RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + itemHeightPx + ) + gravity = Gravity.CENTER + textSize = 18f + setTextColor(Color.parseColor("#B5B5B5")) + } + return VH(tv) + } + + override fun onBindViewHolder(holder: VH, position: Int) { + (holder.itemView as TextView).text = items[position] + } + + override fun getItemCount(): Int = items.size + + class VH(itemView: View) : RecyclerView.ViewHolder(itemView) + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/NicknameEditSheet.kt b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/NicknameEditSheet.kt new file mode 100644 index 0000000..dfa9147 --- /dev/null +++ b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/NicknameEditSheet.kt @@ -0,0 +1,64 @@ +package com.example.myapplication.ui.mine.myotherpages + +import android.os.Bundle +import android.text.InputType +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.core.view.setPadding +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.button.MaterialButton +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import com.example.myapplication.R + +class NicknameEditSheet : BottomSheetDialogFragment() { + + override fun onStart() { + super.onStart() + dialog?.window?.setSoftInputMode( + android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or + android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE + ) + } + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val view = inflater.inflate(R.layout.sheet_edit_nickname, container, false) + val initial = arguments?.getString(ARG_INITIAL).orEmpty() + + val et = view.findViewById(R.id.et_nickname) + et.setText(initial) + + view.findViewById(R.id.btn_close).setOnClickListener { dismiss() } + + view.findViewById(R.id.btn_save).setOnClickListener { + val nickname = et.text?.toString()?.trim().orEmpty() + if (nickname.isBlank()) return@setOnClickListener + + parentFragmentManager.setFragmentResult( + REQ_KEY, + Bundle().apply { putString(BUNDLE_KEY_NICKNAME, nickname) } + ) + dismiss() + } + + return view + } + + companion object { + const val REQ_KEY = "req_edit_nickname" + const val BUNDLE_KEY_NICKNAME = "bundle_nickname" + private const val ARG_INITIAL = "arg_initial_nickname" + + fun newInstance(initial: String) = NicknameEditSheet().apply { + arguments = Bundle().apply { putString(ARG_INITIAL, initial) } + } + } +} diff --git a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt index 2865dc2..76c4a01 100644 --- a/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt +++ b/app/src/main/java/com/example/myapplication/ui/mine/myotherpages/PersonalSettings.kt @@ -1,34 +1,386 @@ package com.example.myapplication.ui.mine.myotherpages +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import com.example.myapplication.R import android.widget.FrameLayout +import android.widget.TextView +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.lifecycle.lifecycleScope +import com.bumptech.glide.Glide +import com.example.myapplication.R +import com.example.myapplication.network.ApiResponse +import com.example.myapplication.network.AuthEvent +import com.example.myapplication.network.AuthEventBus +import com.example.myapplication.network.RetrofitClient +import com.example.myapplication.network.User +import com.example.myapplication.ui.common.LoadingOverlay import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.button.MaterialButton -import com.google.android.material.textfield.TextInputLayout -import java.util.* +import de.hdodenhof.circleimageview.CircleImageView +import kotlinx.coroutines.launch +import com.example.myapplication.network.updateInfoRequest +import android.util.Log +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale class PersonalSettings : BottomSheetDialogFragment() { - + + private var user: User? = null + + private lateinit var avatar: CircleImageView + private lateinit var tvNickname: TextView + private lateinit var tvGender: TextView + private lateinit var tvUserId: TextView + private lateinit var loadingOverlay: LoadingOverlay + + // ActivityResultLauncher for image selection - restrict to image types + private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + uri?.let { handleImageResult(it) } + } + + private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success: Boolean -> + if (success) { + cameraImageUri?.let { handleImageResult(it) } + } + } + + private var cameraImageUri: Uri? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.personal_settings, container, false) - } + ): View = inflater.inflate(R.layout.personal_settings, container, false) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - // 设置关闭按钮点击事件 + // 初始化loadingOverlay + loadingOverlay = LoadingOverlay.attach(requireView() as ViewGroup) + + // 关闭 view.findViewById(R.id.iv_close).setOnClickListener { - parentFragmentManager.popBackStack() + AuthEventBus.emit(AuthEvent.UserUpdated) + requireActivity().onBackPressedDispatcher.onBackPressed() + } + + // bind + avatar = view.findViewById(R.id.avatar) + tvNickname = view.findViewById(R.id.tv_nickname_value) + tvGender = view.findViewById(R.id.tv_gender_value) + tvUserId = view.findViewById(R.id.tv_userid_value) + + // Avatar click listener + avatar.setOnClickListener { + showImagePickerDialog() + } + + // ===================== FragmentResult listeners ===================== + + // 昵称保存回传 + parentFragmentManager.setFragmentResultListener( + NicknameEditSheet.REQ_KEY, + viewLifecycleOwner + ) { _, bundle -> + val newName = bundle.getString(NicknameEditSheet.BUNDLE_KEY_NICKNAME).orEmpty() + if (newName.isBlank()) return@setFragmentResultListener + lifecycleScope.launch { + loadingOverlay.show() + try { + val ReturnValue = setupdateUserInfo(updateInfoRequest(nickName = newName)) + Log.d("1314520-PersonalSettings", "setupdateUserInfo: $ReturnValue") + if(ReturnValue?.code == 0){ + tvNickname.text = newName + } + user = user?.copy(nickName = newName) + } catch (e: Exception) { + Log.e("PersonalSettings", "Failed to update nickname", e) + } finally { + loadingOverlay.hide() + } } } -} \ No newline at end of file + + // 性别保存回传 + parentFragmentManager.setFragmentResultListener( + GenderSelectSheet.REQ_KEY, + viewLifecycleOwner + ) { _, bundle -> + val newGender = bundle.getInt(GenderSelectSheet.BUNDLE_KEY_GENDER, 0) + + lifecycleScope.launch { + loadingOverlay.show() + try { + val ReturnValue = setupdateUserInfo(updateInfoRequest(gender = newGender)) + if(ReturnValue?.code == 0){ + tvGender.text = genderText(newGender) + } + user = user?.copy(gender = newGender) + } catch (e: Exception) { + Log.e("PersonalSettings", "Failed to update gender", e) + } finally { + loadingOverlay.hide() + } + } + } + + // ===================== row click ===================== + + // Nickname:打开编辑 BottomSheet(arguments 传初始值) + view.findViewById(R.id.row_nickname).setOnClickListener { + NicknameEditSheet.newInstance(user?.nickName.orEmpty()) + .show(parentFragmentManager, "NicknameEditSheet") + } + + // Gender:打开选择 BottomSheet + view.findViewById(R.id.row_gender).setOnClickListener { + GenderSelectSheet.newInstance(user?.gender ?: 0) + .show(parentFragmentManager, "GenderSelectSheet") + } + + // UserID:点击复制 + view.findViewById(R.id.row_userid).setOnClickListener { + val uid = user?.uid?.toString() ?: tvUserId.text?.toString().orEmpty() + if (uid.isBlank()) return@setOnClickListener + copyToClipboard(uid) + Toast.makeText(requireContext(), "Copy successfully", Toast.LENGTH_SHORT).show() + } + + // ===================== load & render ===================== + + viewLifecycleOwner.lifecycleScope.launch { + loadingOverlay.show() + try { + val resp = getUserdata() + val u = resp?.data // 如果你的 ApiResponse 字段不是 data,这里改成你的字段名 + if (u == null) { + Toast.makeText(requireContext(), "Load failed", Toast.LENGTH_SHORT).show() + return@launch + } + user = u + renderUser(u) + } finally { + loadingOverlay.hide() + } + } + } + + private fun renderUser(u: User) { + tvNickname.text = u.nickName + tvGender.text = genderText(u.gender) + tvUserId.text = u.uid.toString() + + Glide.with(this) + .load(u.avatarUrl) + .placeholder(R.drawable.default_avatar) + .error(R.drawable.default_avatar) + .into(avatar) + } + + private fun genderText(gender: Int): String = when (gender) { + 1 -> "Female" + 2 -> "The third gender" + 0 -> "Male" + else -> "" + } + + private fun copyToClipboard(text: String) { + val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("user_id", text)) + } + + private suspend fun getUserdata(): ApiResponse? = + runCatching { RetrofitClient.apiService.getUser() }.getOrNull() + + private suspend fun setupdateUserInfo(body: updateInfoRequest): ApiResponse? = + runCatching { RetrofitClient.apiService.updateUserInfo(body) }.getOrNull() + + private val cameraPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + cameraImageUri = createImageFile() + cameraImageUri?.let { cameraLauncher.launch(it) } + } else { + Toast.makeText( + requireContext(), + "Camera permission is required to take photos", + Toast.LENGTH_SHORT + ).show() + } + } + + private fun showImagePickerDialog() { + val options = arrayOf( + getString(R.string.choose_from_gallery), + getString(R.string.take_photo) + ) + + androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle(R.string.change_avatar) + .setItems(options) { _, which -> + when (which) { + 0 -> galleryLauncher.launch("image/png,image/jpeg") + 1 -> { + if (ContextCompat.checkSelfPermission( + requireContext(), + android.Manifest.permission.CAMERA + ) == PackageManager.PERMISSION_GRANTED + ) { + cameraImageUri = createImageFile() + cameraImageUri?.let { cameraLauncher.launch(it) } + } else { + cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA) + } + } + } + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun handleImageResult(uri: Uri) { + Glide.with(this) + .load(uri) + .into(avatar) + + lifecycleScope.launch { + uploadAvatar(uri) + } + } + + private suspend fun uploadAvatar(uri: Uri) { + loadingOverlay.show() + try { + // Get MIME type to determine image format + val mimeType = requireContext().contentResolver.getType(uri) + val isPng = mimeType?.equals("image/png", ignoreCase = true) == true + + // Determine file extension and compression format based on MIME type + val fileExtension = if (isPng) ".png" else ".jpg" + val compressFormat = if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG + val mediaType = if (isPng) "image/png" else "image/jpeg" + + val inputStream = requireContext().contentResolver.openInputStream(uri) ?: return + val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val tempFile = File.createTempFile( + "UPLOAD_${timeStamp}_", + fileExtension, + storageDir + ) + + // Read and compress image if needed + val options = BitmapFactory.Options() + options.inJustDecodeBounds = true + BitmapFactory.decodeStream(inputStream, null, options) + inputStream.close() + + // Calculate inSampleSize + var inSampleSize = 1 + val maxSize = 5 * 1024 * 1024 // 5MB + if (options.outHeight * options.outWidth * 4 > maxSize) { + val halfHeight = options.outHeight / 2 + val halfWidth = options.outWidth / 2 + while (halfHeight / inSampleSize >= 1024 && halfWidth / inSampleSize >= 1024) { + inSampleSize *= 2 + } + } + + // Decode with inSampleSize + options.inJustDecodeBounds = false + options.inSampleSize = inSampleSize + val inputStream2 = requireContext().contentResolver.openInputStream(uri) ?: return + val bitmap = BitmapFactory.decodeStream(inputStream2, null, options) + inputStream2.close() + + // Compress to file + tempFile.outputStream().use { output -> + bitmap?.let { bmp -> + if (isPng) { + // PNG compression (quality parameter is ignored for PNG) + bmp.compress(Bitmap.CompressFormat.PNG, 100, output) + } else { + // JPEG compression with quality adjustment + var quality = 90 + do { + output.channel.truncate(0) + bmp.compress(Bitmap.CompressFormat.JPEG, quality, output) + quality -= 10 + } while (tempFile.length() > maxSize && quality > 10) + } + } ?: run { + Toast.makeText(requireContext(), "Failed to decode image", Toast.LENGTH_SHORT).show() + return + } + } + + if (tempFile.length() > maxSize) { + Toast.makeText(requireContext(), "Image is too large after compression", Toast.LENGTH_SHORT).show() + return + } + + val requestFile = RequestBody.create(mediaType.toMediaTypeOrNull(), tempFile) + val body = MultipartBody.Part.createFormData("file", tempFile.name, requestFile) + + val response = RetrofitClient.createFileUploadService() + .uploadFile("avatar", body) + + // Clean up + bitmap?.recycle() + tempFile.delete() + + if (response?.code == 0) { + val ReturnValue = setupdateUserInfo(updateInfoRequest(avatarUrl = response.data)) + Toast.makeText(requireContext(), R.string.avatar_updated, Toast.LENGTH_SHORT).show() + user = user?.copy(avatarUrl = response.data) + } else { + Toast.makeText(requireContext(), R.string.upload_failed, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Toast.makeText(requireContext(), R.string.upload_error, Toast.LENGTH_SHORT).show() + Log.e("PersonalSettings", "Upload avatar error", e) + } finally { + loadingOverlay.hide() + } + } + + private fun createImageFile(): Uri? { + val storageDir = requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFile = File.createTempFile( + "JPEG_${timeStamp}_", + ".jpg", + storageDir + ) + return FileProvider.getUriForFile( + requireContext(), + "${requireContext().packageName}.fileprovider", + imageFile + ) + } + + override fun onDestroyView() { + loadingOverlay.remove() + super.onDestroyView() + } +} diff --git a/app/src/main/res/drawable/associate_close.png b/app/src/main/res/drawable/associate_close.png new file mode 100644 index 0000000000000000000000000000000000000000..d3c98094d068c035f25715d67a0d23cd8a761c1b GIT binary patch literal 2905 zcmWkwdpJ~U7oR;BW|T2Ag{F43nJp42sYa14Q!`46N|JQSD3{XAM9Haax5k7qGkrMZ zqe4=0>*Cfhq+BZ^$*swZ$SR#cwkGG-q8?sbvJ^(XWy+^Dq3jM*UIPPod#>oXKidU8tc+?etEl%YnT1Al}Kf+ z%KaAEJE4s3zS%vxw4$Wu;3>5I^2|98c5Lq=-({vg0j77gNx!Zxr}rkSPNwJlt@2*v z_I|S8@B9s%eQub22J@0zoH5-Z8SU2mFEM}5yNH^pvqVmsM^xIgKgxbG;Qb-0ZT&|V zET!MvpYoh8&+Tlp`RoXl|A$ey_7J!uc*t;jN;qcDa< zn>W>UDpt)}?m#@vlE!tI@%~h;x&0-p6v*1Wb@yEzMK|wp;QEDP&i*v-oHXRO`#D1C z-b=^}%kl?LUZO6h%SKI?rX#<1)3`DVUW00RV7>6r(Wl7Pl_lAefFjy(gP^NVz_X}* zj%>9lL2OHH4UhYmth81~uys4D8-13gvm{SEpXXdQ1aFlbIT61Yo*@+$ zip@X+Wa~g|_&OnEzo@G0SJ7Kp;NZ!%LBD2F`U(Npj|mSJKR2aHn#D7=C0|$U_r-@E zP+4;;E@>j$eoXjTZEq{GzXrPcq@AAW^%6E`NBP!zSiC8%UIQ(+(HgP5?-YnD46~g) z0zX?c{D7@nYmU3Mv5PCJp4ZIIZ5*y5x7{f}QaspiD;-O{Ju=Vu)snue?|P;Ov}FUW z$=-usiv_+m{wErn`U2jJgra$&odwdrsZC30GfbZ6_Sn-ag&+V>;&T{41=|xv;e5{U z17R}Dkjxv#_x7KfW{Xgi2)B{rRPs4Q7?P9Y>V-OF-VqihUSma*HF6kKE(Noj84T`n z%pwdUu08~PPKNEIJ_s^A&ou&E8;v#^GXw(@XTKb~daCQ0zO;(o^=b*2nANS*Am<#a zA8jXtJ3E5`B5ZF~SYXZe5_Rg4d0*yVXmwMS(eu*~MB$mK=|}y|b?-!@d-g=++SV&!_8j1Lz$?-uZN!iku_tkCn6Ad9TE zfFOH$QOrs;oB2?VAR^nKj6s#0SsF0ei6EY`hjGPf9{GjeJbmo-S7Y+H`X+6kHRhXS zB$}u^mS<<8Xr8-gy@$EZd{`6 z$&5x}>tr=Av8&Ll539iGj+(vq>%0G2;eeLNP4#lW(Sid$0AA5FKUNdgG8%r5NQ6&T zk+^=z#`hoSz#ABX`KfcI=KVEXqez=|M=A%dg9HQQ{)kcTXQ zn`-Oil%Naw$x+R@gZswbkrLL!>YHA}0fe*oVG@Pi8eAQG@+4Bkn%T6YYxEX#d}D7x z+oBAo@1f-%dmQj((!3K-`jf}0;<(loG}*n_(skwA!J%k0&wx?EHpKz12jF5M2g`OH zZ$OiPWg0GzQ_=Owy!Q|#mSswl{l|n7&$6V+vRp86@FA$h1rtXY1i88}jC7G9J#9r> zUG0sR+bX@T32|EGkn=>alLHlXFe34WG=TJgmBX8&h@*}~MG(H}x(GMXYGKO|IJ_hv zJt=GGa@0TH2v@G8CB_lKMd3KvuQjP;o~mp&0cXD{bvlU%>dxpkucnopTo%JJnZ~)2 zU)+%tuqbTCBo&jV{bsy0DT@HJys5t+AAP%W&G&Y!4yICs2?f!aNM+g@vpw)Dc$HA-9K*36Q$iCpxxq=Fnf;DifOI8i@B+ z&e;@GTC{D(aN_GOW$AS277|`+2fp1oz`_IECDIPwbpU9x2M^0Rc)ZjWG?8N;Al<-j zFp=PhV7M@D^YCIayH8DyZCpbW+GyO1KBJnZkFUx`nzE`l50eti!AS)P_D{QHa9|48$fC+DR3^Jisf}we4>`i* z5kZKV)MzQ=u@PIEWD7*cYNRnj5K|4V1qY|0s78rY5(g-WhLAeKnk)-LjsRr#gE;`4 zEDzMhOEq|`iAnAi9dn7`yC9NlWKp?KFji=ho!_EZ*y*U)(GCwZ9qwDV2-TkZ;Pq!P z0WX~(#^yO(oTl3W09V-`A}8quOO+u68si^Y^-}qMcoK zzg9U!8SFk2uVpspq~kBM5*{0~zwn=0Dx$FcrTnK^4e>~LL7tWBnr@TByxUXo3R+>o zSO`VybbhsaCY`Nlu*;M0g{Lz!qksN8Y)nk7`YqV4!>@8Hw!qW5&2u^K?IXm&7jwYV zE0r(jFpgY%(e;`J5k&t^9^y6LOp5w)TQD_!;&B28D@yp!9J4sAlM@t zi1xOh#fi^@X)@pEE?V-xa{H++jnPs$KYlD#e-b~^QyBE6Kgm#rwCFsWO(lpYlk)6&OJ zS>k7XnP!o15EfBs+hH1<#I(fc-_Fvew$o+y?`=&yu1xN-&xm{YR#e9~vW)+5JcZ0V zxNXO8#i#A(iks?0*|iyZd`dvJ%q{fyu}zcdC8FNeOil`~Tz{JG-IM#_!_M?Gk7f`0 grf!(x_01Caue%SKd1Q&PT^Z-P#hp{U`Hz_Y1L6kvg#Z8m literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/complete_close_bg.xml b/app/src/main/res/drawable/complete_close_bg.xml new file mode 100644 index 0000000..c8b23d0 --- /dev/null +++ b/app/src/main/res/drawable/complete_close_bg.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/consumption_details_bg.xml b/app/src/main/res/drawable/consumption_details_bg.xml new file mode 100644 index 0000000..445361e --- /dev/null +++ b/app/src/main/res/drawable/consumption_details_bg.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/first_add.png b/app/src/main/res/drawable/first_add.png new file mode 100644 index 0000000000000000000000000000000000000000..99249b9c3fe423af0faf48c32bad61a3bfc425bb GIT binary patch literal 2685 zcmeHJ`#Tc~7~X7S?xwkQT$W1|an4D($Fzl0XfB~#v&12*X;v=na`cFGLWC4fN-mRJ za-WEpT2w;0%`IVAjA2;L`WyPy58wB_&-Xmv58wBE@B6$-r(GOmrS?hz003DhN0ghW zcKr;fxF|pKF3%GcV3eDKJ)pcteGUMSGIK&9J+ApL=K8q}e^EZM9whckr+z7fE3*n$ zbqds_q$OxN&FXg}HV$!N*?EA*`1jVK}6eOw!7hO0LKP)g1Rb)rV>A5d*79dA4|Y64ZFyEc>`v!lkxuUg^<>E zRl*CZj0eeP#sKqD9=@~6cs(~@_n#6LQ9)dEy7xFQ8#i^tHX<^muJTdkrQ=BP(72B- z?@wl!fKV-a2hm8jA8{2gGx7=Zsi3%qu(l|kdih$uKfkekc~}yyiQ3No+Xgy0I}vLy_8vN-ezfkQ zJawc-KbNeFpyWf}$v+sL674j@lBV*~$Y4!av5Erili}DN#cTIkof|~7R*J&et@>$G zX26=@<)K8JtR&-9pg&|8JX)6shQD4XgE#kFPVUdYAAUQrwH?k%UtNZC)7Eq@Pca!n za>ME^XkTm<=cav&`jLWOn?Al|-?=e5f={nu7uO#%%Tkx5w12BD<96_@vATt^+4C^( zQ66`ZJHL!CNrTN6Se47;m!#cw4B74R4_d2DPW5|c&S(=#Yv%PqFXO?+`zRAo>yd9S zlS!K hB6f^a-d1@B`z^V5k(jT{tPw5lY~jWlaY5x(!x#<+Q2byBS3xSoZ-((9~5k z-HJ?AlN-r3J>r@|vWsx}Vaj~j;mix$z}z_JF{Y4HvPhEM1X&8?Y{oP%Tmy7Y(R>Ba z#WBrh0myQT=-4zI3>nnedQ@A6K=?p_LHm>36e^&*VD|{W@CmojRzlwbUus(OY{SMj8W;|a3&tuup>fZPH0o9RUP1>9Tu`8ny0##U1CUxG{=(qFf#a@Yu$uE!gUk|4rvtWh2C2g^XO;hMPA`S zoSIbXY8!v8jTLrA1AT|>pW`=K(NN>690rAppPh>SBiTGg<}@~eDnlVyB=?04)b^mi ziA=(>0`t$cQx2&VHgI?wMW=Bidf!EgTV`OG2Sr{67hGDX4pN?lhkMtvR?=QY+G6fZ zY(;_9@~+r~Jy%h9<68TTm5wj+1g#e#Kp_7PFDLEEPqDa=g@UGsrq%RAi)*uHj}hl? zNPA%9CdZ>xMz7~Pf2Mz3ze1Y6q#Iw{O!9$Tw{8DdF;nO598x`Qp@9CF#laD@JabR< kALt8U=QhF4V+(hH-#sEZK~`$dMSc?Cgmyud+Xqtr2id4nFaQ7m literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/gender_background.xml b/app/src/main/res/drawable/gender_background.xml index 4420fb3..47a483c 100644 --- a/app/src/main/res/drawable/gender_background.xml +++ b/app/src/main/res/drawable/gender_background.xml @@ -2,4 +2,5 @@ + \ No newline at end of file diff --git a/app/src/main/res/drawable/gender_background_select.xml b/app/src/main/res/drawable/gender_background_select.xml new file mode 100644 index 0000000..c5996f6 --- /dev/null +++ b/app/src/main/res/drawable/gender_background_select.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gold_coin_bg.xml b/app/src/main/res/drawable/gold_coin_bg.xml new file mode 100644 index 0000000..b326e83 --- /dev/null +++ b/app/src/main/res/drawable/gold_coin_bg.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/list_two_bg_already.xml b/app/src/main/res/drawable/list_two_bg_already.xml new file mode 100644 index 0000000..37d8946 --- /dev/null +++ b/app/src/main/res/drawable/list_two_bg_already.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/my_keyboard_cancel.xml b/app/src/main/res/drawable/my_keyboard_cancel.xml new file mode 100644 index 0000000..16990a2 --- /dev/null +++ b/app/src/main/res/drawable/my_keyboard_cancel.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/operation_add.png b/app/src/main/res/drawable/operation_add.png new file mode 100644 index 0000000000000000000000000000000000000000..52a253d2f2eebadd077a01252403bedd9519b8dc GIT binary patch literal 2563 zcmeHJ=~vPT6a@qWmjMhd8%-=7*UDD1Ow?om8#fwLEKtkR(cG8RTz{ITHl=A<=9as; zkPDds%Cxu~3+<3(YVJ!yMrMW#BaHnG^Kts{-aY5N^X`ZD;oh6+;zU$e*`@*lfz-() zd&&m(elNK4MsM_JD%k*V1chh^YU1fEf1LAUxkwe((Em&{ADN9j0O3dm_y-aStLqX=UMW7! zYd$Dl75fD)&HI~md_e}AO~_M262%<*wMXxyUAxylYs^I#R*M89r^;a2M@5}bjIUR? zYQeo1Yc14|0aN8S{PtK1{l75E!g`}sAWzjY zDX53=???6~aE~+~+2VLP25N3+Yja26RRyATAB%mNvq6*X@`L98{F{JgEJxD%N1?Sd z|LXu-eU-&mK(qRrW5WoS~CF!77Mvt1bUd9kAvOKpiC@ObR&Y=>rGP#P>1|%f#-VsUXCeA%t?2}Yb9LU ze_}Ec!Hzu;tA91TD7e2#nt?cT43CT5aWy=C>qBsjW7&0sf<|UWeewWIsu_CsOBn&(rpqcE}?4sxdluU3GmXrLHJ?H0F3yJ|I8)Oj6~zDn`$FSlPTPw zz@@Whq%GH=2A|Wow7@0y>bR)am54Ih*qWqdYdoL2#kvBe8-<<*-snc?*+g#1<(_~@ zfEe*J+HnYD^J6nxE89zwoMhlJlH_d(&hY5J+wnXOK89?zB6~Sc1uyP{NQ@Tg^EBmG z9dD0MRzsKjT}38iyJ){owm|FJL)Xft_l5E}5Xikf&djA&kCKdrvQS(YIz>htPZ&!; z@AsplQ&!?~Q@!qZy3Pa(dC-zXp9fm^1_%MQQ6F(ipV*o_;}kITlU~HcytTsViP1-; zt3St5ph8_LqpQuw`sUhtPM(l|3x=@Dtq4yD(;>aB&HLrE-E|ynxoEaBJ9@4F9kaZ< zp(k6hJU6tGDJysd>14-gM=tP;J~tP*J75K-lr&(NeC<7+V)^iRCgm-8z!qTU7e$Qd5T zdN6MUXU^v$~MHtcVEZyNSmTQh+8qA5C})i_FfjURuqIo)S9M5g1m k-k|>`q?Gqwb#KdH(yM&1qI)QF(?+TTkqJ)rO?KYEzt|`37ytkO literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/pop_collapse.png b/app/src/main/res/drawable/pop_collapse.png new file mode 100644 index 0000000000000000000000000000000000000000..1a580a9c385b33d4df66b4087f6743c79902ff80 GIT binary patch literal 2294 zcmVPx-ut`KgRCr$Pom;3?MHt8bUr~gRlo?bUE4zrO5Cy4FN@O8fQQ$eJ9ttW?mM^8p zlaLQ~c}OrXi&ZI7@fuB!CkDjsy4w zz)=8)0UQKy0Ki@mIaGwMOCB4klsW^z^#E=LFblwR02f$p>oEXd0@w~<7{G23`Ok74 zZ5NjtKuW1|0L+cv?%qgn^8k!%yLZV7_%(VFwnn3LGk|R(@@KN_Zl2B@kWz{xWdJ}Q zfb-pSGjXZE0oVlKRT0^pIJd%|(`H%5*}VZ$O5GLhv{nI_WZb%ZKKO04LtG{z z@A_z-b3jTd0(nvI$b9t4R~_8>EfNs|bC#R|QcCfg1{(FCyRBZk}~ON~wndtciZ-+HP#l%liYsQW1H@W;?9`(o@=M z0FT*hU;)co4`9gFlxGEyQi}SpEs@G(b^!(IqRF50J5NNY2W%^~4M-_PrRB#RuzhYz zkaaS1cXz9ZP)VCQwFO8iMXmCu04_`2g3iwPW2B6oBO=tIr%I^-X*i!n`l_B(HFr;@ zQ2;lkg_H`Ap4K?ehLL0TqH!e6PBYyl2BbFxUxYQsj7;n)u1Pl}-8duyq_@(agxy+V zIOPnSb#6(r1y2g-h47t|8csRmdJ%ctv=n1NT0^kGwBYbuQwc0I)i@Xd(z>-hU8ufs z^`22Jbe)Ort|cJ7?(kHxTNIzJpwyO21?!Af>yZhd6#$;`)#@&*=P40+wppK>0@8;^PH^T2c9Qg)9lc%6(Fi{Xqy(2I|&Sn$ec#SHw2_@2;Onmu26DJ0(UpEai|MO zTi@&t+5&Gc5w#3c>sbpm0?{6+qfQ=cgRGfOIP@y1rae_D_lfqH%lpFwMtnQTcF<19}s{ zefel#*SF%W$jIb=N}&Wu`-J|Sf!kr%lV~6Sx?^5QpV=}Xdbx3X9a4TIFjGW!l>yOB zgx+HzE3(kd1hTs&A3K%UZ)p(Q0a z<4;p`mQSyg48Al1StH+0+9k4mG-W4e=oh5$V8u~N*u zaYkkI#Ouuiq(*Sq&Ye3(N>j4O^aKIX1N%{rm7}pd;OQ0KjhkK~pbzxj zINli5u0gdGX91}hoW9J0R)AP<7LW=6hobqLfOyMbOB-$10DVF# z2po!BU06AfN(0Cgv_?t&DJ%WkO~T? zvdzxqL)B!{VFOZu;Z#0U-;eH$zTHG%Sv=DYq=LhtxJ#-Y@5<7opwmF=05}v=Nm=Us zxFIQ^q+9R~fx~W>jvKrm*rfqv3hBt`Xw^w*ysiwj(rLR~I}rX%Yiq@+(!s&HmkrjQB>hgho2t_o?m*a}jC;Sf-j;bLJ; ze;Yw6I2;11GW}&hrNSD|6VkxK1)w1$Q$_|JX_wS``KpcQ^*U5|_Y(r5m8xgcD4Sr) zWmUVMXh6Y%%Y~@Zm08z;ix(yZ7szx1wVA8F3#1-J!HL+WsMD2I--%cQVm!IvhM!Kb zwsJLg!`G0)MHaS1oxGgp7g=aP;VPKqg0+>y=qeaSXbhJr*%G;VIcAqB=~2YErd9(L znYXiRpESFUOatO53K#nMdsjGPO{o{BHC&154??q=l3j^eE=egBE|ql;Fx{oHda7d# z)Nn1cJ4o$a%d8=Vi_P5uYVTrm4Tu42Ludf|knDs8(2%YMKnv7vi;Zd@%c&CW1=ome z7#P4dgr~8C%x-v^7^D#3seM3h0zAdmqYxr3JtVgw(ljJS9S%VTlS6VJWH2_>(QQA3 z=`@GrFHC0)5~C1@K)0*I;8A_t;Zm7^BKqYYL|Pob?eD8%flH)2)TSP7U+P^PH9Z*_zU70XhQ|1I0r>dXjfq zxX)n#i<-qiO*&efZW3BGMu+`e)d7(uxqV*ND6D2HIVAnx`3QVv+9#XOWio>5bwGz`n+u>a=Im#=J0UQKyAR486MdVNld3n$BKmV$$+IjMb Q-v9sr07*qoM6N<$f;)g3NdN!< literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/record.png b/app/src/main/res/drawable/record.png new file mode 100644 index 0000000000000000000000000000000000000000..c92289fe883b407a5dd041f1e240517f240cb573 GIT binary patch literal 2660 zcmV-q3Y+zbP)Px<9!W$&RCr$PU0rh2MiA~E;Z)wTIYE{aU`~M7r1FYgIH}4La02iNz$YM_0P{o@ zIA!x5&~kz-Cx|#fHgBm~YdY!CXsy-GNHfw%t2O8WC|2|LP0w^s_ctwsuAcaO1aP?v zDUL*hw*X*7M7z4VG5?JK>hIDsCxE&BzXTBG2$+DBpC!;71k3<-5||w3?{qbSM)hK& zQl>)j+dUy+mjvDcf-wT*;00@?j%2haQ9RJ# zjXc{9{ra2`rYPy76gN&cCBc4x#^<9*E)Fz^d!Z2tSswvRNx}<=qG_Mtx&a!$9t+rc z3Iqqd7ls^o4|UA}@iz(LMIXRc19}DI!yX3SbrZb+YXvlV|K=f~cnW4s>@#@Q+B2=K zXCUQ2N?VhdLqujxElFvA0XPC7egL2mky3Iai)G-|A0&C4-u>m&r;zKr>ww0u$0&E6 zBdX=?`>>gFrFr4TEci0h<``QPAn+Cu>@@hqFH&jPVS}j`p7*(psz}e!GW+&+EpeOsxH*Nr?6ppd$YLDG)uYsBpW0 zEDc1wfBOUwS;1OOJ|y`lgrFes5~1`jTVq}9Ga)3wyAD>iv~yk|t%=+}y6IhSsQ^|= zCu_pHM~~VzGMMu5`3NbEZJ>sbQp68u+<-|lCK99Dq0NeogIl3|3E_e!M~7|th~mT9 z-=^g*ktRd&;ItW_sjNP~r#OV8LC!P5~<2Sp4#GeL06Kc7S zRsbq*!{nD)o7oy{*+0$>gn;v20R@CpIk`p(seqnMZ?Mz4GYM7g6CKODKmRsZNo&xt zDyyb@K(867bqAP!09Aqt7bK!bTdSlTJ!riFs#jJo4%!B**j_|A$iI0|bF5yL?;Fs( z;o$>&6=~oDiz!rXoP=oC=)5&C$)O-3>x%$W5dW%?O0ANs)G=%46oU>?%~fj5G+^ zVV7mUfU4H~LkdlR?YhjWgFN#%Z3xi9P+4NPfi;qMncZcNfZf!0`HTu7@E2lw+hg@wVazmOtax9#Sx&){w=UTlwg{Uhly3!?#ikf zh*I+D^Pt~VIv{0pu=LAT%yzklRLb!^l6H7xsO09xK=mJ@lYx0r9|p?ne7 z2hg0A2jUI^?E)$4qoP1ME`;pVmLHdWtcF4Z+YM+#)}153>b2~01fD9VkHf`?tps#a zC${CGp@J;8U4Z&gE`0>i_uWKFIEEjOF4pXXZ6P|;1=xHM3Gu!u)@GH^5X7kvon~(r zQp&8kLHP^{V_PBYS^))sL6F5N$ZWRxN+{FR`iROWE;f%0rjfD?{v9bNHXoJMC5cA7 z{UxYXfU<_05k`|xHkz-*YND*+M6+7cm4}J1C zU~7oLstz6mEKZz5Doza>UUGD@Gh?w9P+k2Th)(*X<L6snM<1_ zuhkte>_<-the81J&|SSXwh1kGaX9sbN_UZ6pv@m9L6nYh4)$l~+fH#nu9u0c&+?Au zLfa+8<(XJftj7V+;(pHFpm#3bAg{jiUJ2qckg`Lam@010ksg3;0ca5;b(LI2L7D!; z7kW)6BonIfo86r9!+BFMyH4%|;))XTfQmI-_eqJ?6I6tIYu|H_J;#$Ksn19=-zVGLIY_OLulP74bU& z8WS%yQhU>i^-#XfQf1hy#9Hq6;az&_`i{z2!Ttji*{sqIElZ4Ri=p1L0$RK>$;r0> zAB5O)s`_mro!N9xxFsJ7NnMWkp_=acTxMJC1dx zowe7N^qGn}|#?b$jlKZIzkcz^ct zfAX&}AK+jU5Y%{^5{dLExOb^;Td0dEv2Vbd0Bgeu2wdEEDuET!N$0?NWT`|EpS=V! zi?6(tx8bKiaqha=Fa7QUXkMg~FigX{qX>M$Amjvsre`jZ4m+0s)8=kZe^fU$t3FY6 zC32y2rDI}8$0rtf`A}!J^|_!>hu!jb z74#!|CB@}Maa_)TW_zmA{2Y__j{51IWQ2!2`G9hf`MJlO04>6@o}1Y>3HD0z?Yhhq zet;@ay;JYH&U&77fFqAfvO_t;tT&)ltt;~=f1(knYmxfuBmZ}U73B|T-hP&<~w^Qu=(7F4PcU3v80A#eMGCTl{fgn!{R9V9spcV#m<_`^*ciwea%l9ghW2!Dau@SMDNNcng;WzoJER46u~`bZm3RPJ7_@Fwov~S2oq-7C(0B?l`8JD z^>ah)2i2b$YMchHknex+fVKd&k#Kq!;;)Gn@|0`^plXm=W^cZx*wnw^ja5y7jN@N& zyl)SnJi3lgYtD#QT#4K+K$rf7&Kx&Ym@c`YRnC;{1k`X0)(WNm*08e}s_4Y*|4+%y zB+x6@K;`xWx&m(Y`QYq(b%G<9d{R?vY0Kq=+?A*a^~qrMx!`8s)B6vT-i;rT SQohds0000ualn1{8glgrI$*=58l z534XmLKCa>@^mAlby!a+W`GJ`+UFg7Q*C`f5+HQf&Y}`+YLCZG~a4W`#B41RI;I>GCfW} zvaqWQR;yTE{8prePDaz_Qn-lncjxTB;nm{yiRE)r{>%-fQ5bJ>+ME40=Rw1s-ibk( zt81IZ}-#E>iX)(HVHV;?FJG0?_6qddv@#>4Mnkx87bW&utIooWLa@Gpq z-5`-R{}tZ!nEa7jcK%!dnK&ji?K&Hf_Jcnbjk&w8f0Kz~e*84+CZ+#az>lPL#K2@u zi~JsGi=4jtav=cefr+pRU}wMy9jce2PuDL8eB;N_C)B#*oOq+C!rsb1g)jkpp<*{~ zH$k^#z}y^cJ$4I~XBRO#Q7?s9>D8xO>FFT}T-HVGm*mS5{`V#LGcdW*Yte(=omOj6 zj}0Pkfd9#TFUan5D0Ohi+I2nF$u;u;vdvo4FQI5(y(d1fpOu+=%x#?tUj4*`K_+aN zyX46|UF_}%$9G;B7kiSm;K$SKY_ZvQw+-cI){Bz^N6qXPZ_@;i447q-;h?;fNXNa6 zK^%Ga*zFOyBQ*vJnJ{M>XYK0N^<|?bq}DsZw1X-1$hqU2<>&+3tLwmdjy!B$%IezQ zcRMlK2Wlbaz2 z?a}O0`-+t?f4-0-{spio710h9$~MxHg1`tJi0h6Q57L!su1yUt)(!a^@ql5%ks`^N zT-0M)9Y;(7WZ{8bbv#d)Wwdd&I0m3%8kgP+wF5&O3gE#-qI5ziQb#wsn6$GCRb*Y_x4+cFNJ7$L;_hqHW)p zy0w#kd1{AYLMpu*^DKr*~?+pNxOx_f%xj zWnw4+_|!TC+ch&%YM`z@-$o%X&43@_heDFW-JMVj*#2ZZQ;f+9RH;vHMQBwv>BKP~~+ zSq{6y_1jm?@FyBi_9XW(Fud1?(HQaC4i>y&BGrlG1u+6}+Fr}r;J2QywqkK21- z=;FIjm;cy8npxVKK*xUXMa2ACwj>CWU1+<+PPM5**Vda%W}r5Nvp zTx+!=Fep)W_Zx4HRJ&-z)a)`;KCBUCkDs?Ff~REkvo+$V2LohE_~N86E`nWdS)`Q$ z^sLWU{PxNfr#!24x0DzQ)oJmG@u!?umU`kLx?`T___Lp#PI~b>pZ492>`>HYvgU_t z*A(74FH;#y&oZ40AJz=lOj>@(Nzt<+z)rNh_U6Vd?3Bm$+6YCS2Tcl9+lY1%(`FU$ zX~sZ-e1Pxo3NL&UDnCIL)hWU#-~7SDyN<@9n_?k;N~e_ZgsW@>e0T@dn>& P^(Kgj^*$^-6w3M+&BH`g literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/round_bg_others_already.xml b/app/src/main/res/drawable/round_bg_others_already.xml new file mode 100644 index 0000000..d440990 --- /dev/null +++ b/app/src/main/res/drawable/round_bg_others_already.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/round_bg_three.xml b/app/src/main/res/drawable/round_bg_three.xml index 082001b..10bd0cf 100644 --- a/app/src/main/res/drawable/round_bg_three.xml +++ b/app/src/main/res/drawable/round_bg_three.xml @@ -2,6 +2,6 @@ android:shape="rectangle"> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/round_bg_two.xml b/app/src/main/res/drawable/round_bg_two.xml index 662018a..0435c16 100644 --- a/app/src/main/res/drawable/round_bg_two.xml +++ b/app/src/main/res/drawable/round_bg_two.xml @@ -2,6 +2,6 @@ android:shape="rectangle"> - + diff --git a/app/src/main/res/drawable/second_add.png b/app/src/main/res/drawable/second_add.png new file mode 100644 index 0000000000000000000000000000000000000000..922be08803e881f7c723558e1ebdb112b0211a51 GIT binary patch literal 2735 zcmeHJ>pK$)9F{X3tw*Rtu4}0tTeq{wGnrWlvrI^Kax7gWLN!jA$!v;>h>hhgrl~Z! zHPP1M%#=3SbQVIFNioLeHZ(^c&R@{iKKy>~`{DQLectzdem4$dJq&>6Kph<&1GJ}` z-!iuU82y#Y`w@;^ybLR1{5XgAc6IQ&>qzpn+_!o67-xpG4QZ{E#z zLl#I+$7Z7$jBk`A_YB#LA`L;#?ZE|c_mxfTx?^0Ji$|x97Kb%;miw!rSv2yw%@>@C z4Ze8C2YH#x=?iu_iC)BBY$X#G<(DVM%&Q~nk$?8tUQQ4ag|g?Wp^^;b3?^}!&2(1q z)Xt*tL|O+!oz_)w%~ks-C5c*<5)&b~<4E$j&#W6(!hHO9(#~i#$z{$ZpI||dc36Ee z{L)28j&ypDBRQnbIqDTM)vLw$%62pgvggZCW64B+mt0kO8Bbib&#D?^GFrV&=!nku zb2lv?@D_wBV(Y0#9*{4JvSy>90m@CEGL zQ^5r7mVSPzaAMgfo!TeJp+~b*xUJY^tL_^~)Yz8pD{tbl)x7?h>62ViV6Z#H)6~mK zSf!pCNxbQePIWQ;hQpMoY4lBe_wvUMbYh?Z+_&OmH&lQ`2Z%cbRWm}Q@WtK)~ zPwl0gw!X%Gz*u$bL<@HkE8=@b&+^lf{AU*0rrkf3fSI4S8S*%u@M=; zs4-%4PdRjwI2OE}tX(Uv^Ev*Hu7Ru(Aa+}%0VP`^di(ftqmleH3>s7iGX<}>>ROCn zbKFM$d6IQPOiVv*4yNn?zoEz8L2!G^+=GE$r3le0A+4vOnpXOkLEl>4)u@D-nk`XH z=|F(Wo|l32Kg_WWi&fT}xfYDZ<)mhMfD8$D9AM4O5E@4`vpE92=6-5F4rXB8a1I!y1D%CP$h3%OmK+8=-Use zQ40swB%X-C0RP@-Njc8j{nmXjIBoIbSYR~#&DJO|rCIh~8K#wH?wu30cvp49V*tH@ z?`^(!r3mzz2Ms@-EvWYeB<7YEJwsY@d`+BsjsdK*Kl`GdZM`3m{B%-(aXtt)*g45T z;<=3%&d+2lPL9MiIF&Qj#(m5TBtKtRSXz}$a8dQ8?%L5dCc~5#m>CWTMqrcGwM=?` z;lQPyGl6Y$hOwUp0^R& zKL1YETSrYgZ34Ku(bfya2e1D{eu?lCPWaJJ71?5bdqp_`LYr;<-X*bUdXWrR4_FLw zGh)(VP0LNj4;_o8QHg*;lqb0&pK?e!l%bO=H%8&Yk+<=+JM3cI}d>_LhxWU$Gch+8w?+r9X4jV zeEw&!=auvxJ&P4E-Qj_I!t+vaT5 zg}h*n&&wDMVf>tJ$6=BNr$>ahhHcp%t;Y&X-pb;r=?m~7ZGWraSzP7=CQJnHrxP_D goS2epW<6hzPaU}@2lf#K%O9u?`XJVgy+4HdAI>#JHvj+t literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/third_add.png b/app/src/main/res/drawable/third_add.png new file mode 100644 index 0000000000000000000000000000000000000000..1e474ccffa4271c83609f496dadbbd0233f40dae GIT binary patch literal 2696 zcmeHJ`#aMM7-xOeY{|7!*>YJib(Rx{+{TvKre@5gh0r5vNKDGqBSzBIu$9m_X~}iD zbU~Y_a5(a0$>Xw+W{S-?<&w-_ zDMTzHQgcV0HKR2w@y4HbeM~DJ;AlmIZQ*yfC}D$sjLu2XBbNkX?TPH%qMN!F{UPqO zlhQEDa5jDL5N-a9LxC4j#2&h|H1ks)TU$dE&Tf|430L^SCCQJe;K7EKpGDwynEywQ z`0RYK=%OMS#VR^tQf$m;+VdI=yGvI$X_@b%N^E#e#O}8t*XBUiU|alHn;w6FIk<2- zrBkTp&<0xMxhyt-Xhe8mk|KuFj25i9Fw)$pqFzc$6wN|bC zd4=C~1Wx;}r&fL}W{ZTstojo!B*5z>T7}OB|T$2+o@ZK>OFU;4YRq!(7 zifw*bnI?q%&n>Y*&a@5aL#~*YFgJ{A9_GFQuot_Qtby>`OmKiws+;9JaRl4rN0?A6 zd|$^?x4bqBUOf#iV)x_;t2-vR`h|qbAufOf%`x#|56-Oj+b)fh_}(Np~&0Ip7Zxy=TXv7Zr*>M#C^M zs7t_NK0R>EVP+)lWl2=d1`Kof^ABg8rp-g1p*b5~#Wm?#V(qxv;bi`> zIpT4;>xFzj)xs|yxZ0EnY|+Wn{3Os`(9+y%W_4O)79@tbfv#&>WM0SsxMris+sjvg z+xmG)*>br_YB(TA^$M=bQKqSHfZYT5VX@t>d2h$3)2_+kdeplXe#*TNhs@hUNkhSq&rP z(K~l+7rRCIa>p9A}M+7`7R@~!>&F)ABQ?}pEWu!F0-KqFvsg>kK==(NunQ`BkQ{w0`n^X)_{R3B zy_5UNnik;aN78i=(zxXF2h{DRNO&ZedZ|IV-@|dq=*BdhXDq(!T$j#FInedJUr-;v zyiQf@o0dz{7DRGr<7-n81T?c%G9+Pi(t!34ZHyJ~L*?{BrZ2M`<}VC^l~UeDwwH`M z39xZbk3&Fa0>|!1{=xatNlqNYs=kNAZq(8_Wf$yMVn;Wao%HS_^h34Nmn=&II^Q+iK|w7D zTLYqQ$iB-P>6PyY&u|70cJQCLFDSY{*!&efP3sFtI2%B~IE)2cQUv8ph`M%0{A9&i4yuK!Yka{%4KJLxSt_JB$PMj?-SV=m?6VK%TgM=C*pw+0T0i{RHXa52FlU~^ w6;q>EyNgOJY58;^!G2|2)G$R!UZSspDsoQzDeIEHR=t*LM4YEvH8zy_KWPmwZU6uP literal 0 HcmV?d00001 diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index 9be21fc..280434d 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -80,9 +80,10 @@ android:src="@drawable/male" /> @@ -117,9 +118,10 @@ android:src="@drawable/female" /> @@ -161,6 +163,7 @@ android:src="@drawable/question_mark_one" /> - - + android:layout_width="60dp" + android:layout_marginTop="50dp" + android:layout_height="28dp" + android:background="@drawable/round_bg_two"> + + @@ -117,17 +119,19 @@ android:textSize="10sp" android:textColor="#1B1F1A" /> - + android:layout_height="28dp" + android:background="@drawable/round_bg_one"> + + @@ -172,17 +176,20 @@ android:textSize="10sp" android:textColor="#1B1F1A" /> - + android:layout_width="60dp" + android:layout_marginTop="50dp" + android:layout_height="28dp" + android:background="@drawable/round_bg_three"> + + diff --git a/app/src/main/res/layout/dialog_confirm_delete_character.xml b/app/src/main/res/layout/dialog_confirm_delete_character.xml new file mode 100644 index 0000000..e226d4a --- /dev/null +++ b/app/src/main/res/layout/dialog_confirm_delete_character.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/feedback_fragment.xml b/app/src/main/res/layout/feedback_fragment.xml index ece79e8..7685d00 100644 --- a/app/src/main/res/layout/feedback_fragment.xml +++ b/app/src/main/res/layout/feedback_fragment.xml @@ -69,14 +69,17 @@ + android:minLines="4" + android:maxLines="10" + android:scrollbars="vertical" + android:isScrollContainer="true" + android:nestedScrollingEnabled="true" /> + - - - - diff --git a/app/src/main/res/layout/fragment_consumption_record.xml b/app/src/main/res/layout/fragment_consumption_record.xml new file mode 100644 index 0000000..40acb23 --- /dev/null +++ b/app/src/main/res/layout/fragment_consumption_record.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml index 03938c7..71e6491 100644 --- a/app/src/main/res/layout/fragment_mine.xml +++ b/app/src/main/res/layout/fragment_mine.xml @@ -8,13 +8,13 @@ android:layout_height="match_parent" tools:context=".ui.home.HomeFragment"> - - + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_loading_footer.xml b/app/src/main/res/layout/item_loading_footer.xml new file mode 100644 index 0000000..882af55 --- /dev/null +++ b/app/src/main/res/layout/item_loading_footer.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_persona.xml b/app/src/main/res/layout/item_persona.xml index 21943f5..d839a8b 100644 --- a/app/src/main/res/layout/item_persona.xml +++ b/app/src/main/res/layout/item_persona.xml @@ -70,20 +70,23 @@ android:textColor="#02BEAC" android:textSize="10sp" /> - + android:background="@drawable/list_two_bg"> + + - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/item_rank_other.xml b/app/src/main/res/layout/item_rank_other.xml index 555f9c4..0169f3e 100644 --- a/app/src/main/res/layout/item_rank_other.xml +++ b/app/src/main/res/layout/item_rank_other.xml @@ -57,16 +57,17 @@ android:textColor="#9A9A9A" /> - + android:background="@drawable/round_bg_others"> + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_transaction_record.xml b/app/src/main/res/layout/item_transaction_record.xml new file mode 100644 index 0000000..2672418 --- /dev/null +++ b/app/src/main/res/layout/item_transaction_record.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/keyboard.xml b/app/src/main/res/layout/keyboard.xml index 32f7158..56f6b72 100644 --- a/app/src/main/res/layout/keyboard.xml +++ b/app/src/main/res/layout/keyboard.xml @@ -1,5 +1,6 @@ - + + android:overScrollMode="never" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + android:id="@+id/completion_HorizontalScrollView"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/my_keyboard.xml b/app/src/main/res/layout/my_keyboard.xml index b506bd0..0e682ef 100644 --- a/app/src/main/res/layout/my_keyboard.xml +++ b/app/src/main/res/layout/my_keyboard.xml @@ -56,7 +56,6 @@ android:textSize="16sp" /> - + android:orientation="vertical"> - - - - + - - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/layout/personal_settings.xml b/app/src/main/res/layout/personal_settings.xml index 26be5c1..2462eb6 100644 --- a/app/src/main/res/layout/personal_settings.xml +++ b/app/src/main/res/layout/personal_settings.xml @@ -96,6 +96,7 @@ android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/sheet_select_gender.xml b/app/src/main/res/layout/sheet_select_gender.xml new file mode 100644 index 0000000..536f0da --- /dev/null +++ b/app/src/main/res/layout/sheet_select_gender.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/navigation/global_graph.xml b/app/src/main/res/navigation/global_graph.xml index 25035c3..d2ba096 100644 --- a/app/src/main/res/navigation/global_graph.xml +++ b/app/src/main/res/navigation/global_graph.xml @@ -11,13 +11,6 @@ android:name="com.example.myapplication.ui.EmptyFragment" android:label="empty" /> - - - + + + + + + + + + + + + + + + + + + + , . Enter + 从相册选择 + 拍照 + 更换头像 + 取消 + 头像更新成功 + 上传失败 + 上传出错 diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..e1eb1c5 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + +