diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index b268ef3..3bf9052 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,6 +4,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/assets/keyboard_themes/default/Key_collapse.png b/app/src/main/assets/keyboard_themes/default/Key_collapse.png
index 6696970..68d5c27 100644
Binary files a/app/src/main/assets/keyboard_themes/default/Key_collapse.png and b/app/src/main/assets/keyboard_themes/default/Key_collapse.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/background.png b/app/src/main/assets/keyboard_themes/default/background.png
index c2a690d..d750125 100644
Binary files a/app/src/main/assets/keyboard_themes/default/background.png and b/app/src/main/assets/keyboard_themes/default/background.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_0.png b/app/src/main/assets/keyboard_themes/default/key_0.png
index 3f50ba7..35a70ea 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_0.png and b/app/src/main/assets/keyboard_themes/default/key_0.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_1.png b/app/src/main/assets/keyboard_themes/default/key_1.png
index a63ddbe..fcb29f1 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_1.png and b/app/src/main/assets/keyboard_themes/default/key_1.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_123.png b/app/src/main/assets/keyboard_themes/default/key_123.png
index 2742137..cc45ff2 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_123.png and b/app/src/main/assets/keyboard_themes/default/key_123.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_2.png b/app/src/main/assets/keyboard_themes/default/key_2.png
index dbc74b9..bdfcf3b 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_2.png and b/app/src/main/assets/keyboard_themes/default/key_2.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_3.png b/app/src/main/assets/keyboard_themes/default/key_3.png
index f38b8a3..1dc9644 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_3.png and b/app/src/main/assets/keyboard_themes/default/key_3.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_4.png b/app/src/main/assets/keyboard_themes/default/key_4.png
index ce0566b..e474d11 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_4.png and b/app/src/main/assets/keyboard_themes/default/key_4.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_5.png b/app/src/main/assets/keyboard_themes/default/key_5.png
index f1aecfe..ba2f53d 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_5.png and b/app/src/main/assets/keyboard_themes/default/key_5.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_6.png b/app/src/main/assets/keyboard_themes/default/key_6.png
index c76f7ac..3bc78a6 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_6.png and b/app/src/main/assets/keyboard_themes/default/key_6.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_7.png b/app/src/main/assets/keyboard_themes/default/key_7.png
index 20be3d8..df9bfe9 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_7.png and b/app/src/main/assets/keyboard_themes/default/key_7.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_8.png b/app/src/main/assets/keyboard_themes/default/key_8.png
index 6ee3dfc..018207a 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_8.png and b/app/src/main/assets/keyboard_themes/default/key_8.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_9.png b/app/src/main/assets/keyboard_themes/default/key_9.png
index 3459117..8b9a922 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_9.png and b/app/src/main/assets/keyboard_themes/default/key_9.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_a.png b/app/src/main/assets/keyboard_themes/default/key_a.png
index 76687e3..42e98b9 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_a.png and b/app/src/main/assets/keyboard_themes/default/key_a.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_a_up.png b/app/src/main/assets/keyboard_themes/default/key_a_up.png
index 2598472..049a9a2 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_a_up.png and b/app/src/main/assets/keyboard_themes/default/key_a_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_abc.png b/app/src/main/assets/keyboard_themes/default/key_abc.png
index a1923a6..e0424ad 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_abc.png and b/app/src/main/assets/keyboard_themes/default/key_abc.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_ai.png b/app/src/main/assets/keyboard_themes/default/key_ai.png
index e3cf382..b9812d7 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_ai.png and b/app/src/main/assets/keyboard_themes/default/key_ai.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_amp.png b/app/src/main/assets/keyboard_themes/default/key_amp.png
index 03538fa..264d14f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_amp.png and b/app/src/main/assets/keyboard_themes/default/key_amp.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_asterisk.png b/app/src/main/assets/keyboard_themes/default/key_asterisk.png
index 620ce9f..6986979 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_asterisk.png and b/app/src/main/assets/keyboard_themes/default/key_asterisk.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_at.png b/app/src/main/assets/keyboard_themes/default/key_at.png
index 00ca5ed..de261f9 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_at.png and b/app/src/main/assets/keyboard_themes/default/key_at.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_b.png b/app/src/main/assets/keyboard_themes/default/key_b.png
index ef6e890..d13aa06 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_b.png and b/app/src/main/assets/keyboard_themes/default/key_b.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_b_up.png b/app/src/main/assets/keyboard_themes/default/key_b_up.png
index bd5a787..8f470d3 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_b_up.png and b/app/src/main/assets/keyboard_themes/default/key_b_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_backslash.png b/app/src/main/assets/keyboard_themes/default/key_backslash.png
index aa8563c..a97e362 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_backslash.png and b/app/src/main/assets/keyboard_themes/default/key_backslash.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_backspace.png b/app/src/main/assets/keyboard_themes/default/key_backspace.png
deleted file mode 100644
index 129f1af..0000000
Binary files a/app/src/main/assets/keyboard_themes/default/key_backspace.png and /dev/null differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_brace_l.png b/app/src/main/assets/keyboard_themes/default/key_brace_l.png
index 5c28b8a..9338dbf 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_brace_l.png and b/app/src/main/assets/keyboard_themes/default/key_brace_l.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_brace_r.png b/app/src/main/assets/keyboard_themes/default/key_brace_r.png
index b1b2eb1..fb585c8 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_brace_r.png and b/app/src/main/assets/keyboard_themes/default/key_brace_r.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_bracket_l.png b/app/src/main/assets/keyboard_themes/default/key_bracket_l.png
index 1ec5367..dbc4d05 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_bracket_l.png and b/app/src/main/assets/keyboard_themes/default/key_bracket_l.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_bracket_r.png b/app/src/main/assets/keyboard_themes/default/key_bracket_r.png
index 300523d..fa633be 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_bracket_r.png and b/app/src/main/assets/keyboard_themes/default/key_bracket_r.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_bullet.png b/app/src/main/assets/keyboard_themes/default/key_bullet.png
index 3ac5c87..5a9549d 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_bullet.png and b/app/src/main/assets/keyboard_themes/default/key_bullet.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_c.png b/app/src/main/assets/keyboard_themes/default/key_c.png
index 63fe12c..998425b 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_c.png and b/app/src/main/assets/keyboard_themes/default/key_c.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_c_up.png b/app/src/main/assets/keyboard_themes/default/key_c_up.png
index d823d4f..95f2581 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_c_up.png and b/app/src/main/assets/keyboard_themes/default/key_c_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_caret.png b/app/src/main/assets/keyboard_themes/default/key_caret.png
index 073533a..a7d055f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_caret.png and b/app/src/main/assets/keyboard_themes/default/key_caret.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_colon.png b/app/src/main/assets/keyboard_themes/default/key_colon.png
index 83069a9..07004af 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_colon.png and b/app/src/main/assets/keyboard_themes/default/key_colon.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_comma.png b/app/src/main/assets/keyboard_themes/default/key_comma.png
index 641c7d6..89e8b0f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_comma.png and b/app/src/main/assets/keyboard_themes/default/key_comma.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_d.png b/app/src/main/assets/keyboard_themes/default/key_d.png
index ec4aac5..73e5f2d 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_d.png and b/app/src/main/assets/keyboard_themes/default/key_d.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_d_up.png b/app/src/main/assets/keyboard_themes/default/key_d_up.png
index 79188a0..fe70bd9 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_d_up.png and b/app/src/main/assets/keyboard_themes/default/key_d_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_del.png b/app/src/main/assets/keyboard_themes/default/key_del.png
index 129f1af..adbf8f7 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_del.png and b/app/src/main/assets/keyboard_themes/default/key_del.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_dollar.png b/app/src/main/assets/keyboard_themes/default/key_dollar.png
index f1ae8b9..e06c81f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_dollar.png and b/app/src/main/assets/keyboard_themes/default/key_dollar.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_dot.png b/app/src/main/assets/keyboard_themes/default/key_dot.png
index adb7bd2..f26e595 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_dot.png and b/app/src/main/assets/keyboard_themes/default/key_dot.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_e.png b/app/src/main/assets/keyboard_themes/default/key_e.png
index 1266671..22fa162 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_e.png and b/app/src/main/assets/keyboard_themes/default/key_e.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_e_up.png b/app/src/main/assets/keyboard_themes/default/key_e_up.png
index b7ea305..cdc5fcb 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_e_up.png and b/app/src/main/assets/keyboard_themes/default/key_e_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_emoji.png b/app/src/main/assets/keyboard_themes/default/key_emoji.png
new file mode 100644
index 0000000..299ac42
Binary files /dev/null and b/app/src/main/assets/keyboard_themes/default/key_emoji.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_equal.png b/app/src/main/assets/keyboard_themes/default/key_equal.png
index a6df242..fee04e4 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_equal.png and b/app/src/main/assets/keyboard_themes/default/key_equal.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_euro.png b/app/src/main/assets/keyboard_themes/default/key_euro.png
index dd2a727..bc34dd7 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_euro.png and b/app/src/main/assets/keyboard_themes/default/key_euro.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_exclam.png b/app/src/main/assets/keyboard_themes/default/key_exclam.png
index 9b13fe9..24a6266 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_exclam.png and b/app/src/main/assets/keyboard_themes/default/key_exclam.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_f.png b/app/src/main/assets/keyboard_themes/default/key_f.png
index bb41a4b..6be8401 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_f.png and b/app/src/main/assets/keyboard_themes/default/key_f.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_f_up.png b/app/src/main/assets/keyboard_themes/default/key_f_up.png
index 613b05c..8916620 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_f_up.png and b/app/src/main/assets/keyboard_themes/default/key_f_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_g.png b/app/src/main/assets/keyboard_themes/default/key_g.png
index 1e2023b..586b06e 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_g.png and b/app/src/main/assets/keyboard_themes/default/key_g.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_g_up.png b/app/src/main/assets/keyboard_themes/default/key_g_up.png
index 3885062..2fe71a8 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_g_up.png and b/app/src/main/assets/keyboard_themes/default/key_g_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_gt.png b/app/src/main/assets/keyboard_themes/default/key_gt.png
index 6aab0fb..cc10e4d 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_gt.png and b/app/src/main/assets/keyboard_themes/default/key_gt.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_h.png b/app/src/main/assets/keyboard_themes/default/key_h.png
index 97cc234..90076a4 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_h.png and b/app/src/main/assets/keyboard_themes/default/key_h.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_h_up.png b/app/src/main/assets/keyboard_themes/default/key_h_up.png
index 169b11c..ba77f87 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_h_up.png and b/app/src/main/assets/keyboard_themes/default/key_h_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_hash.png b/app/src/main/assets/keyboard_themes/default/key_hash.png
index 2dc2f72..14a1ed1 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_hash.png and b/app/src/main/assets/keyboard_themes/default/key_hash.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_i.png b/app/src/main/assets/keyboard_themes/default/key_i.png
index 77cded8..609bccb 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_i.png and b/app/src/main/assets/keyboard_themes/default/key_i.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_i_up.png b/app/src/main/assets/keyboard_themes/default/key_i_up.png
index c44dd63..4335b19 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_i_up.png and b/app/src/main/assets/keyboard_themes/default/key_i_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_j.png b/app/src/main/assets/keyboard_themes/default/key_j.png
index cf66ae5..c99d93e 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_j.png and b/app/src/main/assets/keyboard_themes/default/key_j.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_j_up.png b/app/src/main/assets/keyboard_themes/default/key_j_up.png
index 15221e9..cc535a2 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_j_up.png and b/app/src/main/assets/keyboard_themes/default/key_j_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_k.png b/app/src/main/assets/keyboard_themes/default/key_k.png
index 4c802e8..35d8693 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_k.png and b/app/src/main/assets/keyboard_themes/default/key_k.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_k_up.png b/app/src/main/assets/keyboard_themes/default/key_k_up.png
index 18b5ef2..2d60615 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_k_up.png and b/app/src/main/assets/keyboard_themes/default/key_k_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_l.png b/app/src/main/assets/keyboard_themes/default/key_l.png
index 3da5812..a3a95d6 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_l.png and b/app/src/main/assets/keyboard_themes/default/key_l.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_l_up.png b/app/src/main/assets/keyboard_themes/default/key_l_up.png
index 48d11fe..f85cbd5 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_l_up.png and b/app/src/main/assets/keyboard_themes/default/key_l_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_lt.png b/app/src/main/assets/keyboard_themes/default/key_lt.png
index 8094090..c97de96 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_lt.png and b/app/src/main/assets/keyboard_themes/default/key_lt.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_m.png b/app/src/main/assets/keyboard_themes/default/key_m.png
index 6b5af2c..7d9e583 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_m.png and b/app/src/main/assets/keyboard_themes/default/key_m.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_m_up.png b/app/src/main/assets/keyboard_themes/default/key_m_up.png
index b84d37c..4838732 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_m_up.png and b/app/src/main/assets/keyboard_themes/default/key_m_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_minus.png b/app/src/main/assets/keyboard_themes/default/key_minus.png
index c1912a3..1d16de5 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_minus.png and b/app/src/main/assets/keyboard_themes/default/key_minus.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_money.png b/app/src/main/assets/keyboard_themes/default/key_money.png
index 42220fe..d2c1d67 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_money.png and b/app/src/main/assets/keyboard_themes/default/key_money.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_n.png b/app/src/main/assets/keyboard_themes/default/key_n.png
index dab1f87..33f9cae 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_n.png and b/app/src/main/assets/keyboard_themes/default/key_n.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_n_up.png b/app/src/main/assets/keyboard_themes/default/key_n_up.png
index 1d88286..2cc14b3 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_n_up.png and b/app/src/main/assets/keyboard_themes/default/key_n_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_o.png b/app/src/main/assets/keyboard_themes/default/key_o.png
index 23cf2b9..c3e4a5b 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_o.png and b/app/src/main/assets/keyboard_themes/default/key_o.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_o_up.png b/app/src/main/assets/keyboard_themes/default/key_o_up.png
index c3231f9..6c26507 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_o_up.png and b/app/src/main/assets/keyboard_themes/default/key_o_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_p.png b/app/src/main/assets/keyboard_themes/default/key_p.png
index e93161b..a76ab9d 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_p.png and b/app/src/main/assets/keyboard_themes/default/key_p.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_p_up.png b/app/src/main/assets/keyboard_themes/default/key_p_up.png
index 70194e2..cbeb0db 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_p_up.png and b/app/src/main/assets/keyboard_themes/default/key_p_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_paren_l.png b/app/src/main/assets/keyboard_themes/default/key_paren_l.png
index 36f3555..7fc553a 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_paren_l.png and b/app/src/main/assets/keyboard_themes/default/key_paren_l.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_paren_r.png b/app/src/main/assets/keyboard_themes/default/key_paren_r.png
index 6ce9365..e4bbda1 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_paren_r.png and b/app/src/main/assets/keyboard_themes/default/key_paren_r.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_percent.png b/app/src/main/assets/keyboard_themes/default/key_percent.png
index a7943e2..aa5f800 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_percent.png and b/app/src/main/assets/keyboard_themes/default/key_percent.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_pipe.png b/app/src/main/assets/keyboard_themes/default/key_pipe.png
index 7025885..b089b4b 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_pipe.png and b/app/src/main/assets/keyboard_themes/default/key_pipe.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_plus.png b/app/src/main/assets/keyboard_themes/default/key_plus.png
index 4257687..8616e59 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_plus.png and b/app/src/main/assets/keyboard_themes/default/key_plus.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_pound.png b/app/src/main/assets/keyboard_themes/default/key_pound.png
index 9218ec7..bc2a560 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_pound.png and b/app/src/main/assets/keyboard_themes/default/key_pound.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_q.png b/app/src/main/assets/keyboard_themes/default/key_q.png
index f19cf50..9bdf87e 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_q.png and b/app/src/main/assets/keyboard_themes/default/key_q.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_q_up.png b/app/src/main/assets/keyboard_themes/default/key_q_up.png
index 5d4df5a..7ae4e97 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_q_up.png and b/app/src/main/assets/keyboard_themes/default/key_q_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_question.png b/app/src/main/assets/keyboard_themes/default/key_question.png
index bd2de25..5648e74 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_question.png and b/app/src/main/assets/keyboard_themes/default/key_question.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_quote.png b/app/src/main/assets/keyboard_themes/default/key_quote.png
index 9812639..357b3e3 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_quote.png and b/app/src/main/assets/keyboard_themes/default/key_quote.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_quote_d.png b/app/src/main/assets/keyboard_themes/default/key_quote_d.png
index ad314c5..b238bc2 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_quote_d.png and b/app/src/main/assets/keyboard_themes/default/key_quote_d.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_r.png b/app/src/main/assets/keyboard_themes/default/key_r.png
index 17eabf6..445c888 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_r.png and b/app/src/main/assets/keyboard_themes/default/key_r.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_r_up.png b/app/src/main/assets/keyboard_themes/default/key_r_up.png
index 8566458..d43c37b 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_r_up.png and b/app/src/main/assets/keyboard_themes/default/key_r_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_revoke.png b/app/src/main/assets/keyboard_themes/default/key_revoke.png
new file mode 100644
index 0000000..664827e
Binary files /dev/null and b/app/src/main/assets/keyboard_themes/default/key_revoke.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_s.png b/app/src/main/assets/keyboard_themes/default/key_s.png
index d585210..5024992 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_s.png and b/app/src/main/assets/keyboard_themes/default/key_s.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_s_up.png b/app/src/main/assets/keyboard_themes/default/key_s_up.png
index 96f8e59..73ca4ca 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_s_up.png and b/app/src/main/assets/keyboard_themes/default/key_s_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_semicolon.png b/app/src/main/assets/keyboard_themes/default/key_semicolon.png
index d154544..91c9351 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_semicolon.png and b/app/src/main/assets/keyboard_themes/default/key_semicolon.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_send.png b/app/src/main/assets/keyboard_themes/default/key_send.png
index f15aae1..36ae642 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_send.png and b/app/src/main/assets/keyboard_themes/default/key_send.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_slash.png b/app/src/main/assets/keyboard_themes/default/key_slash.png
index 8f28257..783deff 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_slash.png and b/app/src/main/assets/keyboard_themes/default/key_slash.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_space.png b/app/src/main/assets/keyboard_themes/default/key_space.png
index 4db597c..7218309 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_space.png and b/app/src/main/assets/keyboard_themes/default/key_space.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_symbols_123.png b/app/src/main/assets/keyboard_themes/default/key_symbols_123.png
index e2e9a5b..ab6dff5 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_symbols_123.png and b/app/src/main/assets/keyboard_themes/default/key_symbols_123.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_symbols_more.png b/app/src/main/assets/keyboard_themes/default/key_symbols_more.png
index a5b40cd..c4eeb19 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_symbols_more.png and b/app/src/main/assets/keyboard_themes/default/key_symbols_more.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_t.png b/app/src/main/assets/keyboard_themes/default/key_t.png
index f94a079..3038975 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_t.png and b/app/src/main/assets/keyboard_themes/default/key_t.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_t_up.png b/app/src/main/assets/keyboard_themes/default/key_t_up.png
index 1bfba2a..5203e0a 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_t_up.png and b/app/src/main/assets/keyboard_themes/default/key_t_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_tilde.png b/app/src/main/assets/keyboard_themes/default/key_tilde.png
index de61625..e7fdcac 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_tilde.png and b/app/src/main/assets/keyboard_themes/default/key_tilde.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_u.png b/app/src/main/assets/keyboard_themes/default/key_u.png
index dc06718..c8e147a 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_u.png and b/app/src/main/assets/keyboard_themes/default/key_u.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_u_up.png b/app/src/main/assets/keyboard_themes/default/key_u_up.png
index 1d2ba3c..8497786 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_u_up.png and b/app/src/main/assets/keyboard_themes/default/key_u_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_underscore.png b/app/src/main/assets/keyboard_themes/default/key_underscore.png
index b88b39d..2f3bcf0 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_underscore.png and b/app/src/main/assets/keyboard_themes/default/key_underscore.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_up.png b/app/src/main/assets/keyboard_themes/default/key_up.png
index 9b825cd..bfd31ed 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_up.png and b/app/src/main/assets/keyboard_themes/default/key_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_up_upper.png b/app/src/main/assets/keyboard_themes/default/key_up_upper.png
index 52cd704..f902943 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_up_upper.png and b/app/src/main/assets/keyboard_themes/default/key_up_upper.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_v.png b/app/src/main/assets/keyboard_themes/default/key_v.png
index b6dba48..c4bdb86 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_v.png and b/app/src/main/assets/keyboard_themes/default/key_v.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_v_up.png b/app/src/main/assets/keyboard_themes/default/key_v_up.png
index a3026fa..eddc674 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_v_up.png and b/app/src/main/assets/keyboard_themes/default/key_v_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_w.png b/app/src/main/assets/keyboard_themes/default/key_w.png
index ca77d4e..3146329 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_w.png and b/app/src/main/assets/keyboard_themes/default/key_w.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_w_up.png b/app/src/main/assets/keyboard_themes/default/key_w_up.png
index 2f37d3b..788ce01 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_w_up.png and b/app/src/main/assets/keyboard_themes/default/key_w_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_x.png b/app/src/main/assets/keyboard_themes/default/key_x.png
index baf8eec..439da77 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_x.png and b/app/src/main/assets/keyboard_themes/default/key_x.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_x_up.png b/app/src/main/assets/keyboard_themes/default/key_x_up.png
index 72d9946..7c80335 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_x_up.png and b/app/src/main/assets/keyboard_themes/default/key_x_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_y.png b/app/src/main/assets/keyboard_themes/default/key_y.png
index 3033c75..431575f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_y.png and b/app/src/main/assets/keyboard_themes/default/key_y.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_y_up.png b/app/src/main/assets/keyboard_themes/default/key_y_up.png
index 09bd13c..f39e85f 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_y_up.png and b/app/src/main/assets/keyboard_themes/default/key_y_up.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_z.png b/app/src/main/assets/keyboard_themes/default/key_z.png
index e757c11..49e400c 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_z.png and b/app/src/main/assets/keyboard_themes/default/key_z.png differ
diff --git a/app/src/main/assets/keyboard_themes/default/key_z_up.png b/app/src/main/assets/keyboard_themes/default/key_z_up.png
index ae0b2b0..504d06a 100644
Binary files a/app/src/main/assets/keyboard_themes/default/key_z_up.png and b/app/src/main/assets/keyboard_themes/default/key_z_up.png differ
diff --git a/app/src/main/java/com/example/myapplication/AppContext.kt b/app/src/main/java/com/example/myapplication/AppContext.kt
new file mode 100644
index 0000000..ab11a18
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/AppContext.kt
@@ -0,0 +1,12 @@
+package com.example.myapplication
+
+import android.content.Context
+
+object AppContext {
+ lateinit var context: Context
+ private set
+
+ fun init(ctx: Context) {
+ context = ctx.applicationContext
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/GuideActivity.kt b/app/src/main/java/com/example/myapplication/GuideActivity.kt
index 97c1a22..af4aa1f 100644
--- a/app/src/main/java/com/example/myapplication/GuideActivity.kt
+++ b/app/src/main/java/com/example/myapplication/GuideActivity.kt
@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import android.widget.ImageView
import android.text.TextWatcher
import android.text.Editable
+import com.example.myapplication.network.BehaviorReporter
class GuideActivity : AppCompatActivity() {
@@ -91,12 +92,22 @@ class GuideActivity : AppCompatActivity() {
}
// 情话复制
findViewById(R.id.love_words_1).setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "guide",
+ "element_id" to "copy_example_1",
+ )
val text = it as TextView
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("text", text.text))
Toast.makeText(this, "Copy successfully", Toast.LENGTH_SHORT).show()
}
findViewById(R.id.love_words_2).setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "guide",
+ "element_id" to "copy_example_2",
+ )
val text = it as TextView
val clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("text", text.text))
diff --git a/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt b/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt
index c54095e..ab1afd5 100644
--- a/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt
+++ b/app/src/main/java/com/example/myapplication/ImeGuideActivity.kt
@@ -12,6 +12,7 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
+import com.example.myapplication.network.BehaviorReporter
class ImeGuideActivity : AppCompatActivity() {
@@ -33,6 +34,11 @@ class ImeGuideActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_ime_guide)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_permission_guide",
+ )
+
Log.d(TAG, "onCreate")
btnEnable = findViewById(R.id.enabled) // btn启用输入法
@@ -69,6 +75,15 @@ class ImeGuideActivity : AppCompatActivity() {
}
}
+ override fun onBackPressed() {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_permission_guide",
+ "element_id" to "close_btn",
+ )
+ super.onBackPressed()
+ }
+
private fun registerImeObserver() {
if (imeObserver != null) return
@@ -153,6 +168,11 @@ class ImeGuideActivity : AppCompatActivity() {
selectLayout.setOnClickListener {
val imm = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
imm.showInputMethodPicker()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_permission_guide",
+ "element_id" to "open_settings_btn",
+ )
}
}
@@ -192,6 +212,11 @@ class ImeGuideActivity : AppCompatActivity() {
selectText.setTextColor(Color.parseColor("#A1A1A1"))
step1.text = "Completed"
step2.text = "You have completed the relevant Settings"
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_permission_guide",
+ "element_id" to "close_btn",
+ )
Toast.makeText(this, "The input method is all set!", Toast.LENGTH_SHORT).show()
try {
startActivity(Intent(this, GuideActivity::class.java))
diff --git a/app/src/main/java/com/example/myapplication/MainActivity.kt b/app/src/main/java/com/example/myapplication/MainActivity.kt
index 9afd912..d47bb31 100644
--- a/app/src/main/java/com/example/myapplication/MainActivity.kt
+++ b/app/src/main/java/com/example/myapplication/MainActivity.kt
@@ -1,6 +1,7 @@
package com.example.myapplication
import android.os.Bundle
+import android.util.Log
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
@@ -10,6 +11,7 @@ import androidx.navigation.NavController
import androidx.navigation.fragment.NavHostFragment
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
+import com.example.myapplication.network.BehaviorReporter
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.bottomnavigation.BottomNavigationView
import kotlinx.coroutines.flow.collectLatest
@@ -59,6 +61,45 @@ class MainActivity : AppCompatActivity() {
private val globalNavController: NavController
get() = globalHost.navController
+ // =======================
+ // 全局路由埋点:新增字段
+ // =======================
+ private val ROUTE_TAG = "RouteReport"
+
+ private var lastHomeDestIdForReport: Int? = null
+ private var lastShopDestIdForReport: Int? = null
+ private var lastMineDestIdForReport: Int? = null
+ private var lastGlobalDestIdForReport: Int? = null
+
+ // 统一 listener,方便 add/remove
+ private val homeRouteListener =
+ NavController.OnDestinationChangedListener { _, dest, _ ->
+ if (lastHomeDestIdForReport == dest.id) return@OnDestinationChangedListener
+ lastHomeDestIdForReport = dest.id
+ reportPageView(source = "home_tab", destId = dest.id)
+ }
+
+ private val shopRouteListener =
+ NavController.OnDestinationChangedListener { _, dest, _ ->
+ if (lastShopDestIdForReport == dest.id) return@OnDestinationChangedListener
+ lastShopDestIdForReport = dest.id
+ reportPageView(source = "shop_tab", destId = dest.id)
+ }
+
+ private val mineRouteListener =
+ NavController.OnDestinationChangedListener { _, dest, _ ->
+ if (lastMineDestIdForReport == dest.id) return@OnDestinationChangedListener
+ lastMineDestIdForReport = dest.id
+ reportPageView(source = "mine_tab", destId = dest.id)
+ }
+
+ private val globalRouteListener =
+ NavController.OnDestinationChangedListener { _, dest, _ ->
+ if (lastGlobalDestIdForReport == dest.id) return@OnDestinationChangedListener
+ lastGlobalDestIdForReport = dest.id
+ reportPageView(source = "global_overlay", destId = dest.id)
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -105,9 +146,11 @@ class MainActivity : AppCompatActivity() {
openGlobal(R.id.loginFragment)
}
}
+
is AuthEvent.GenericError -> {
- Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_SHORT).show()
+ Toast.makeText(this@MainActivity, event.message, Toast.LENGTH_LONG).show()
}
+
// 登录成功事件处理
is AuthEvent.LoginSuccess -> {
// 关闭 global overlay:回到 empty
@@ -123,24 +166,46 @@ class MainActivity : AppCompatActivity() {
}
}
pendingTabAfterLogin = null
+
+ // 处理intent跳转目标页
+ if (pendingNavigationAfterLogin == "recharge_fragment") {
+ openGlobal(R.id.rechargeFragment)
+ pendingNavigationAfterLogin = null
+ }
+
+ // ✅ 登录成功后也刷新一次
+ bottomNav.post { updateBottomNavVisibility() }
}
+
// 登出事件处理
is AuthEvent.Logout -> {
pendingTabAfterLogin = event.returnTabTag
-
+
// ✅ 用户没登录按返回,应回首页,所以先切到首页
switchTab(TAB_HOME, force = true)
-
+
bottomNav.post {
bottomNav.selectedItemId = R.id.home_graph
openGlobal(R.id.loginFragment) // ✅ 退出登录后立刻打开登录页
}
}
+
// 打开全局页面事件处理
is AuthEvent.OpenGlobalPage -> {
- // 打开指定的全局页面
openGlobal(event.destinationId, event.bundle)
}
+
+ is AuthEvent.UserUpdated -> {
+ // 不需要处理
+ }
+
+ is AuthEvent.CharacterDeleted -> {
+ // 不需要处理
+ }
+
+ is AuthEvent.CharacterAdded -> {
+ // 不需要处理,由HomeFragment处理
+ }
}
}
}
@@ -158,9 +223,27 @@ class MainActivity : AppCompatActivity() {
TAB_MINE -> R.id.mine_graph
else -> R.id.home_graph
}
+ updateBottomNavVisibility()
}
}
+ override fun onResume() {
+ super.onResume()
+ // ✅ 最终兜底:从后台回来 / 某些场景没触发 listener,也能恢复底栏
+ bottomNav.post { updateBottomNavVisibility() }
+ }
+
+ override fun onDestroy() {
+ // ✅ 防泄漏:移除路由监听(Activity 销毁时)
+ runCatching {
+ homeHost.navController.removeOnDestinationChangedListener(homeRouteListener)
+ shopHost.navController.removeOnDestinationChangedListener(shopRouteListener)
+ mineHost.navController.removeOnDestinationChangedListener(mineRouteListener)
+ globalHost.navController.removeOnDestinationChangedListener(globalRouteListener)
+ }
+ super.onDestroy()
+ }
+
private fun initHosts() {
val fm = supportFragmentManager
@@ -196,22 +279,59 @@ class MainActivity : AppCompatActivity() {
// 绑定全局导航可见性监听
bindGlobalVisibility()
-
+
// 绑定底部导航栏可见性监听
bindBottomNavVisibilityForTabs()
+
+ // ✅ 全局路由埋点监听(每次导航变化上报)
+ bindGlobalRouteReporting()
+
+ bottomNav.post { updateBottomNavVisibility() }
+ }
+
+ /**
+ * 这些页面需要隐藏底部导航栏:你按需加/减
+ */
+ private fun shouldHideBottomNav(destId: Int): Boolean {
+ return destId in setOf(
+ R.id.searchFragment,
+ R.id.searchResultFragment,
+ R.id.MySkin,
+ R.id.notificationFragment,
+ R.id.feedbackFragment,
+ R.id.MyKeyboard,
+ R.id.PersonalSettings,
+ )
+ }
+
+ /**
+ * ✅ 统一底栏显隐逻辑:任何地方状态变化都调用它
+ */
+ private fun updateBottomNavVisibility() {
+ // ✅ 只要 global overlay 不在 empty,底栏必须隐藏(用 NavController 判断,别用 View.visibility)
+ if (isGlobalVisible()) {
+ bottomNav.visibility = View.GONE
+ return
+ }
+
+ // 否则按“当前可见 tab 的当前目的地”判断
+ val destId = currentTabNavController.currentDestination?.id
+ bottomNav.visibility =
+ if (destId != null && shouldHideBottomNav(destId)) View.GONE else View.VISIBLE
}
private fun bindGlobalVisibility() {
globalNavController.addOnDestinationChangedListener { _, dest, _ ->
val isEmpty = dest.id == R.id.globalEmptyFragment
-
- findViewById(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 +340,17 @@ class MainActivity : AppCompatActivity() {
TAB_MINE -> R.id.mine_graph
else -> R.id.home_graph
}
- // 未登录且当前处在受保护tab:强制回首页
+
if (!isLoggedIn() && currentTabGraphId in protectedTabs) {
switchTab(TAB_HOME, force = true)
bottomNav.selectedItemId = R.id.home_graph
}
- // ✅ 只有"没登录就关闭登录页"才清 pending
if (!isLoggedIn()) {
pendingTabAfterLogin = null
}
+
+ bottomNav.post { updateBottomNavVisibility() }
}
}
}
@@ -238,7 +359,7 @@ class MainActivity : AppCompatActivity() {
if (!force && targetTag == currentTabTag) return
val fm = supportFragmentManager
- if (fm.isStateSaved) return // ✅ 防崩:stateSaved 时不做事务
+ if (fm.isStateSaved) return
currentTabTag = targetTag
@@ -255,57 +376,80 @@ class MainActivity : AppCompatActivity() {
}
}
.commit()
+
+ // ✅ 关键:hide/show 切 tab 不会触发 destinationChanged,所以手动刷新
+ bottomNav.post { updateBottomNavVisibility() }
+
+ // ✅ 新增:切 tab 后补一次路由上报(不改变其它逻辑)
+ if (!force) {
+ currentTabNavController.currentDestination?.id?.let { destId ->
+ reportPageView(source = "switch_tab", destId = destId)
+ }
+ }
}
/** 打开全局页(login/recharge等) */
private fun openGlobal(destId: Int, bundle: Bundle? = null) {
val fm = supportFragmentManager
- if (fm.isStateSaved) return // ✅ 防崩
+ if (fm.isStateSaved) return
try {
- if (bundle != null) {
- globalNavController.navigate(destId, bundle)
- } else {
- globalNavController.navigate(destId)
- }
+ if (bundle != null) globalNavController.navigate(destId, bundle)
+ else globalNavController.navigate(destId)
} catch (e: IllegalArgumentException) {
- // 可选:防止偶发重复 navigate 崩溃
e.printStackTrace()
}
+
+ bottomNav.post { updateBottomNavVisibility() }
}
- /** 关闭全局页:pop到 empty */
+ /** Tab 内页面变化时刷新底栏显隐 */
private fun bindBottomNavVisibilityForTabs() {
- fun shouldHideBottomNav(destId: Int): Boolean {
- return destId in setOf(
- R.id.searchFragment,
- R.id.searchResultFragment,
- R.id.MySkin
- // 你还有其他需要全屏的页,也加在这里
- )
+ val listener = NavController.OnDestinationChangedListener { _, _, _ ->
+ updateBottomNavVisibility()
}
- val listener = NavController.OnDestinationChangedListener { _, dest, _ ->
- // 只要 global overlay 打开了,仍然以 overlay 为准(你已有逻辑)
- if (isGlobalVisible()) return@OnDestinationChangedListener
- bottomNav.visibility = if (shouldHideBottomNav(dest.id)) View.GONE else View.VISIBLE
- }
-
homeHost.navController.addOnDestinationChangedListener(listener)
shopHost.navController.addOnDestinationChangedListener(listener)
mineHost.navController.addOnDestinationChangedListener(listener)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home",
+ )
+ }
+
+ // ✅ 绑定全局路由埋点(四个 NavController)
+ private fun bindGlobalRouteReporting() {
+ homeHost.navController.addOnDestinationChangedListener(homeRouteListener)
+ shopHost.navController.addOnDestinationChangedListener(shopRouteListener)
+ mineHost.navController.addOnDestinationChangedListener(mineRouteListener)
+ globalHost.navController.addOnDestinationChangedListener(globalRouteListener)
+
+ // ✅ 删除:初始化手动上报(否则启动时会重复上报)
+ // runCatching {
+ // currentTabNavController.currentDestination?.id?.let { reportPageView("init_current_tab", it) }
+ // globalNavController.currentDestination?.id?.let { reportPageView("init_global", it) }
+ // }
}
private fun closeGlobalIfPossible(): Boolean {
if (!isGlobalVisible()) return false
val popped = globalNavController.popBackStack()
- val stillVisible = globalNavController.currentDestination?.id != R.id.globalEmptyFragment
- return popped || stillVisible
+
+ // ✅ pop 后刷新一次(注意:currentDestination 可能要等一帧才更新,所以 post)
+ bottomNav.post { updateBottomNavVisibility() }
+
+ // popped = true 表示确实 pop 了;即使 popped=false 也可能已经在 empty 了
+ return popped || !isGlobalVisible()
}
+ /**
+ * ✅ 改这里:不要再用 View.visibility 判断 overlay
+ * 以 NavController 的目的地为准
+ */
private fun isGlobalVisible(): Boolean {
- return findViewById(R.id.global_container).visibility == View.VISIBLE
+ return globalNavController.currentDestination?.id != R.id.globalEmptyFragment
}
private fun setupBackPress() {
@@ -316,14 +460,15 @@ class MainActivity : AppCompatActivity() {
// 2) 再 pop 当前tab
val popped = currentTabNavController.popBackStack()
- if (popped) return
+ if (popped) {
+ bottomNav.post { updateBottomNavVisibility() }
+ return
+ }
// 3) 当前tab到根了:如果不是home,切回home;否则退出
- if (currentTabTag != TAB_HOME) {
- bottomNav.post {
- bottomNav.selectedItemId = R.id.home_graph
- }
- switchTab(TAB_HOME)
+ if (currentTabTag != TAB_HOME) {
+ bottomNav.post { bottomNav.selectedItemId = R.id.home_graph }
+ switchTab(TAB_HOME)
} else {
finish()
}
@@ -331,17 +476,25 @@ class MainActivity : AppCompatActivity() {
})
}
+ private var pendingNavigationAfterLogin: String? = null
+
private fun handleNavigationFromIntent() {
val navigateTo = intent.getStringExtra("navigate_to")
if (navigateTo == "recharge_fragment") {
bottomNav.post {
if (!isLoggedIn()) {
+ pendingNavigationAfterLogin = navigateTo
openGlobal(R.id.loginFragment)
- return@post
+ return@post
}
openGlobal(R.id.rechargeFragment)
}
}
+ if (navigateTo == "login_fragment") {
+ bottomNav.post {
+ openGlobal(R.id.loginFragment)
+ }
+ }
}
private fun isLoggedIn(): Boolean {
@@ -352,4 +505,68 @@ class MainActivity : AppCompatActivity() {
outState.putString("current_tab_tag", currentTabTag)
super.onSaveInstanceState(outState)
}
-}
\ No newline at end of file
+
+ // =======================
+ // 全局路由埋点:page_id 映射 + 上报
+ // =======================
+
+ private fun pageIdForDest(destId: Int): String {
+ return when (destId) {
+
+ /** ==================== 首页 Home ==================== */
+ R.id.homeFragment -> "home_main" // 首页-主页面
+ R.id.keyboardDetailFragment -> "skin_detail" // 键盘详情页
+ R.id.MyKeyboard -> "my_keyboard" // 键盘设置
+
+ /** ==================== 商城 Shop ==================== */
+ R.id.shopFragment -> "shop" // 商城首页
+ R.id.searchFragment -> "search" // 搜索页
+ R.id.searchResultFragment -> "search_result" // 搜索结果页
+ R.id.MySkin -> "my_skin" // 我的皮肤
+
+ /** ==================== 我的 Mine ==================== */
+ R.id.mineFragment -> "my" // 我的-首页
+ R.id.PersonalSettings -> "person_info" // 个人设置
+ R.id.notificationFragment -> "notice" // 消息通知
+ R.id.feedbackFragment -> "feedback" // 意见反馈
+ R.id.consumptionRecordFragment -> "consumption_record" // 消费记录
+
+ /** ==================== 登录 & 注册 ==================== */
+ R.id.loginFragment -> "login" // 登录页
+ R.id.registerFragment -> "register_email" // 注册页
+ R.id.registerVerifyFragment -> "register_verify_email" // 注册验证码
+ R.id.forgetPasswordEmailFragment -> "forgot_password_email" // 忘记密码-邮箱
+ R.id.forgetPasswordVerifyFragment -> "forgot_password_verify" // 忘记密码-验证码
+ R.id.forgetPasswordResetFragment -> "forgot_password_newpwd" // 忘记密码-重置密码
+
+ /** ==================== 充值相关 ==================== */
+ R.id.rechargeFragment -> "vip_pay" // 充值首页
+ R.id.goldCoinRechargeFragment -> "points_recharge" // 金币充值
+
+ /** ==================== 全局 / 占位 ==================== */
+ R.id.globalEmptyFragment -> "global_empty" // 全局占位页(兜底)
+
+ /** ==================== 兜底处理 ==================== */
+ else -> "unknown_$destId" // 未配置的页面,方便排查遗漏
+ }
+ }
+
+ private fun reportPageView(source: String, destId: Int) {
+ val pageId = pageIdForDest(destId)
+ if (destId == R.id.globalEmptyFragment) return
+ if (destId == R.id.loginFragment || destId == R.id.registerFragment){
+ BehaviorReporter.report(
+ isNewUser = true,
+ "page_id" to pageId,
+ )
+ return
+ }
+
+ Log.d(ROUTE_TAG, "route: source=$source destId=$destId page_id=$pageId")
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to pageId,
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/MyApp.kt b/app/src/main/java/com/example/myapplication/MyApp.kt
index 8df53e8..ac6c030 100644
--- a/app/src/main/java/com/example/myapplication/MyApp.kt
+++ b/app/src/main/java/com/example/myapplication/MyApp.kt
@@ -2,13 +2,16 @@ package com.example.myapplication
import android.app.Application
import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.network.NetworkClient
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
- // 初始化 RetrofitClient,传入 ApplicationContext
+ AppContext.init(this) // ✅ 新增:全局 Application Context
+
RetrofitClient.init(this)
+ NetworkClient.init(this) // ✅ SSE 用(带 token/签名拦截器)
}
}
diff --git a/app/src/main/java/com/example/myapplication/MyInputMethodService.kt b/app/src/main/java/com/example/myapplication/MyInputMethodService.kt
index 6530edb..6832e27 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
@@ -604,15 +590,19 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
if (aiKeyboard == null) {
aiKeyboard = AiKeyboard(this)
aiKeyboardView = aiKeyboard!!.rootView
+
+ // ✅ AI 键盘 Delete 按钮也绑定“长按连删 + 上滑清空”
+ val delId = resources.getIdentifier("keyboard_button_Delete", "id", packageName)
+ aiKeyboardView?.findViewById(delId)?.let { attachRepeatDeleteInternal(it) }
}
return aiKeyboard!!
}
-
+
override fun showMainKeyboard() {
clearEditorState()
val kb = ensureMainKeyboard()
currentKeyboardView = kb.rootView
- setInputView(kb.rootView)
+ setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -620,7 +610,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureNumberKeyboard()
currentKeyboardView = kb.rootView
- setInputView(kb.rootView)
+ setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -628,7 +618,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureSymbolKeyboard()
currentKeyboardView = kb.rootView
- setInputView(kb.rootView)
+ setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
@@ -636,18 +626,48 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
clearEditorState()
val kb = ensureAiKeyboard()
currentKeyboardView = kb.rootView
- setInputView(kb.rootView)
+ setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
+ kb.refreshPersonas()
}
override fun showEmojiKeyboard() {
clearEditorState()
val kb = ensureEmojiKeyboard()
currentKeyboardView = kb.rootView
- setInputView(kb.rootView)
+ setInputViewSafely(kb.rootView)
kb.applyTheme(currentTextColor, currentBorderColor, currentBackgroundColor)
}
+ override fun associateClose() {
+ clearEditorState()
+ val kb = ensureEmojiKeyboard()
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ // 先清理缓存,避免复用旧 View
+ currentKeyboardView = null
+
+ mainKeyboardView = null
+ numberKeyboardView = null
+ symbolKeyboardView = null
+ aiKeyboardView = null
+ emojiKeyboardView = null
+
+ mainKeyboard = null
+ numberKeyboard = null
+ symbolKeyboard = null
+ aiKeyboard = null
+ emojiKeyboard = null
+
+ super.onConfigurationChanged(newConfig)
+ }
+
+ private fun setInputViewSafely(v: View) {
+ (v.parent as? ViewGroup)?.removeView(v)
+ super.setInputView(v)
+ }
+
// Emoji 键盘
private fun ensureEmojiKeyboard(): com.example.myapplication.keyboard.EmojiKeyboard {
if (emojiKeyboard == null) {
@@ -943,7 +963,7 @@ class MyInputMethodService : InputMethodService(), KeyboardEnvironment {
// 新增:联想滚动条 & 控制面板
val completionScroll =
- mainKeyboardView?.findViewById(R.id.completion_scroll)
+ mainKeyboardView?.findViewById(R.id.completion_scroll)
val controlLayout =
mainKeyboardView?.findViewById(R.id.control_layout)
@@ -1006,7 +1026,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 +1422,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/SplashActivity.kt b/app/src/main/java/com/example/myapplication/SplashActivity.kt
index ebc24d2..b23f6c9 100644
--- a/app/src/main/java/com/example/myapplication/SplashActivity.kt
+++ b/app/src/main/java/com/example/myapplication/SplashActivity.kt
@@ -2,23 +2,54 @@ package com.example.myapplication
import android.content.Intent
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+// import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
+import com.example.myapplication.network.BehaviorReporter
class SplashActivity : AppCompatActivity() {
+ // private lateinit var progressBar: ProgressBar
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_splash)
- val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
- val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
- if (isFirstLaunch) {
- // 第一次启动 → 进入引导页
- startActivity(Intent(this, OnboardingActivity::class.java))
- } else {
- // 不是第一次 → 直接进入主界面
- startActivity(Intent(this, MainActivity::class.java))
- }
- finish()
+ // progressBar = findViewById(R.id.progressBar)
+
+ Handler(Looper.getMainLooper()).postDelayed({
+ val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
+ val isFirstLaunch = prefs.getBoolean("is_first_launch", true)
+ if(isFirstLaunch){
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "sex_select",
+ )
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "guide",
+ )
+ }
+
+ val targetIntent = if (isFirstLaunch) {
+ // 第一次启动 → 进入引导页
+ prefs.edit().putBoolean("is_first_launch", false).apply()
+ Intent(this, OnboardingActivity::class.java)
+ } else {
+ // 不是第一次 → 进入主界面,携带原始intent的参数
+ Intent(this, MainActivity::class.java).apply {
+ intent.extras?.let { putExtras(it) }
+ }
+ }
+ startActivity(targetIntent)
+ finish()
+ }, 1000) // 0.5秒延迟,确保初始化
+ }
+
+ override fun onDestroy() {
+ // progressBar.clearAnimation()
+ super.onDestroy()
}
}
diff --git a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt
index fa80317..8430982 100644
--- a/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt
+++ b/app/src/main/java/com/example/myapplication/keyboard/AiKeyboard.kt
@@ -1,24 +1,46 @@
package com.example.myapplication.keyboard
+import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
+import android.os.Handler
+import android.os.Looper
import android.util.TypedValue
import android.view.Gravity
import android.view.View
+import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
-import android.widget.TextView
-import com.example.myapplication.MainActivity
-import com.example.myapplication.theme.ThemeManager
-import android.os.Handler
-import android.os.Looper
import android.widget.ScrollView
-import com.example.myapplication.network.NetworkClient
+import android.widget.TextView
+import android.content.ClipboardManager
+import android.util.Log
+import android.widget.Toast
+import android.view.inputmethod.ExtractedTextRequest
+
+import com.example.myapplication.SplashActivity
import com.example.myapplication.network.LlmStreamCallback
+import com.example.myapplication.network.ListByUserWithNot
+import com.example.myapplication.network.ApiResponse
+import com.example.myapplication.network.NetworkClient
+import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.theme.ThemeManager
+import com.google.android.flexbox.FlexboxLayout
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import okhttp3.Call
+import kotlin.math.min
+import com.example.myapplication.network.BehaviorReporter
class AiKeyboard(
env: KeyboardEnvironment
@@ -26,7 +48,18 @@ class AiKeyboard(
private var currentStreamCall: Call? = null
private val mainHandler = Handler(Looper.getMainLooper())
+ private val uiScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ // ✅ 卡片选中的 characterId(SSE 参数)
+ private var selectedCharacterId: Int? = null
+
+ // ✅ Paste 得到的内容(SSE message)
+ private var lastPastedText: String? = null
+
+ // ✅ 记录“上次从卡片填入到输入框”的文本,用于覆盖
+ private var lastFilledText: String? = null
+
+ // 输出容器
private val messagesContainer: LinearLayout by lazy {
val res = env.ctx.resources
val id = res.getIdentifier("container_messages", "id", env.ctx.packageName)
@@ -39,15 +72,13 @@ class AiKeyboard(
rootView.findViewById(id)
}
- // 当前正在流式更新的那一个 AI 文本
private var currentAssistantTextView: TextView? = null
-
- // 用来处理 的缓冲
private val streamBuffer = StringBuilder()
-
- //新建一条 AI 消息(空内容),返回里面的 TextView 用来后续流式更新
-
+ /**
+ * ✅ inflate 一条消息(item_ai_message.xml)
+ * ✅ 点击卡片:把内容填入输入框,并覆盖上次填入内容
+ */
private fun addAssistantMessage(initialText: String = ""): TextView {
val inflater = env.layoutInflater
val res = env.ctx.resources
@@ -58,160 +89,440 @@ class AiKeyboard(
res.getIdentifier("tv_content", "id", env.ctx.packageName)
)
tv.text = initialText
- messagesContainer.addView(itemView)
+ // ✅ 点击整张卡片:把当前卡片内容填入输入框,并覆盖上次填入内容
+ itemView.setOnClickListener {
+ val text = tv.text?.toString().orEmpty()
+ if (text.isNotBlank()) {
+ fillToEditorOverwriteLast(text)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_StreamTextView",
+ "handle_label" to "handle_label",
+ "send_text" to text,
+ )
+ }
+ }
+
+ messagesContainer.addView(itemView)
scrollToBottom()
return tv
}
- /**
- * (可选)如果你也想显示用户提问
- */
- private fun addUserMessage(text: String) {
- // 简单写:复用同一个 item 布局
- val tv = addAssistantMessage(text)
- // 这里可以改成设置 gravity、背景区分用户/AI 等
- }
-
private fun scrollToBottom() {
- // 延迟一点点执行,保证 addView 完成后再滚动
messagesScrollView.post {
messagesScrollView.fullScroll(View.FOCUS_DOWN)
}
}
- //后端每来一个 llm_chunk 的 data,就调用一次这个方法
private fun onLlmChunk(data: String) {
- // 丢掉 data=":\n\n" 这条
if (data == ":\n\n") return
- // 确保在主线程更新 UI
mainHandler.post {
- // 如果还没有正在流式的 TextView,就新建一条 AI 消息
if (currentAssistantTextView == null) {
currentAssistantTextView = addAssistantMessage("")
streamBuffer.clear()
}
- // 累积到缓冲区
streamBuffer.append(data)
-
- // 先整体把 ":\n\n" 删掉(以防万一有别的地方混进来)
var text = streamBuffer.toString().replace(":\n\n", "")
- // 处理 :代表下一句/下一条消息
val splitTag = ""
var index = text.indexOf(splitTag)
while (index != -1) {
- // split 前面这一段是上一条消息的最终内容
val before = text.substring(0, index)
currentAssistantTextView?.text = before
scrollToBottom()
- // 开启下一条 AI 消息
currentAssistantTextView = addAssistantMessage("")
-
- // 剩下的留给下一轮
text = text.substring(index + splitTag.length)
index = text.indexOf(splitTag)
}
- // 循环结束后 text 就是「当前这条消息的未完成尾巴」
currentAssistantTextView?.text = text
scrollToBottom()
- // 缓冲区只保留尾巴(避免无限变长)
streamBuffer.clear()
streamBuffer.append(text)
}
}
-
- // 收到 type="done" 时调用,表示这一轮回答结束
private fun onLlmDone() {
mainHandler.post {
- // 这里目前不需要做太多事,必要的话可以清掉 buffer
streamBuffer.clear()
currentAssistantTextView = null
}
}
-
- // 开始一次新的 AI 回答流式请求
- fun startAiStream(userQuestion: String) {
- // 可选:先把用户问题显示出来
- addUserMessage(userQuestion)
+ // ✅ 关键:调用 POST /chat/talk 的 SSE,并渲染到输出区
+ private fun startTalkStream(characterId: Int, message: String) {
+ // 每次发起新对话:清空输出区(你如果想保留历史,把这行删掉)
+ messagesContainer.removeAllViews()
- // 如果之前有没结束的流,先取消
+ // 取消旧流
currentStreamCall?.cancel()
- currentStreamCall = NetworkClient.startLlmStream(
- question = userQuestion,
+ currentStreamCall = NetworkClient.startChatTalkStream(
+ characterId = characterId,
+ message = message,
callback = object : LlmStreamCallback {
override fun onEvent(type: String, data: String?) {
- when (type) {
- "llm_chunk" -> {
- if (data != null) {
- onLlmChunk(data) // 这里就是之前写的流式 UI 更新
+ guard("SSE.onEvent(type=$type)") {
+ when (type) {
+ "llm_chunk" -> if (data != null) onLlmChunk(data)
+ "done" -> onLlmDone()
+ else -> {
+ Log.d("AI_KB", "unknown event type=$type data=${data?.take(200)}")
}
}
- "done" -> {
- onLlmDone() // 一轮结束
- }
- "search_result" -> {
- }
}
}
override fun onError(t: Throwable) {
- addAssistantMessage("出错了:${t.message}")
+ // 尝试解析JSON错误响应
+ val errorResponse = try {
+ val errorJson = t.message?.let {
+ org.json.JSONObject(it)
+ }
+ if (errorJson != null) {
+ ApiResponse(
+ code = errorJson.optInt("code", 500),
+ message = errorJson.optString("message", "Unknown error"),
+ data = null
+ )
+ } else {
+ ApiResponse(
+ code = 500,
+ message = t.message ?: "Unknown error",
+ data = null
+ )
+ }
+ } catch (e: Exception) {
+ ApiResponse(
+ code = 500,
+ message = t.message ?: "Unknown error",
+ data = null
+ )
+ }
+
+ // SSE错误处理(如没有vip)
+ if (errorResponse.code == 50022) {
+ mainHandler.post {
+ Toast.makeText(env.ctx, errorResponse.message, Toast.LENGTH_LONG).show()
+ }
+ } else {
+ showErrorOnUi(errorResponse.message)
+ }
}
}
)
}
- // 比如键盘关闭时可以调用一次,避免内存泄漏 / 多余请求
fun cancelAiStream() {
currentStreamCall?.cancel()
currentStreamCall = null
}
-
-
- // 以下是 BaseKeyboard 的实现
override val rootView: View = run {
val res = env.ctx.resources
val layoutId = res.getIdentifier("ai_keyboard", "layout", env.ctx.packageName)
if (layoutId != 0) {
env.layoutInflater.inflate(layoutId, null) as View
} else {
- // 如果找不到布局,创建一个默认的View
LinearLayout(env.ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
- addView(TextView(env.ctx).apply {
- text = "AI Keyboard"
- })
+ addView(TextView(env.ctx).apply { text = "AI Keyboard" })
}
}
}
init {
- applyKeyBackground(rootView, "background")
- applyTheme(
- env.currentTextColor,
- env.currentBorderColor,
- env.currentBackgroundColor
- )
- setupListeners()
+ guard("AiKeyboard.init") {
+ applyKeyBackground(rootView, "background")
+ applyTheme(env.currentTextColor, env.currentBorderColor, env.currentBackgroundColor)
+ setupListeners()
+ loadPersonasAndRender()
+ }
}
-
- private fun applyKeyBackground(
- root: View,
- viewIdName: String,
- drawableName: String? = null
- ) {
+
+ // ========= UI 绑定 =========
+
+ private fun setupListeners() {
+ val res = env.ctx.resources
+ val pkg = env.ctx.packageName
+
+ val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
+ val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
+ val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null
+ val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null
+
+ aiPersonaView?.visibility = View.VISIBLE
+ aiOutputView?.visibility = View.GONE
+
+ // 返回主键盘
+ val backId = res.getIdentifier("key_abc", "id", pkg)
+ if (backId != 0) {
+ rootView.findViewById(backId)?.setOnClickListener {
+ env.showMainKeyboard()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_main_panel",
+ )
+ }
+ }
+
+ // VIP
+ val vipButtonId = res.getIdentifier("key_vip", "id", pkg)
+ if (vipButtonId != 0) {
+ rootView.findViewById(vipButtonId)?.setOnClickListener {
+ navigateToRechargeFragment()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_subscription_panel",
+ )
+ }
+ }
+
+ // Return:输出区 -> 人设区
+ val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
+ if (returnButtonId != 0) {
+ rootView.findViewById(returnButtonId)?.setOnClickListener {
+ aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
+ aiOutputView.visibility = View.GONE
+ aiPersonaView?.visibility = View.VISIBLE
+ aiPersonaView?.alpha = 0f
+ aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
+ }
+ }
+ }
+
+ // Paste:读取剪贴板第一条 -> 保存 lastPastedText,并显示到 completion_text
+ val pasteBtnId = res.getIdentifier("keyboard_button_Paste", "id", pkg)
+ val completionTextId = res.getIdentifier("completion_text", "id", pkg)
+ if (pasteBtnId != 0) {
+ rootView.findViewById(pasteBtnId)?.setOnClickListener {
+ val text = readClipboardFirstText()
+ lastPastedText = text
+
+ if (completionTextId != 0) {
+ rootView.findViewById(completionTextId)?.text =
+ if (text.isNullOrBlank()) "" else text
+ }
+ }
+ }
+
+ // HorizontalScrollView:点击事件与Paste按钮相同
+ val scrollViewId = res.getIdentifier("completion_container", "id", pkg)
+ if (scrollViewId != 0) {
+ rootView.findViewById(scrollViewId)?.setOnClickListener {
+ val text = readClipboardFirstText()
+ lastPastedText = text
+
+ if (completionTextId != 0) {
+ rootView.findViewById(completionTextId)?.text =
+ if (text.isNullOrBlank()) "" else text
+ }
+ }
+ }
+
+ // ✅ Delete:沿用 MyInputMethodService.deleteOne()
+ val deleteBtnId = res.getIdentifier("keyboard_button_Delete", "id", pkg)
+ if (deleteBtnId != 0) {
+ rootView.findViewById(deleteBtnId)?.setOnClickListener {
+ env.deleteOne() // 就是 MyInputMethodService.deleteOne()
+ lastFilledText = null // 可选:防止覆盖逻辑误删
+ }
+ }
+
+ // ✅ Send:沿用 MyInputMethodService.performSendAction()
+ val sendBtnId = res.getIdentifier("keyboard_button_Send", "id", pkg)
+ if (sendBtnId != 0) {
+ rootView.findViewById(sendBtnId)?.setOnClickListener {
+ env.performSendAction() // 就是 MyInputMethodService.performSendAction()
+ lastFilledText = null // 发送后不再尝试覆盖
+ }
+ }
+
+ // ✅ Clear:清空输入框
+ val clearBtnId = res.getIdentifier("keyboard_button_Clear", "id", pkg)
+ if (clearBtnId != 0) {
+ rootView.findViewById(clearBtnId)?.setOnClickListener {
+ clearEditorAll()
+ lastFilledText = null
+ }
+ }
+ }
+
+ // ========= 人设卡片:listByUser 渲染 =========
+
+ fun refreshPersonas() {
+ loadPersonasAndRender()
+ }
+
+ private fun loadPersonasAndRender() {
+ val res = env.ctx.resources
+ val pkg = env.ctx.packageName
+ val containerId = res.getIdentifier("persona_container", "id", pkg)
+ val container = if (containerId != 0) rootView.findViewById(containerId) else null
+ if (container == null) return
+
+ uiScope.launch {
+ try {
+ val resp = withContext(Dispatchers.IO) { RetrofitClient.apiService.listByUser() }
+
+ Log.d("1314520-AI_KB", "listByUser response: $resp")
+ if (resp.code == 0) {
+ val list = resp.data ?: emptyList()
+ renderPersonaCards(container, list)
+ } else if (resp.code == 40102) {
+ Toast.makeText(env.ctx, "You need to log in to use this function.", Toast.LENGTH_LONG).show()
+ } else {
+ Toast.makeText(env.ctx, resp.message, Toast.LENGTH_LONG).show()
+ }
+ } catch (_: Throwable) {
+ container.removeAllViews()
+ }
+ }
+ }
+
+ private fun renderPersonaCards(container: FlexboxLayout, list: List) {
+ container.removeAllViews()
+
+ val inflater = env.layoutInflater
+
+ list.forEach { item ->
+ val v = inflater.inflate(
+ com.example.myapplication.R.layout.item_ai_persona_card,
+ container,
+ false
+ )
+
+ val avatar = v.findViewById(
+ com.example.myapplication.R.id.avatar
+ )
+ val nameTv = v.findViewById(com.example.myapplication.R.id.name)
+
+ nameTv.text = item.characterName
+
+ val sizePx = (env.ctx.resources.displayMetrics.density * 20f).toInt()
+ avatar.setImageBitmap(emojiToBitmap(item.emoji, sizePx))
+
+ v.setOnClickListener {
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_function_panel",
+ "element_id" to "renshe_item",
+ "id" to item.characterId,
+ "name" to item.characterName,
+ )
+ // ✅ SSE 要的是 characterId,不是 id
+ selectedCharacterId = item.characterId
+
+ val res = env.ctx.resources
+ val pkg = env.ctx.packageName
+ val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
+ val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
+ val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null
+ val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null
+
+ // 获取消息内容:优先使用lastPastedText,其次读取剪贴板
+ val message = lastPastedText ?: readClipboardFirstText() ?: run {
+ addAssistantMessage("Please Paste the content of the clipboard first or make sure the clipboard is not empty")
+ return@setOnClickListener
+ }
+
+ aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
+ aiPersonaView.visibility = View.GONE
+ aiOutputView?.visibility = View.VISIBLE
+ aiOutputView?.alpha = 0f
+ aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
+ }
+
+ // 发送SSE请求
+ startTalkStream(item.characterId, message)
+ }
+
+ container.addView(v)
+ }
+ }
+
+ private fun emojiToBitmap(emoji: String, sizePx: Int): Bitmap {
+ val safeSize = min(sizePx.coerceAtLeast(16), 128)
+ val bmp = Bitmap.createBitmap(safeSize, safeSize, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bmp)
+
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ textAlign = Paint.Align.CENTER
+ typeface = Typeface.DEFAULT
+ textSize = safeSize * 0.8f
+ }
+ val fm = paint.fontMetrics
+ val x = safeSize / 2f
+ val y = safeSize / 2f - (fm.ascent + fm.descent) / 2f
+
+ canvas.drawText(emoji, x, y, paint)
+ return bmp
+ }
+
+ private fun readClipboardFirstText(): String? {
+ val cm = env.ctx.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return null
+ val clip = cm.primaryClip ?: return null
+ if (clip.itemCount <= 0) return null
+ return clip.getItemAt(0).coerceToText(env.ctx)?.toString()
+ }
+
+ /**
+ * ✅ 将卡片文本填入宿主输入框,并覆盖上一次“卡片填入”的内容
+ * 覆盖策略(安全版):
+ * - 仅当光标前的文本 == lastFilledText 时,才删除那段并覆盖
+ * - 否则就直接插入(避免误删用户手动输入的内容)
+ */
+ private fun fillToEditorOverwriteLast(newText: String) {
+ val ic = env.getInputConnection() ?: return
+
+ ic.beginBatchEdit()
+ try {
+ val prev = lastFilledText
+ if (!prev.isNullOrEmpty()) {
+ val before = ic.getTextBeforeCursor(prev.length, 0)?.toString()
+ if (before == prev) {
+ ic.deleteSurroundingText(prev.length, 0)
+ }
+ }
+ ic.commitText(newText, 1)
+ lastFilledText = newText
+ } finally {
+ ic.endBatchEdit()
+ }
+ }
+
+ /** ✅ 清空宿主输入框(当前编辑框) */
+ private fun clearEditorAll() {
+ val ic = env.getInputConnection() ?: return
+
+ val et = try {
+ ic.getExtractedText(ExtractedTextRequest(), 0)
+ } catch (_: Throwable) {
+ null
+ }
+ val full = et?.text?.toString().orEmpty()
+ if (full.isEmpty()) return
+
+ ic.beginBatchEdit()
+ try {
+ ic.setSelection(0, full.length)
+ ic.commitText("", 1)
+ } finally {
+ ic.endBatchEdit()
+ }
+ }
+
+ // ========= 换肤相关(保留你原有逻辑) =========
+
+ private fun applyKeyBackground(root: View, viewIdName: String, drawableName: String? = null) {
val res = env.ctx.resources
val viewId = res.getIdentifier(viewIdName, "id", env.ctx.packageName)
if (viewId == 0) return
@@ -250,125 +561,51 @@ class AiKeyboard(
}
}
-
- private fun setupListeners() {
- val res = env.ctx.resources
- val pkg = env.ctx.packageName
-
- // 获取ai_persona和ai_output视图引用
- val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
- val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
-
- val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null
- val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null
-
- // 初始化显示状态:显示ai_persona,隐藏ai_output
- aiPersonaView?.visibility = View.VISIBLE
- aiOutputView?.visibility = View.GONE
-
- // 如果 ai_keyboard.xml 里有 “返回主键盘” 的按钮,比如 key_abc,就绑定一下
- val backId = res.getIdentifier("key_abc", "id", pkg)
- if (backId != 0) {
- rootView.findViewById(backId)?.setOnClickListener {
- env.showMainKeyboard()
- }
- }
-
- // 绑定 VIP 按钮点击事件,跳转到充值页面
- val vipButtonId = res.getIdentifier("key_vip", "id", pkg)
- if (vipButtonId != 0) {
- rootView.findViewById(vipButtonId)?.setOnClickListener {
- navigateToRechargeFragment()
- }
- }
-
- //显示切换
- val returnButtonId = res.getIdentifier("Return_keyboard", "id", pkg)
- if (returnButtonId != 0) {
- rootView.findViewById(returnButtonId)?.let { returnButton ->
- // 确保按钮可点击且可获得焦点,防止事件穿透
- returnButton.isClickable = true
- returnButton.isFocusable = true
- returnButton.setOnClickListener {
- // 点击Return_keyboard:先隐藏ai_output,再显示ai_persona(顺序动画)
- aiOutputView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
- aiOutputView?.visibility = View.GONE
- // 等ai_output完全隐藏后再显示ai_persona
- aiPersonaView?.visibility = View.VISIBLE
- aiPersonaView?.alpha = 0f
- aiPersonaView?.animate()?.alpha(1f)?.setDuration(150)
- }
- }
- }
- }
-
- val cardButtonId = res.getIdentifier("card", "id", pkg)
- if (cardButtonId != 0) {
- rootView.findViewById(cardButtonId)?.setOnClickListener {
- // 点击card:先隐藏ai_persona,再显示ai_output(顺序动画)
- aiPersonaView?.animate()?.alpha(0f)?.setDuration(150)?.withEndAction {
- aiPersonaView?.visibility = View.GONE
- // 等ai_persona完全隐藏后再显示ai_output
- aiOutputView?.visibility = View.VISIBLE
- aiOutputView?.alpha = 0f
- aiOutputView?.animate()?.alpha(1f)?.setDuration(150)
- }
- }
- }
-
-
-
-
-
- // // 假设 ai_keyboard.xml 里有一个发送按钮 key_send
- // val sendId = res.getIdentifier("key_send", "id", pkg)
- // val inputId = res.getIdentifier("et_prompt", "id", pkg) // 假设这是你的输入框 id
-
- // if (sendId != 0 && inputId != 0) {
- // val inputView = rootView.findViewById(inputId)
-
- // rootView.findViewById(sendId)?.setOnClickListener {
- // val question = inputView?.text?.toString()?.trim().orEmpty()
- // if (question.isNotEmpty()) {
- // startAiStream(question)
- // }
- // }
- // }
- }
-
private fun navigateToRechargeFragment() {
try {
- val intent = Intent(env.ctx, MainActivity::class.java).apply {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
- putExtra("navigate_to", "recharge_fragment")
- }
+ val intent = Intent(env.ctx, SplashActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ intent.putExtra("navigate_to", "recharge_fragment")
env.ctx.startActivity(intent)
} catch (e: Exception) {
- // 如果启动失败,记录错误日志
- android.util.Log.e("AiKeyboard", "Failed to navigate to recharge fragment", e)
+ Log.e("AiKeyboard", "Failed to navigate to recharge fragment", e)
}
}
- override fun applyTheme(
- textColor: ColorStateList,
- borderColor: ColorStateList,
- backgroundColor: ColorStateList
- ) {
+ override fun applyTheme(textColor: ColorStateList, borderColor: ColorStateList, backgroundColor: ColorStateList) {
applyKeyBackgroundsForTheme()
}
- // ==============================刷新主题==================================
+
override fun applyKeyBackgroundsForTheme() {
- // 背景
applyKeyBackground(rootView, "background")
-
- // // AI 键盘上的功能键(按你现有 layout 里出现过的 id 来列)
- // val others = listOf(
- // "key_abc", // 返回主键盘
- // "key_vip", // VIP
- // "Return_keyboard", // 返回 persona 页
- // "card" // 切换到 output 页
- // // 如果后续 ai_keyboard.xml 里还有其它需要换肤的 key id,继续往这里加
- // )
- // others.forEach { applyKeyBackground(rootView, it) }
}
-}
\ No newline at end of file
+
+ private fun showErrorOnUi(title: String, t: Throwable? = null) {
+ val msg = if (t == null) title else "$title:${t.message ?: t.toString()}"
+ Log.e("AI_KB", msg, t)
+
+ mainHandler.post {
+ runCatching {
+ val res = env.ctx.resources
+ val pkg = env.ctx.packageName
+ val aiPersonaId = res.getIdentifier("ai_persona", "id", pkg)
+ val aiOutputId = res.getIdentifier("ai_output", "id", pkg)
+ val aiPersonaView = if (aiPersonaId != 0) rootView.findViewById(aiPersonaId) else null
+ val aiOutputView = if (aiOutputId != 0) rootView.findViewById(aiOutputId) else null
+ aiPersonaView?.visibility = View.GONE
+ aiOutputView?.visibility = View.VISIBLE
+
+ addAssistantMessage(msg)
+ }
+ }
+ }
+
+ /** 包一层 try-catch,避免任何地方异常直接白屏 */
+ private inline fun guard(tag: String, block: () -> Unit) {
+ try {
+ block()
+ } catch (t: Throwable) {
+ showErrorOnUi("发生异常($tag)", t)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt b/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt
index 174ba82..2ff5c5c 100644
--- a/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt
+++ b/app/src/main/java/com/example/myapplication/keyboard/BaseKeyboard.kt
@@ -65,8 +65,8 @@ abstract class BaseKeyboard(
protected fun applyBorderToAllKeyViews(root: View?) {
if (root == null) return
- val keyMarginPx = 1.dpToPx()
- val keyPaddingH = 6.dpToPx()
+ val keyMarginPx = 2.dpToPx()
+ val keyPaddingH = 8.dpToPx()
// 忽略 suggestion_0..20(联想栏)
val ignoredIds = HashSet().apply {
@@ -112,6 +112,12 @@ abstract class BaseKeyboard(
return (this * density + 0.5f).toInt()
}
+ /** dp -> px (float version) */
+ protected fun Float.dpToPx(): Int {
+ val density = env.ctx.resources.displayMetrics.density
+ return (this * density + 0.5f).toInt()
+ }
+
/** 按键震动 */
protected fun vibrateKey() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
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..e129254 100644
--- a/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt
+++ b/app/src/main/java/com/example/myapplication/keyboard/MainKeyboard.kt
@@ -15,7 +15,9 @@ import android.view.MotionEvent
import android.view.View
import android.widget.PopupWindow
import android.widget.TextView
+import android.widget.LinearLayout
import com.example.myapplication.theme.ThemeManager
+import com.example.myapplication.network.BehaviorReporter
class MainKeyboard(
env: KeyboardEnvironment,
@@ -145,6 +147,10 @@ class MainKeyboard(
view.findViewById(res.getIdentifier("key_ai", "id", pkg))?.setOnClickListener {
vibrateKey(); env.showAiKeyboard()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "keyboard_function_panel",
+ )
}
view.findViewById(res.getIdentifier("key_send", "id", pkg))?.setOnClickListener {
@@ -159,6 +165,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..34e67a9 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,42 @@ 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 +136,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 +216,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/BehaviorApiService.kt b/app/src/main/java/com/example/myapplication/network/BehaviorApiService.kt
new file mode 100644
index 0000000..d5604d7
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/network/BehaviorApiService.kt
@@ -0,0 +1,21 @@
+package com.example.myapplication.network
+
+import com.google.gson.JsonObject
+import retrofit2.Response
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+interface BehaviorApiService {
+
+ // 新用户
+ @POST("newAccount")
+ suspend fun reportNewUserBehavior(
+ @Body body: JsonObject
+ ): Response
+
+ // 老用户
+ @POST("genericData")
+ suspend fun reportGenericUserBehavior(
+ @Body body: JsonObject
+ ): Response
+}
diff --git a/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt b/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt
new file mode 100644
index 0000000..1f66c3b
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/network/BehaviorHttpClient.kt
@@ -0,0 +1,84 @@
+package com.example.myapplication.network
+
+import android.util.Log
+import okhttp3.Interceptor
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+object BehaviorHttpClient {
+
+ private const val TAG = "BehaviorHttp"
+
+ // TODO:改成你的行为服务 baseUrl(必须以 / 结尾)
+ private const val BASE_URL = "http://192.168.2.21:35310/api/"
+
+ /**
+ * 请求拦截器:打印请求信息
+ */
+ private val requestInterceptor = Interceptor { chain ->
+ val request = chain.request()
+
+ val bodyStr = request.body?.let { body ->
+ val buffer = okio.Buffer()
+ body.writeTo(buffer)
+ buffer.readUtf8()
+ }
+
+ Log.d(TAG, "201314-请求")
+ Log.d(TAG, "201314-URL: ${request.url}")
+ Log.d(TAG, "201314-Method: ${request.method}")
+ if (!bodyStr.isNullOrBlank()) {
+ Log.d(TAG, "201314-Body: $bodyStr")
+ }
+
+ chain.proceed(request)
+ }
+
+ /**
+ * 响应拦截器:打印响应信息
+ * ⚠️ response.body 只能读一次,这里使用 clone()
+ */
+ private val responseInterceptor = Interceptor { chain ->
+ val response = chain.proceed(chain.request())
+
+ val responseBody = response.body
+ val bodyStr = responseBody?.source()?.let { source ->
+ source.request(Long.MAX_VALUE)
+ source.buffer.clone().readUtf8()
+ }
+
+ Log.d(TAG, "201314-响应")
+ Log.d(TAG, "201314-URL: ${response.request.url}")
+ Log.d(TAG, "201314-Code: ${response.code}")
+ if (!bodyStr.isNullOrBlank()) {
+ Log.d(TAG, "201314-Body: $bodyStr")
+ }
+
+ response
+ }
+
+ private val okHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .writeTimeout(10, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .addInterceptor(requestInterceptor)
+ .addInterceptor(responseInterceptor)
+ .build()
+ }
+
+ private val retrofit: Retrofit by lazy {
+ Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ }
+
+ val service: BehaviorApiService by lazy {
+ retrofit.create(BehaviorApiService::class.java)
+ }
+}
diff --git a/app/src/main/java/com/example/myapplication/network/BehaviorReporter.kt b/app/src/main/java/com/example/myapplication/network/BehaviorReporter.kt
new file mode 100644
index 0000000..b2c24d2
--- /dev/null
+++ b/app/src/main/java/com/example/myapplication/network/BehaviorReporter.kt
@@ -0,0 +1,111 @@
+package com.example.myapplication.network
+
+import android.util.Log
+import com.example.myapplication.AppContext
+import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
+import com.google.gson.Gson
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+object BehaviorReporter {
+
+ private const val TAG = "BehaviorHttp"
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val gson = Gson()
+
+ /**
+ * 你只管调用这个方法即可:
+ *
+ * BehaviorReporter.report(
+ * isNewUser = true,
+ * "event" to "register_success",
+ * "page" to "home",
+ * "key" to "A",
+ * "count" to 1,
+ * "obj" to mapOf("a" to 1)
+ * )
+ *
+ * @param isNewUser true = newAccount,false = genericData
+ * @param extra 你想上报的任意字段,null/空字符串/空集合会被过滤掉
+ */
+ fun report(
+ isNewUser: Boolean,
+ vararg extra: Pair
+ ) {
+ scope.launch {
+ runCatching {
+
+ val user = EncryptedSharedPreferencesUtil.get(
+ AppContext.context,
+ "user",
+ LoginResponse::class.java
+ )
+ val token = user?.token.orEmpty()
+
+ // ✅ 用 JsonObject,避免 Retrofit 的 Map, ?> wildcard 报错
+ val body = JsonObject()
+
+ // token:非空才带(放 body 内)
+ if (token.isNotBlank()) body.addProperty("token", token)
+
+ // extra:你传什么都行,自动过滤无效值,并转成 JsonElement
+ extra.forEach { (k, v) ->
+ val element = v.toJsonElementOrNull() ?: return@forEach
+ body.add(k, element)
+ }
+
+ // 根据新/老用户走不同接口
+ if (isNewUser) {
+ BehaviorHttpClient.service.reportNewUserBehavior(body)
+ } else {
+ BehaviorHttpClient.service.reportGenericUserBehavior(body)
+ }
+ }.onFailure { e ->
+ Log.e(TAG, "201314-report failed: ${e.message}", e)
+ }
+ }
+ }
+
+ /**
+ * 把 Any? 转 JsonElement,并过滤掉你不想传的“空值”
+ * 支持:String/Number/Boolean/Char/Map/List/Set/以及其他对象(尽量 gson.toJsonTree)
+ */
+ private fun Any?.toJsonElementOrNull(): JsonElement? {
+ return when (this) {
+ null -> null
+
+ is String -> if (this.isBlank()) null else JsonPrimitive(this)
+ is Number -> JsonPrimitive(this)
+ is Boolean -> JsonPrimitive(this)
+ is Char -> JsonPrimitive(this.toString())
+
+ is Map<*, *> -> {
+ if (this.isEmpty()) return null
+ gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE }
+ }
+
+ is List<*> -> {
+ if (this.isEmpty()) return null
+ gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE } ?: JsonArray()
+ }
+
+ is Set<*> -> {
+ if (this.isEmpty()) return null
+ gson.toJsonTree(this).takeIf { it != JsonNull.INSTANCE }
+ }
+
+ else -> {
+ // 兜底:尽量序列化;失败就丢弃,避免影响主流程
+ runCatching { gson.toJsonTree(this) }.getOrNull()
+ ?.takeIf { it != JsonNull.INSTANCE }
+ }
+ }
+ }
+}
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..b3bf2a9 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/ 后面的部分
@@ -18,59 +27,95 @@ import android.content.Context
private val NO_LOGIN_REQUIRED_PATHS = setOf(
"/themes/listByStyle",
"/wallet/balance",
+ "/character/listByUser",
)
-private fun noLoginRequired(url: HttpUrl): Boolean {
- val path = url.encodedPath // 例:/api/home/banner
+private val NO_SIGN_REQUIRED_PATHS = setOf(
+ "/auth/login",
+)
- // 统一裁掉 /api 前缀
- val apiPath = path.substringAfter("/api", path)
-
- return NO_LOGIN_REQUIRED_PATHS.contains(apiPath)
+private fun apiPath(url: HttpUrl): String {
+ val path = url.encodedPath
+ return path.substringAfter("/api", path)
}
+private fun noLoginRequired(url: HttpUrl): Boolean =
+ NO_LOGIN_REQUIRED_PATHS.contains(apiPath(url))
+
+private fun noSignRequired(url: HttpUrl): Boolean =
+ NO_SIGN_REQUIRED_PATHS.contains(apiPath(url))
+
/**
* 请求拦截器:统一加 Header、token 等
*/
fun requestInterceptor(appContext: Context) = Interceptor { chain ->
val original = chain.request()
+ val url = original.url
val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
val token = user?.token.orEmpty()
- val newRequest = original.newBuilder()
+ val builder = original.newBuilder()
.apply {
- if (token.isNotBlank()) {
- addHeader("auth-token", "$token")
- }
+ if (token.isNotBlank()) addHeader("auth-token", token)
}
.addHeader("Accept-Language", "lang")
- .build()
- // ===== 打印请求信息 =====
- val request = newRequest
- val url = request.url
+ // ======= ✅ 按你规则加签名(header + query + body)=======
+ if (!noSignRequired(url)) {
+ val appId = "loveKeyboard"
+ val secret = "kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H" // TODO 正式环境建议下发/混淆/NDK
+ val timestamp = (System.currentTimeMillis() / 1000).toString()
+ val nonce = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
+
+ // 1) 合并成 Map(去掉 sign 本身)
+ val params = linkedMapOf()
+ params["appId"] = appId
+ params["timestamp"] = timestamp
+ params["nonce"] = nonce
+
+ // 2) query 参数
+ for (i in 0 until url.querySize) {
+ params[url.queryParameterName(i)] = url.queryParameterValue(i).orEmpty()
+ }
+
+ // 3) body 参数(json / form)
+ params.putAll(extractBodyParams(original))
+
+ // 4) 生成 sign
+ val sign = calcSign(params, secret)
+
+ builder
+ .addHeader("X-App-Id", appId)
+ .addHeader("X-Timestamp", timestamp)
+ .addHeader("X-Nonce", nonce)
+ .addHeader("X-Sign", sign)
+ }
+
+ val request = builder.build()
+
+ // ===== 打印请求信息(保留你原来的)=====
val sb = StringBuilder()
sb.append("\n======== HTTP Request ========\n")
sb.append("Method: ${request.method}\n")
- sb.append("URL: $url\n")
+ sb.append("URL: ${request.url}\n")
sb.append("Headers:\n")
for (name in request.headers.names()) {
sb.append(" $name: ${request.header(name)}\n")
}
- if (url.querySize > 0) {
+ if (request.url.querySize > 0) {
sb.append("Query Params:\n")
- for (i in 0 until url.querySize) {
- sb.append(" ${url.queryParameterName(i)} = ${url.queryParameterValue(i)}\n")
+ for (i in 0 until request.url.querySize) {
+ sb.append(" ${request.url.queryParameterName(i)} = ${request.url.queryParameterValue(i)}\n")
}
}
val requestBody = request.body
if (requestBody != null) {
- val buffer = okio.Buffer()
+ val buffer = Buffer()
requestBody.writeTo(buffer)
sb.append("Body:\n")
sb.append(buffer.readUtf8())
@@ -83,6 +128,132 @@ fun requestInterceptor(appContext: Context) = Interceptor { chain ->
chain.proceed(request)
}
+//
+// ================== 签名工具(严格按你描述规则) ==================
+//
+
+private fun calcSign(params: Map, 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 +292,7 @@ val responseInterceptor = Interceptor { chain ->
val gson = Gson()
val errorResponse = gson.fromJson(bodyString, ErrorResponse::class.java)
- if (errorResponse.code == 40102) {
+ if (errorResponse.code == 40102|| errorResponse.code == 40103) {
val isNoLoginApi = noLoginRequired(request.url)
Log.w(
@@ -134,14 +305,14 @@ val responseInterceptor = Interceptor { chain ->
AuthEventBus.emit(AuthEvent.TokenExpired(errorResponse.message))
}
- return@Interceptor response.newBuilder()
- .code(401)
- .message(
- if (isNoLoginApi) response.message
- else "Login required: ${errorResponse.message}"
- )
- .body(bodyString.toResponseBody(mediaType))
- .build()
+ // return@Interceptor response.newBuilder()
+ // .code(401)
+ // .message(
+ // if (isNoLoginApi) response.message
+ // else "Login required: ${errorResponse.message}"
+ // )
+ // .body(bodyString.toResponseBody(mediaType))
+ // .build()
}
// 其他非0的错误码,通过事件总线发送错误信息
else if (errorResponse.code!= 0) {
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..be6e683 100644
--- a/app/src/main/java/com/example/myapplication/network/Models.kt
+++ b/app/src/main/java/com/example/myapplication/network/Models.kt
@@ -71,18 +71,77 @@ data class User(
val email: String,
val emailVerified: Boolean,
val isVip: Boolean,
- val vipExpiry: String,
- val token: String
+ val vipExpiry: String?,
+ val token: String?,
)
//更新用户
data class updateInfoRequest(
- val uid: Long,
- val nickName: String,
- val gender: Int,
- val avatarUrl: String?,
+ val uid: Long? = null,
+ val nickName: String? = null,
+ val gender: Int? = null,
+ val avatarUrl: String? = null,
)
+//分页查询钱包交易记录
+data class transactionsRequest(
+ val pageNum: Int,
+ val pageSize: Int,
+)
+
+//分页查询钱包交易记录响应
+data class transactionsResponse(
+ val records: List,
+ 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,
+)
+
+data class FreeTrialQuota(
+ val effectiveQuota: Int,
+ val nacosQuota: Int,
+ val source: Int,
+)
// =======================================首页======================================
//标签列表
data class Tag(
@@ -115,7 +174,7 @@ data class listByTagWithNotLogin(
// 人设详情响应
data class CharacterDetailResponse(
- val id: Long? = null,
+ val id: Int? = null,
val characterName: String? = null,
val characterBackground: String? = null,
val avatarUrl: String? = null,
@@ -189,4 +248,4 @@ data class deleteThemeRequest(
//购买主题
data class purchaseThemeRequest(
val themeId: Int,
-)
\ No newline at end of file
+)
diff --git a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt
index 97fae86..d2d25f9 100644
--- a/app/src/main/java/com/example/myapplication/network/NetworkClient.kt
+++ b/app/src/main/java/com/example/myapplication/network/NetworkClient.kt
@@ -1,116 +1,406 @@
package com.example.myapplication.network
+import android.content.Context
+import android.util.Log
import okhttp3.*
+import okio.BufferedSource
import okio.Buffer
import org.json.JSONObject
import java.io.IOException
+import java.net.URLDecoder
+import java.net.URLEncoder
+import java.util.UUID
import java.util.concurrent.TimeUnit
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
+import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
object NetworkClient {
- // 你自己后端的 base url
private const val BASE_URL = "http://192.168.2.21:7529/api"
+ private const val TAG = "999-SSE_TALK"
+
+ // ====== 按你给的规则固定值 ======
+ private const val APP_ID = "loveKeyboard"
+ private const val SECRET = "kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"
+
+ @Volatile
+ private lateinit var appContext: Context
+
+ fun init(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ private fun debugLoggingInterceptor(): Interceptor = Interceptor { chain ->
+ val req = chain.request()
+ Log.d("999-HTTP", ">>> ${req.method} ${req.url}")
+ Log.d("999-HTTP", ">>> headers:\n${req.headers}")
+ val resp = chain.proceed(req)
+ Log.d("999-HTTP", "<<< code=${resp.code} ct=${resp.header("Content-Type")} len=${resp.header("Content-Length")}")
+ Log.d("999-HTTP", "<<< headers:\n${resp.headers}")
+ resp
+ }
- // 专门用于 SSE 的 OkHttpClient:readTimeout = 0 代表不超时,一直保持连接
private val sseClient: OkHttpClient by lazy {
+ check(::appContext.isInitialized) {
+ "NetworkClient not initialized. Call NetworkClient.init(context) first."
+ }
+
OkHttpClient.Builder()
- .readTimeout(0, TimeUnit.MILLISECONDS) // SSE 必须不能有读超时
+ .readTimeout(0, TimeUnit.MILLISECONDS)
+ .addInterceptor(debugLoggingInterceptor())
.build()
}
- /**
- * 启动一次 SSE 流式请求
- * @param question 用户问题(你要传给后端的)
- * @return Call,可用于取消(比如用户关闭键盘时)
- */
- fun startLlmStream(
- question: String,
+ fun startChatTalkStream(
+ characterId: Int,
+ message: String,
callback: LlmStreamCallback
): Call {
- // 根据你后端的接口改:是 POST 还是 GET,参数格式是什么
+ Log.d(TAG, "POST /chat/talk send -> characterId=$characterId, message=${message.take(200)}")
+
val json = JSONObject().apply {
- put("query", question) // 假设你后端字段叫 query
+ put("characterId", characterId)
+ put("message", message)
}
val requestBody = json.toString()
.toRequestBody("application/json; charset=utf-8".toMediaType())
- val request = Request.Builder()
- .url("$BASE_URL/llm/stream") // TODO: 换成你真实的 SSE 路径
+ val baseRequest = Request.Builder()
+ .url("$BASE_URL/chat/talk")
.post(requestBody)
- // 有些 SSE 接口会要求 Accept
- .addHeader("Accept", "text/event-stream")
.build()
- val call = sseClient.newCall(request)
+ // ✅ 在这里:按你提供的规则生成签名并加 header
+ val signedRequest = signRequest(baseRequest)
+
+ Log.d(TAG, "before newCall")
+ val call = sseClient.newCall(signedRequest)
+ Log.d(TAG, "after newCall -> enqueue")
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
- if (call.isCanceled()) return // 被主动取消就不用回调错误了
+ if (call.isCanceled()) {
+ Log.w(TAG, "canceled: $call")
+ return
+ }
+ Log.e(TAG, "onFailure: ${e.javaClass.name}: ${e.message}", e)
callback.onError(e)
}
override fun onResponse(call: Call, response: Response) {
+ Log.d(TAG, "onResponse -> $response")
+ Log.d(TAG, "resp headers -> ${response.headers}")
+
+ val body = response.body
+ val contentType = body?.contentType()?.toString().orEmpty()
+ val isSse = contentType.contains("text/event-stream", ignoreCase = true)
+
if (!response.isSuccessful) {
- callback.onError(IOException("SSE failed: ${response.code}"))
+ val err = peekOrReadBody(response)
+ Log.e(TAG, "HTTP failed: code=${response.code}, contentType=$contentType, body=$err")
+ callback.onError(IOException(err))
response.close()
return
}
- val body = response.body ?: run {
+ if (!isSse) {
+ val text = peekOrReadBody(response)
+ Log.w(TAG, "Not SSE response: contentType=$contentType, body=$text")
+ callback.onError(IOException(text))
+ response.close()
+ return
+ }
+
+ if (body == null) {
callback.onError(IOException("Empty body"))
return
}
- // 长连接读取:一行一行读,直到服务器关闭或我们取消
body.use { b ->
val source = b.source()
try {
- while (!source.exhausted() && !call.isCanceled()) {
- val line = source.readUtf8Line() ?: break
- if (line.isBlank()) {
- // SSE 中空行代表一个 event 结束,这里可以忽略
- continue
- }
-
- // 兼容两种格式:
- // 1) 标准 SSE: "data: { ... }"
- // 2) 服务器直接一行一个 JSON: "{ ... }"
- val payload = if (line.startsWith("data:")) {
- line.substringAfter("data:").trim()
- } else {
- line.trim()
- }
-
- // 你日志里是:
- // {"type":"llm_chunk","data":"Her"}
- // {"type":"done","data":null}
- try {
- val jsonObj = JSONObject(payload)
- val type = jsonObj.optString("type")
- val data =
- if (jsonObj.has("data") && !jsonObj.isNull("data"))
- jsonObj.getString("data")
- else
- null
-
- callback.onEvent(type, data)
- } catch (e: Exception) {
- // 解析失败就忽略这一行(或者你可以打印下日志)
- // Log.e("NetworkClient", "Bad SSE line: $payload", e)
- }
- }
+ readSseStream(source, call, callback)
} catch (ioe: IOException) {
if (!call.isCanceled()) {
+ Log.e(TAG, "read error: ${ioe.message}", ioe)
callback.onError(ioe)
}
+ } catch (t: Throwable) {
+ Log.e(TAG, "unexpected error: ${t.message}", t)
+ callback.onError(t)
}
}
}
})
+ Log.d(TAG, "after enqueue")
return call
}
+
+ /**
+ * ✅ 按你给的规则生成:
+ * - timestamp = 秒
+ * - nonce = UUID 去 '-' 后取 16 位
+ * - params = appId/timestamp/nonce + query + body(flatten)
+ * - sign = calcSign(params, secret)
+ * - headers: X-App-Id / X-Timestamp / X-Nonce / X-Sign
+ * - token: auth-token(如果有)
+ */
+ private fun signRequest(original: Request): Request {
+ val url = original.url
+
+ // 你原代码里 token 从 EncryptedSharedPreferencesUtil.get(...) 取
+ // 这里保持一致:如果你项目里有这工具类就用它;没有就把 token 获取替换成你自己的方式
+ val token = try {
+ val user = EncryptedSharedPreferencesUtil.get(appContext, "user", LoginResponse::class.java)
+ user?.token.orEmpty()
+ } catch (_: Throwable) {
+ ""
+ }
+
+ val timestamp = (System.currentTimeMillis() / 1000).toString()
+ val nonce = UUID.randomUUID().toString().replace("-", "").take(16)
+
+ val params = linkedMapOf()
+ params["appId"] = APP_ID
+ params["timestamp"] = timestamp
+ params["nonce"] = nonce
+
+ // query 参数
+ for (i in 0 until url.querySize) {
+ params[url.queryParameterName(i)] = url.queryParameterValue(i).orEmpty()
+ }
+
+ // body 参数(json / form)
+ params.putAll(extractBodyParams(original))
+
+ val sign = calcSign(params, SECRET)
+
+ val builder = original.newBuilder()
+ .addHeader("Accept-Language", "lang")
+ .addHeader("X-App-Id", APP_ID)
+ .addHeader("X-Timestamp", timestamp)
+ .addHeader("X-Nonce", nonce)
+ .addHeader("X-Sign", sign)
+ .apply {
+ if (token.isNotBlank()) addHeader("auth-token", token)
+ }
+
+ val request = builder.build()
+
+ // ✅ 打印签名相关(避免泄露:sign/secret别全量打印到线上)
+ Log.d(TAG, "signed -> X-App-Id=$APP_ID X-Timestamp=$timestamp X-Nonce=$nonce X-Sign=${sign.take(16)}... tokenPresent=${token.isNotBlank()}")
+ return request
+ }
+
+ // ==================== 签名计算(严格按你给的规则) ====================
+
+ private fun calcSign(params: Map, secret: String): String {
+ val filtered = params
+ .filter { (k, v) -> v.isNotBlank() && !k.equals("sign", ignoreCase = true) }
+
+ val sorted = filtered.toSortedMap()
+
+ val sb = StringBuilder()
+ sorted.forEach { (k, v) ->
+ if (sb.isNotEmpty()) sb.append("&")
+ sb.append(k).append("=").append(urlEncode(v))
+ }
+ sb.append("&secret=").append(urlEncode(secret))
+
+ return hmacSha256Hex(sb.toString(), secret)
+ }
+
+ private fun hmacSha256Hex(data: String, secret: String): String {
+ val mac = Mac.getInstance("HmacSHA256")
+ val keySpec = SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")
+ mac.init(keySpec)
+ val bytes = mac.doFinal(data.toByteArray(Charsets.UTF_8))
+ return bytes.joinToString("") { "%02x".format(it) }
+ }
+
+ private fun urlEncode(v: String): String =
+ URLEncoder.encode(v, "UTF-8")
+
+ // ==================== body 参数提取:json / form ====================
+
+ private fun extractBodyParams(request: Request): Map {
+ val body = request.body ?: return emptyMap()
+ val ct = body.contentType()?.toString()?.lowercase().orEmpty()
+
+ return when {
+ ct.contains("application/json") -> extractJsonBody(body)
+ ct.contains("application/x-www-form-urlencoded") -> extractFormBody(body)
+ else -> emptyMap()
+ }
+ }
+
+ private fun extractJsonBody(body: RequestBody): Map {
+ val raw = bodyToString(body).trim()
+ if (raw.isBlank()) return emptyMap()
+
+ return try {
+ // 这里只支持 JSON object/array 的扁平化;primitive 忽略
+ val root = org.json.JSONTokener(raw).nextValue()
+ val out = linkedMapOf()
+ flattenJsonAny(root, "", out)
+ out
+ } catch (_: Throwable) {
+ emptyMap()
+ }
+ }
+
+ private fun extractFormBody(body: 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)
+ map[k] = URLDecoder.decode(v, "UTF-8")
+ } else {
+ map[pair] = ""
+ }
+ }
+ return map
+ }
+
+ private fun bodyToString(body: RequestBody): String {
+ return try {
+ val buffer = Buffer()
+ body.writeTo(buffer)
+ val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
+ buffer.readString(charset)
+ } catch (_: Throwable) {
+ ""
+ }
+ }
+
+ /**
+ * JSON 扁平化规则(尽量与你给的 gson flatten 保持一致):
+ * object: a.b.c
+ * array : items[0].id
+ */
+ private fun flattenJsonAny(any: Any?, prefix: String, out: MutableMap) {
+ when (any) {
+ null -> Unit
+ is JSONObject -> {
+ val keys = any.keys()
+ while (keys.hasNext()) {
+ val k = keys.next()
+ val newKey = if (prefix.isBlank()) k else "$prefix.$k"
+ flattenJsonAny(any.opt(k), newKey, out)
+ }
+ }
+ is org.json.JSONArray -> {
+ for (i in 0 until any.length()) {
+ val newKey = "$prefix[$i]"
+ flattenJsonAny(any.opt(i), newKey, out)
+ }
+ }
+ is Boolean, is Int, is Long, is Double, is Float -> {
+ if (prefix.isNotBlank()) out[prefix] = any.toString()
+ }
+ is String -> {
+ if (prefix.isNotBlank()) out[prefix] = any
+ }
+ else -> {
+ // 其它类型忽略
+ }
+ }
+ }
+
+ // ==================== SSE 读取:JSON / 纯文本 chunk ====================
+
+ private fun readSseStream(
+ source: BufferedSource,
+ call: Call,
+ callback: LlmStreamCallback
+ ) {
+ var eventName: String? = null
+ val dataLines = mutableListOf()
+
+ fun dispatch() {
+ if (eventName == null && dataLines.isEmpty()) return
+
+ val rawData = dataLines.joinToString("\n")
+ Log.d("999-SSE_TALK-event", "event=${eventName ?: "(null)"} rawData=[${rawData.take(500)}]")
+
+ if (rawData.isNotEmpty()) {
+ handlePayload(eventName, rawData, callback)
+ }
+
+ eventName = null
+ dataLines.clear()
+ }
+
+ while (!source.exhausted() && !call.isCanceled()) {
+ val line = source.readUtf8Line() ?: break
+ Log.d("999-SSE_TALK-raw", "raw: [$line]")
+
+ if (line.isEmpty()) {
+ dispatch()
+ continue
+ }
+ if (line.startsWith(":")) continue
+
+ val idx = line.indexOf(':')
+ val field = if (idx == -1) line else line.substring(0, idx)
+
+ val rawValue = if (idx == -1) "" else line.substring(idx + 1)
+ val value = if (rawValue.startsWith(" ")) rawValue.substring(1) else rawValue
+
+ when (field) {
+ "event" -> eventName = value
+ "data" -> dataLines.add(value)
+ else -> Unit
+ }
+ }
+
+ dispatch()
+ }
+
+ private fun handlePayload(eventName: String?, rawData: String, callback: LlmStreamCallback) {
+ val trimmed = rawData.trim()
+ val looksLikeJson = trimmed.startsWith("{") && trimmed.endsWith("}")
+
+ if (looksLikeJson) {
+ try {
+ val obj = JSONObject(trimmed)
+ val type = obj.optString("type", "")
+ val dataValue = if (obj.has("data") && !obj.isNull("data")) obj.opt("data") else null
+ val dataStr = dataValue?.toString()
+
+ if (type.isNotBlank()) {
+ callback.onEvent(type, dataStr)
+ return
+ }
+ } catch (_: Throwable) {
+ // fallthrough to text
+ }
+ }
+
+ callback.onEvent(eventName ?: "text_chunk", rawData)
+ }
+
+ private fun peekOrReadBody(response: Response): String {
+ return try {
+ response.peekBody(1024 * 1024).string()
+ } catch (_: Throwable) {
+ try {
+ response.body?.string().orEmpty()
+ } catch (t2: Throwable) {
+ "read body failed: ${t2.message}"
+ }
+ }
+ }
}
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..3650d53 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,21 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.example.myapplication.ImeGuideActivity
+import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.R
-import com.example.myapplication.network.*
+import com.example.myapplication.network.AddPersonaClick
+import com.example.myapplication.network.AuthEvent
+import com.example.myapplication.network.AuthEventBus
+import com.example.myapplication.network.BehaviorReporter
+import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.network.listByTagWithNotLogin
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
+import com.example.myapplication.network.PersonaClick
+import com.example.myapplication.ui.home.PersonaAdapter
import kotlin.math.abs
class HomeFragment : Fragment() {
@@ -47,6 +55,7 @@ class HomeFragment : Fragment() {
private lateinit var tabList2: TextView
private lateinit var backgroundImage: ImageView
private var lastList1RenderKey: String? = null
+ private lateinit var loadingOverlay: LoadingOverlay
private var preloadJob: Job? = null
private var allPersonaCache: List = emptyList()
@@ -85,6 +94,7 @@ class HomeFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
+
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
AuthEventBus.events.collect { event ->
@@ -95,13 +105,13 @@ class HomeFragment : Fragment() {
personaCache.clear()
allPersonaCache = emptyList()
lastList1RenderKey = null
-
+
// 2) 重新拉列表1(登录态接口会变)
viewLifecycleOwner.lifecycleScope.launch {
allPersonaCache = fetchAllPersonaList()
notifyPageChangedOnMain(0)
}
-
+
// 3) 如果当前在某个 tag 页,也建议重新拉当前页数据
val pos = viewPager.currentItem
if (pos > 0) {
@@ -114,21 +124,100 @@ class HomeFragment : Fragment() {
}
}
}
+
+ is AuthEvent.CharacterAdded -> {
+ viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
+ try {
+ // 1) 列表一:重新拉
+ allPersonaCache = fetchAllPersonaList()
+ lastList1RenderKey = null
+ notifyPageChangedOnMain(0)
+
+ // 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
+ personaCache.clear()
+
+ // 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新)
+ val pos = viewPager.currentItem
+ if (pos > 0) {
+ val tagId = tags.getOrNull(pos - 1)?.id
+ if (tagId != null) {
+ // 先刷新一次,让页面进入 loading(因为缓存被清了)
+ notifyPageChangedOnMain(pos)
+
+ // 再拉当前 tag 的新数据
+ val list = fetchPersonaByTag(tagId)
+ personaCache[tagId] = list
+ notifyPageChangedOnMain(pos)
+ }
+ }
+
+ // 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍
+ startPreloadAllTagsFillCacheOnly()
+ } finally {
+ loadingOverlay.hide()
+ }
+ }
+ }
+
+ is AuthEvent.CharacterDeleted -> {
+ viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
+ try {
+ // 1) 列表一:重新拉
+ allPersonaCache = fetchAllPersonaList()
+ lastList1RenderKey = null
+ notifyPageChangedOnMain(0)
+
+ // 2) 列表二:清缓存(最可靠,避免“删除后还显示旧数据”)
+ personaCache.clear()
+
+ // 3) 如果当前就在某个 tag 页:让它立刻更新(显示 loading -> 拉取 -> 刷新)
+ val pos = viewPager.currentItem
+ if (pos > 0) {
+ val tagId = tags.getOrNull(pos - 1)?.id
+ if (tagId != null) {
+ // 先刷新一次,让页面进入 loading(因为缓存被清了)
+ notifyPageChangedOnMain(pos)
+
+ // 再拉当前 tag 的新数据
+ val list = fetchPersonaByTag(tagId)
+ personaCache[tagId] = list
+ notifyPageChangedOnMain(pos)
+ }
+ }
+
+ // 4) 可选:你如果希望“删除后列表二也立刻全量更新”,就顺手再预加载一遍
+ startPreloadAllTagsFillCacheOnly()
+ } finally {
+ loadingOverlay.hide()
+ }
+ }
+ }
else -> Unit
}
}
}
}
-
// 充值按钮点击 - 使用事件总线打开全局页面
view.findViewById(R.id.rechargeButton).setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home_main",
+ "element_id" to "buy_vip_btn",
+ )
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.rechargeFragment))
}
// 输入法激活跳转
view.findViewById(R.id.floatingImage).setOnClickListener {
if (!isAdded) return@setOnClickListener
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home_main",
+ "element_id" to "permission_float_btn",
+ )
startActivity(Intent(requireActivity(), ImeGuideActivity::class.java))
}
@@ -141,11 +230,13 @@ class HomeFragment : Fragment() {
tabList2 = view.findViewById(R.id.tab_list2)
viewPager = view.findViewById(R.id.viewPager)
viewPager.isSaveEnabled = false
- viewPager.offscreenPageLimit = 2
+ viewPager.offscreenPageLimit = 2
backgroundImage = bottomSheet.findViewById(R.id.backgroundImage)
val root = view.findViewById(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 +260,26 @@ class HomeFragment : Fragment() {
// 加载列表一
viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
try {
val list = fetchAllPersonaList()
if (!isAdded) return@launch
allPersonaCache = list
-
+
// ✅ 关键:数据变了就清 renderKey,允许重建一次 UI
lastList1RenderKey = null
-
+
notifyPageChangedOnMain(0)
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "获取列表一失败", e)
+ } finally {
+ loadingOverlay.hide()
}
}
// 拉标签 + 预加载
viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
try {
val response = RetrofitClient.apiService.tagList()
if (!isAdded) return@launch
@@ -204,11 +299,13 @@ class HomeFragment : Fragment() {
startPreloadAllTagsFillCacheOnly()
} catch (e: Exception) {
Log.e("1314520-HomeFragment", "获取标签失败", e)
+ } finally {
+ loadingOverlay.hide()
}
}
}
- // ================== 你要求的核心优化:setupViewPager 只初始化一次 ==================
+ // ================== 核心:setupViewPager 只初始化一次 ==================
private fun setupViewPagerOnce() {
if (sheetAdapter != null) return
@@ -223,7 +320,7 @@ class HomeFragment : Fragment() {
override fun onPageSelected(position: Int) {
if (!isAdded) return
updateTabsAndTags(position)
-
+
// ✅ 修复:当切换到标签页且缓存已有数据时,强制刷新UI
if (position > 0) {
val tagIndex = position - 1
@@ -245,6 +342,52 @@ class HomeFragment : Fragment() {
}
}
+ // ---------------- 方案A:成功后“造新数据(copy)替换缓存”并刷新 ----------------
+
+ private fun applyAddedToggle(personaId: Int, newAdded: Boolean) {
+ // 1) 更新列表一缓存
+ run {
+ val oldAll = allPersonaCache
+ val idxAll = oldAll.indexOfFirst { it.id == personaId }
+ if (idxAll >= 0) {
+ val newList = oldAll.toMutableList()
+ val oldItem = newList[idxAll]
+ newList[idxAll] = oldItem.copy(added = newAdded)
+ allPersonaCache = newList
+
+ // renderList1 有 renderKey,必须清一下
+ lastList1RenderKey = null
+ notifyPageChangedOnMain(0)
+ }
+ }
+
+ // 2) 更新所有 tag 缓存(personaCache)
+ val keys = personaCache.keys.toList()
+ var changedCurrentTagPage = false
+
+ for (tagId in keys) {
+ val old = personaCache[tagId] ?: continue
+ val idx = old.indexOfFirst { it.id == personaId }
+ if (idx >= 0) {
+ val newList = old.toMutableList()
+ val oldItem = newList[idx]
+ newList[idx] = oldItem.copy(added = newAdded)
+ personaCache[tagId] = newList
+
+ // 如果当前就在这个 tag 页,标记需要刷新
+ val pos = viewPager.currentItem
+ val currentTagId = tags.getOrNull(pos - 1)?.id
+ if (pos > 0 && currentTagId == tagId) {
+ changedCurrentTagPage = true
+ }
+ }
+ }
+
+ if (changedCurrentTagPage) {
+ notifyPageChangedOnMain(viewPager.currentItem)
+ }
+ }
+
// ---------------- 拖拽效果 ----------------
private fun initDrag(target: View, parent: ViewGroup) {
@@ -328,62 +471,52 @@ class HomeFragment : Fragment() {
private fun setupBottomSheet(root: View) {
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
-
+
bottomSheetBehavior.isDraggable = true
bottomSheetBehavior.isHideable = false
bottomSheetBehavior.isFitToContents = false
bottomSheetBehavior.halfExpandedRatio = 0.7f
-
- root.post {
- if (!isAdded) return@post
- val coordinatorHeight = root.height - 40
- val button = root.findViewById(R.id.rechargeButton)
- val peek = (coordinatorHeight - button.bottom).coerceAtLeast(200)
- bottomSheetBehavior.peekHeight = peek
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
- }
-
+
+ // ✅ 固定初始高度,避免每次计算导致跳动
+ bottomSheetBehavior.peekHeight = dpToPx(260) // 你想要多少就写多少
+ bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
+
bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
scrim.isVisible = newState != BottomSheetBehavior.STATE_COLLAPSED
}
-
override fun onSlide(bottomSheet: View, slideOffset: Float) {
if (slideOffset >= 0f) scrim.alpha = slideOffset.coerceIn(0f, 1f)
}
})
-
- scrim.setOnClickListener {
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
- }
-
- scrim.setOnTouchListener { _, event ->
- if (event.action == MotionEvent.ACTION_MOVE) {
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
- true
- } else false
- }
-
- header.setOnClickListener {
- when (bottomSheetBehavior.state) {
- BottomSheetBehavior.STATE_COLLAPSED ->
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED
-
- BottomSheetBehavior.STATE_HALF_EXPANDED,
- BottomSheetBehavior.STATE_EXPANDED ->
- bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
-
- else -> {}
- }
- }
+ }
+
+ private fun dpToPx(dp: Int): Int {
+ return (dp * resources.displayMetrics.density).toInt()
}
// ---------------- Tabs ----------------
private fun setupTopTabs() {
- tabList1.setOnClickListener { viewPager.currentItem = 0 }
+ tabList1.setOnClickListener {
+ viewPager.currentItem = 0
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home_rank",
+ )
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home_rank_content",
+ )
+ }
tabList2.setOnClickListener {
if (tags.isNotEmpty()) viewPager.currentItem = 1
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "home_hot",
+ )
}
}
@@ -488,28 +621,28 @@ class HomeFragment : Fragment() {
private fun startPreloadAllTagsFillCacheOnly() {
preloadJob?.cancel()
-
+
val semaphore = kotlinx.coroutines.sync.Semaphore(permits = 2)
-
+
preloadJob = viewLifecycleOwner.lifecycleScope.launch {
if (tags.isEmpty()) return@launch
-
+
tags.forEach { tag ->
if (personaCache.containsKey(tag.id)) return@forEach
-
+
launch {
semaphore.acquire()
try {
val list = fetchPersonaByTag(tag.id)
personaCache[tag.id] = list
-
+
// ✅ 只在用户正在看的页时刷新一次(不算乱刷 UI)
val idx = tags.indexOfFirst { it.id == tag.id }
val thisPos = 1 + idx
if (idx >= 0 && viewPager.currentItem == thisPos) {
notifyPageChangedOnMain(thisPos)
}
-
+
} catch (e: Exception) {
Log.e("HomeFragment", "preload tag=${tag.id} fail", e)
} finally {
@@ -519,7 +652,6 @@ class HomeFragment : Fragment() {
}
}
}
-
// ---------------- ViewPager Adapter ----------------
@@ -531,10 +663,10 @@ class HomeFragment : Fragment() {
fun updatePageCount(newCount: Int) {
if (newCount == pageCount) return
-
+
val old = pageCount
pageCount = newCount
-
+
if (newCount > old) {
notifyItemRangeInserted(old, newCount - old)
} else {
@@ -564,10 +696,7 @@ class HomeFragment : Fragment() {
val loadingView = root.findViewById(R.id.loadingView)
rv2.setHasFixedSize(true)
-
- // ✅ 禁止 itemAnimator(减少 layout 抖动)
rv2.itemAnimator = null
-
rv2.isNestedScrollingEnabled = false
var adapter = rv2.adapter as? PersonaAdapter
@@ -584,22 +713,31 @@ class HomeFragment : Fragment() {
is PersonaClick.Add -> {
viewLifecycleOwner.lifecycleScope.launch {
+ val personaId = click.persona.id?: return@launch
+ val oldAdded = click.persona.added
+ val newAdded = !oldAdded
try {
- if (click.persona.added == true) {
- click.persona.id?.let { id ->
- RetrofitClient.apiService.delUserCharacter(id.toInt())
- }
- } else {
+ if (oldAdded) {
+ // RetrofitClient.apiService.delUserCharacter(personaId)
+ // // ✅ 成功后替换缓存并刷新
+ // applyAddedToggle(personaId, newAdded)
+ }
+ else {
+ Log.d("1314520-HomeFragment", "add persona id=${click.persona.id}")
val req = AddPersonaClick(
- characterId = click.persona.id?.toInt() ?: 0,
+ characterId = personaId,
emoji = click.persona.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
+ // ✅ 成功后替换缓存并刷新
+ applyAddedToggle(personaId, newAdded)
}
- } catch (_: Exception) {
+ } catch (e: Exception) {
+ Log.e("1314520-HomeFragment", "grid toggle add failed id=$personaId", e)
}
}
}
+ else -> Unit
}
}
rv2.layoutManager = GridLayoutManager(root.context, 2)
@@ -629,7 +767,7 @@ class HomeFragment : Fragment() {
override fun getItemCount(): Int = pageCount
}
- // ---------------- 列表一渲染(原逻辑不动) ----------------
+ // ---------------- 列表一渲染 ----------------
private fun renderList1(root: View, list: List) {
val key = buildString {
@@ -641,6 +779,7 @@ class HomeFragment : Fragment() {
}
if (key == lastList1RenderKey) return
lastList1RenderKey = key
+
val sorted = list.sortedBy { it.rank ?: Int.MAX_VALUE }
val top3 = sorted.take(3)
val others = if (sorted.size > 3) sorted.drop(3) else emptyList()
@@ -650,6 +789,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_first,
nameId = R.id.name_first,
addBtnId = R.id.btn_add_first,
+ addBtnIcon = R.id.add_first_icon,
containerId = R.id.container_first,
item = top3.getOrNull(0)
)
@@ -659,6 +799,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_second,
nameId = R.id.name_second,
addBtnId = R.id.btn_add_second,
+ addBtnIcon = R.id.add_second_icon,
containerId = R.id.container_second,
item = top3.getOrNull(1)
)
@@ -668,6 +809,7 @@ class HomeFragment : Fragment() {
avatarId = R.id.avatar_third,
nameId = R.id.name_third,
addBtnId = R.id.btn_add_third,
+ addBtnIcon = R.id.add_third_icon,
containerId = R.id.container_third,
item = top3.getOrNull(2)
)
@@ -686,6 +828,62 @@ class HomeFragment : Fragment() {
val iv = itemView.findViewById(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 +891,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 +900,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 +922,60 @@ class HomeFragment : Fragment() {
name.text = item.characterName ?: ""
com.bumptech.glide.Glide.with(avatar).load(item.avatarUrl).into(avatar)
+ // ✅ 记录“原始背景/原始icon”,用于 added=false 时恢复
+ val originBg = addBtn.background
+ val originIconRes = when (addBtnId) {
+ R.id.btn_add_first -> R.drawable.first_add
+ R.id.btn_add_second -> R.drawable.second_add
+ R.id.btn_add_third -> R.drawable.third_add
+ else -> 0
+ }
+
+ fun renderAddState(added: Boolean) {
+ if (added) {
+ addBtn.setBackgroundResource(R.drawable.round_bg_others_already)
+ addIcon.setImageResource(R.drawable.ime_guide_activity_btn_completed_img)
+ } else {
+ addBtn.background = originBg
+ if (originIconRes != 0) addIcon.setImageResource(originIconRes)
+ }
+ }
+
+ // ✅ 首次渲染
+ renderAddState(item.added == true)
+
+ // ✅ 点击:失败回滚 + 防连点(请求中禁用按钮)
addBtn.setOnClickListener {
+ if (!addBtn.isEnabled) return@setOnClickListener
+
viewLifecycleOwner.lifecycleScope.launch {
+ val personaId = item.id?: return@launch
+ val oldAdded = item.added
+ val newAdded = !oldAdded
+
+ addBtn.isEnabled = false
+ renderAddState(newAdded)
try {
- if (item.added == true) {
- item.id?.let { id -> RetrofitClient.apiService.delUserCharacter(id.toInt()) }
- } else {
+ if (oldAdded) {
+ // RetrofitClient.apiService.delUserCharacter(personaId)
+ // // ✅ 只有成功才更新缓存 + 更新UI
+ // applyAddedToggle(personaId, newAdded)
+ }
+ else {
+ Log.d("1314520-HomeFragment", "add persona id=${item.id}")
val req = AddPersonaClick(
- characterId = item.id?.toInt() ?: 0,
+ characterId = personaId,
emoji = item.emoji ?: ""
)
RetrofitClient.apiService.addUserCharacter(req)
+ // ✅ 只有成功才更新缓存 + 更新UI
+ applyAddedToggle(personaId, newAdded)
}
- } catch (_: Exception) {
+ } catch (e: Exception) {
+ Log.e("1314520-HomeFragment", "others toggle add failed id=$personaId", e)
+ renderAddState(oldAdded)
+ } finally {
+ addBtn.isEnabled = true
}
}
}
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..6482429 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() {
@@ -59,8 +62,9 @@ class PersonaDetailDialogFragment : DialogFragment() {
tvName.text = data.characterName ?: ""
download.text = data.download ?: ""
tvBackground.text = data.characterBackground ?: ""
- btnAdd.text = data.added?.let { "Added" } ?: "Add"
- btnAdd.setBackgroundResource(data.added?.let { R.drawable.ic_added } ?: R.drawable.keyboard_ettings)
+ btnAdd.text = if (data.added == true) "Added" else "Add"
+ btnAdd.setBackgroundResource(if (data.added == true) R.drawable.ic_added else R.drawable.keyboard_ettings)
+ val newAdded = !(data.added ?: false)
Glide.with(requireContext())
.load(data.avatarUrl)
@@ -72,13 +76,13 @@ class PersonaDetailDialogFragment : DialogFragment() {
lifecycleScope.launch {
if(data.added == true){
//取消收藏
- data.id?.let { id ->
- try {
- RetrofitClient.apiService.delUserCharacter(id.toInt())
- } catch (e: Exception) {
- // 处理错误
- }
- }
+ // data.id?.let { id ->
+ // try {
+ // RetrofitClient.apiService.delUserCharacter(id.toInt())
+ // } catch (e: Exception) {
+ // // 处理错误
+ // }
+ // }
}else{
val addPersonaRequest = AddPersonaClick(
characterId = data.id?.toInt() ?: 0,
@@ -86,8 +90,11 @@ class PersonaDetailDialogFragment : DialogFragment() {
)
try {
RetrofitClient.apiService.addUserCharacter(addPersonaRequest)
+ data.id?.let { personaId ->
+ AuthEventBus.emit(AuthEvent.CharacterAdded(personaId,newAdded))
+ }
} catch (e: Exception) {
- // 处理错误
+ Log.e("1314520-PersonaDetailDialogFragment", "addUserCharacter error", e)
}
}
dismissAllowingStateLoss()
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/KeyboardDetailFragment.kt b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt
index 3cdda23..d9c53ca 100644
--- a/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/keyboard/KeyboardDetailFragment.kt
@@ -42,6 +42,7 @@ import java.io.BufferedInputStream
import java.io.FileInputStream
import com.example.myapplication.ui.shop.ShopEvent
import com.example.myapplication.ui.shop.ShopEventBus
+import com.example.myapplication.network.BehaviorReporter
class KeyboardDetailFragment : Fragment() {
@@ -57,6 +58,7 @@ class KeyboardDetailFragment : Fragment() {
private lateinit var enabledButtonText: TextView
private lateinit var progressBar: android.widget.ProgressBar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
+ private var themeDetailResp: themeDetail? = null
override fun onCreateView(
inflater: LayoutInflater,
@@ -80,7 +82,7 @@ class KeyboardDetailFragment : Fragment() {
enabledButtonText = view.findViewById(R.id.enabledButtonText)
progressBar = view.findViewById(R.id.progressBar)
swipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout)
-
+
// 设置按钮始终防止事件穿透的触摸监听器
enabledButton.setOnTouchListener { _, event ->
// 如果按钮被禁用,消耗所有触摸事件防止穿透
@@ -120,6 +122,13 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
enableTheme()
}
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "skin_detail",
+ "element_id" to "download_btn",
+ "theme_id" to themeId,
+ "purchased" to if (themeDetailResp?.isPurchased == true) "1" else "0",
+ )
}
}
@@ -132,7 +141,7 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
try {
- val themeDetailResp = getThemeDetail(themeId)?.data
+ themeDetailResp = getThemeDetail(themeId)?.data
val recommendThemeListResp = getrecommendThemeList()?.data
Glide.with(requireView().context)
@@ -333,6 +342,13 @@ class KeyboardDetailFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
setpurchaseTheme(themeId)
dialog.dismiss()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "skin_detail",
+ "element_id" to "download_btn",
+ "theme_id" to themeId,
+ "purchased" to if (themeDetailResp?.isPurchased == true) "1" else "0",
+ )
}
}
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..31694cb 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,150 @@
package com.example.myapplication.ui.keyboard
import android.os.Bundle
+import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.fragment.app.Fragment
import android.widget.FrameLayout
+import android.widget.TextView
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.RecyclerView
import com.example.myapplication.R
+import com.example.myapplication.network.ApiResponse
+import com.example.myapplication.network.AuthEvent
+import com.example.myapplication.network.AuthEventBus
+import com.example.myapplication.ui.common.LoadingOverlay
+import com.example.myapplication.network.ListByUserWithNot
+import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.network.updateUserCharacterSortRequest
+import kotlinx.coroutines.launch
+import com.example.myapplication.network.BehaviorReporter
class MyKeyboard : Fragment() {
-
+
+ private lateinit var rv: RecyclerView
+ private lateinit var btnSave: TextView
+ private lateinit var adapter: KeyboardAdapter
+ private lateinit var loadingOverlay: LoadingOverlay
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View? {
- return inflater.inflate(R.layout.my_keyboard, container, false)
- }
+ ): View = inflater.inflate(R.layout.my_keyboard, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
-
+
view.findViewById(R.id.iv_close).setOnClickListener {
- parentFragmentManager.popBackStack()
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ }
+
+ rv = view.findViewById(R.id.rv_keyboard)
+ btnSave = view.findViewById(R.id.btn_keyboard)
+
+ adapter = KeyboardAdapter(
+ onItemClick = { item ->
+ // ✅ 点击卡片:弹自定义确认弹窗
+ ConfirmDeleteDialogFragment
+ .newInstance(item.characterName, item.emoji) {
+ // ✅ 用户确认后才删除
+ viewLifecycleOwner.lifecycleScope.launch {
+ val resp = setdelUserCharacter(item.id)
+ if (resp?.code == 0 && resp.data == true) {
+ Toast.makeText(requireContext(),"Deleted successfully", Toast.LENGTH_SHORT).show()
+ AuthEventBus.emit(AuthEvent.CharacterDeleted(item.id))
+ loadList()
+ } else {
+ Toast.makeText(requireContext(), resp?.message ?: "Delete failed", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+ .show(parentFragmentManager, "confirm_delete")
+ }
+ )
+
+ rv.layoutManager = GridLayoutManager(requireContext(), 2)
+ rv.adapter = adapter
+
+ // ✅ 长按拖动排序 + 反馈
+ ItemTouchHelper(
+ DragSortCallback { from, to ->
+ adapter.moveItem(from, to)
+ }
+ ).attachToRecyclerView(rv)
+
+ loadingOverlay = LoadingOverlay.attach(view as ViewGroup)
+ loadList()
+
+ // ✅ Save:上传当前排序(id数组)
+ btnSave.setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my_keyboard",
+ "element_id" to "save_btn",
+ )
+ val sortIds = adapter.getCurrentIdsInOrder()
+ Log.d("MyKeyboard-sort", sortIds.toString())
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
+ try {
+ val body = updateUserCharacterSortRequest(sort = sortIds)
+ val resp = setupdateUserCharacterSort(body)
+ if (resp?.code == 0 && resp.data == true) {
+ requireActivity().onBackPressedDispatcher.onBackPressed()
+ Toast.makeText(requireContext(), "Sorting has been successfully modified.", Toast.LENGTH_SHORT).show()
+ } else {
+ Toast.makeText(requireContext(), resp?.message ?: "Save failed", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(requireContext(), "Network error: ${e.message}", Toast.LENGTH_SHORT).show()
+ } finally {
+ loadingOverlay.hide()
+ }
+ }
+ }
+ }
+
+ private fun loadList() {
+ viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
+ try {
+ val resp = getlistByUser()
+ if (resp?.code == 0 && resp.data != null) {
+ adapter.submitList(resp.data)
+ Log.d("1314520-list", resp.data.toString())
+ } else {
+ Toast.makeText(requireContext(), resp?.message ?: "Load failed", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Toast.makeText(requireContext(), "Load failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ } finally {
+ loadingOverlay.hide()
+ }
+ }
+ }
+
+ // 获取用户人设列表
+ private suspend fun getlistByUser(): ApiResponse>? =
+ 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/login/ForgetPasswordEmailFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt
index a4935e6..9bc479c 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordEmailFragment.kt
@@ -18,6 +18,7 @@ import com.example.myapplication.network.SendVerifyCodeRequest
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import android.util.Log
+import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordEmailFragment : Fragment() {
@@ -55,6 +56,11 @@ class ForgetPasswordEmailFragment : Fragment() {
} else if (!isValidEmail(email)) {
Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show()
} else {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "forgot_password_email",
+ "element_id" to "next_btn",
+ )
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {
diff --git a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt
index f7351e1..0589ae4 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordResetFragment.kt
@@ -21,6 +21,7 @@ import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import kotlinx.coroutines.launch
+import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordResetFragment : Fragment() {
@@ -110,6 +111,11 @@ class ForgetPasswordResetFragment : Fragment() {
} else if (password != confirmPassword) {
Toast.makeText(activity, "The two password entries are inconsistent", Toast.LENGTH_SHORT).show()
} else {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "forgot_password_newpwd",
+ "element_id" to "next_btn",
+ )
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {
diff --git a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt
index b36050f..3d66bc6 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/ForgetPasswordVerifyFragment.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import com.example.myapplication.network.RetrofitClient
import com.example.myapplication.network.VerifyCodeRequest
import android.util.Log
+import com.example.myapplication.network.BehaviorReporter
class ForgetPasswordVerifyFragment : Fragment() {
@@ -59,6 +60,11 @@ class ForgetPasswordVerifyFragment : Fragment() {
// 显示加载遮罩层
loadingOverlay?.show()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "forgot_password_verify",
+ "element_id" to "next_btn",
+ )
viewLifecycleOwner.lifecycleScope.launch {
try {
val body = VerifyCodeRequest(
diff --git a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt
index fba0d87..7bdfbc6 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/LoginFragment.kt
@@ -26,6 +26,7 @@ import com.example.myapplication.ui.mine.MineFragment
import androidx.core.os.bundleOf
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.AuthEvent
+import com.example.myapplication.network.BehaviorReporter
class LoginFragment : Fragment() {
@@ -57,10 +58,20 @@ class LoginFragment : Fragment() {
// 注册
view.findViewById(R.id.tv_signup).setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "login",
+ "element_id" to "signup_btn",
+ )
findNavController().navigate(R.id.action_loginFragment_to_registerFragment)
}
// 忘记密码
view.findViewById(R.id.tv_forgot_password).setOnClickListener {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "login",
+ "element_id" to "forgot_btn",
+ )
findNavController().navigate(R.id.action_loginFragment_to_forgetPasswordEmailFragment)
}
// 返回 - 在global_graph中,直接popBackStack回到globalEmptyFragment
@@ -110,6 +121,12 @@ class LoginFragment : Fragment() {
// 输入框不能为空
Toast.makeText(requireContext(), "The password and email address cannot be left empty!", Toast.LENGTH_SHORT).show()
} else {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "login_email",
+ "element_id" to "submit_btn",
+ )
+
loadingOverlay?.show()
// 调用登录API
lifecycleScope.launch {
@@ -125,6 +142,7 @@ class LoginFragment : Fragment() {
EncryptedSharedPreferencesUtil.save(requireContext(), "email",email)
// 触发登录成功事件,让MainActivity关闭全局overlay
AuthEventBus.emit(AuthEvent.LoginSuccess)
+ AuthEventBus.emit(AuthEvent.CharacterDeleted(0))
// 不在这里popBackStack,让MainActivity的LoginSuccess事件处理关闭全局overlay
} else {
Toast.makeText(requireContext(), "Login failed: ${response.message}", Toast.LENGTH_SHORT).show()
diff --git a/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt
index b1eeb8c..cd00a1d 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/RegisterFragment.kt
@@ -20,6 +20,7 @@ import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import com.example.myapplication.ui.common.LoadingOverlay
import kotlinx.coroutines.launch
import android.util.Log
+import com.example.myapplication.network.BehaviorReporter
class RegisterFragment : Fragment() {
@@ -113,6 +114,11 @@ class RegisterFragment : Fragment() {
} else if (!isValidEmail(email)) {
Toast.makeText(activity, "The email address format is incorrect", Toast.LENGTH_SHORT).show()
} else {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "register_email",
+ "element_id" to "submit_btn",
+ )
loadingOverlay?.show()
viewLifecycleOwner.lifecycleScope.launch {
try {
diff --git a/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt b/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt
index 830e9d1..c56a400 100644
--- a/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/login/RegisterVerifyFragment.kt
@@ -20,6 +20,7 @@ import com.example.myapplication.ui.common.CodeEditText
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import kotlinx.coroutines.launch
import android.widget.TextView
+import com.example.myapplication.network.BehaviorReporter
class RegisterVerifyFragment : Fragment() {
@@ -73,7 +74,11 @@ class RegisterVerifyFragment : Fragment() {
}
loadingOverlay?.show()
-
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "register_verify_email",
+ "element_id" to "confirm_btn",
+ )
viewLifecycleOwner.lifecycleScope.launch {
try {
val body = RegisterRequest(
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..ebb7d89 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,22 +20,30 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
+import com.bumptech.glide.Glide
import com.example.myapplication.R
import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.LoginResponse
+import com.example.myapplication.network.ApiResponse
+import com.example.myapplication.network.ShareResponse
import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.ui.common.LoadingOverlay
import com.example.myapplication.utils.EncryptedSharedPreferencesUtil
import de.hdodenhof.circleimageview.CircleImageView
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
+import com.example.myapplication.network.BehaviorReporter
class MineFragment : Fragment() {
private lateinit var nickname: TextView
private lateinit var time: TextView
private lateinit var logout: TextView
+ private lateinit var avatar: CircleImageView
+ private lateinit var share: LinearLayout
+ private lateinit var loadingOverlay: LoadingOverlay
private var loadUserJob: Job? = null
@@ -48,6 +59,7 @@ class MineFragment : Fragment() {
override fun onDestroyView() {
loadUserJob?.cancel()
+ loadingOverlay.remove()
super.onDestroyView()
}
@@ -57,24 +69,44 @@ class MineFragment : Fragment() {
nickname = view.findViewById(R.id.nickname)
time = view.findViewById(R.id.time)
logout = view.findViewById(R.id.logout)
+ avatar = view.findViewById(R.id.avatar)
+ share = view.findViewById(R.id.click_Share)
+ loadingOverlay = LoadingOverlay.attach(view.findViewById(R.id.rootCoordinator))
// 1) 先用本地缓存秒出首屏
renderFromCache()
- // 2) 首次进入不刷新,由onResume处理
-
- // // ✅ 手动刷新:不改布局也能用
- // // - 点昵称刷新
- // nickname.setOnClickListener { refreshUser(force = true, showToast = true) }
- // // - 长按 time 刷新
- // time.setOnLongClickListener {
- // refreshUser(force = true, showToast = true)
- // true
- // }
+ share.setOnClickListener {
+ viewLifecycleOwner.lifecycleScope.launch {
+ loadingOverlay.show()
+ try {
+ val response = getinviteCode()
+ response?.data?.h5Link?.let { link ->
+ val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clip = ClipData.newPlainText("h5Link", link)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(context, "The sharing link has been copied to the clipboard.", Toast.LENGTH_LONG).show()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my",
+ "element_id" to "invite_copy",
+ )
+ }
+ } finally {
+ loadingOverlay.hide()
+ }
+ }
+ }
logout.setOnClickListener {
LogoutDialogFragment { doLogout() }
.show(parentFragmentManager, "logout_dialog")
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "person_info",
+ "element_id" to "logout_btn",
+ )
}
view.findViewById(R.id.imgLeft).setOnClickListener {
@@ -85,17 +117,43 @@ 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))
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my",
+ "element_id" to "menu_item",
+ "item_title" to "消费记录"
+ )
}
view.findViewById(R.id.click_Feedback).setOnClickListener {
- safeNavigate(R.id.action_mineFragment_to_feedbackFragment)
+ //反馈
+ AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.feedbackFragment))
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my",
+ "element_id" to "menu_item",
+ "item_title" to "反馈"
+ )
}
view.findViewById(R.id.click_Notice).setOnClickListener {
- safeNavigate(R.id.action_mineFragment_to_notificationFragment)
+ //通知
+ AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.notificationFragment))
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my",
+ "element_id" to "menu_item",
+ "item_title" to "通知"
+ )
}
// ✅ 监听登录成功/登出事件(跨 NavHost 可靠)
@@ -108,6 +166,10 @@ class MineFragment : Fragment() {
renderFromCache()
refreshUser(force = true, showToast = false)
}
+ AuthEvent.UserUpdated -> {
+ renderFromCache()
+ refreshUser(force = true, showToast = false)
+ }
else -> Unit
}
}
@@ -131,6 +193,11 @@ class MineFragment : Fragment() {
)
nickname.text = cached?.nickName ?: ""
time.text = cached?.vipExpiry?.let { "Due on November $it" } ?: ""
+ cached?.avatarUrl?.let { url ->
+ Glide.with(requireContext())
+ .load(url)
+ .into(avatar)
+ }
}
/**
@@ -154,15 +221,22 @@ class MineFragment : Fragment() {
Log.d(TAG, "getUser ok: nick=${u?.nickName} vip=${u?.vipExpiry}")
nickname.text = u?.nickName ?: ""
+
time.text = u?.vipExpiry?.let { "Due on November $it" } ?: ""
+ u?.avatarUrl?.let { url ->
+ Glide.with(requireContext())
+ .load(url)
+ .into(avatar)
+ }
+
EncryptedSharedPreferencesUtil.save(requireContext(), "Personal_information", u)
- if (showToast) Toast.makeText(requireContext(), "已刷新", Toast.LENGTH_SHORT).show()
+ if (showToast) Toast.makeText(requireContext(), "Refreshed", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) return@launch
Log.e(TAG, "getUser failed", e)
- if (showToast && isAdded) Toast.makeText(requireContext(), "刷新失败", Toast.LENGTH_SHORT).show()
+ if (showToast && isAdded) Toast.makeText(requireContext(), "Refresh failed", Toast.LENGTH_SHORT).show()
}
}
}
@@ -180,6 +254,9 @@ class MineFragment : Fragment() {
// 清空 UI
nickname.text = ""
time.text = ""
+ Glide.with(requireContext())
+ .load(R.drawable.default_avatar)
+ .into(avatar)
// 触发登出事件,让MainActivity打开登录页面
AuthEventBus.emit(AuthEvent.Logout(returnTabTag = "tab_mine"))
@@ -209,4 +286,7 @@ class MineFragment : Fragment() {
companion object {
private const val TAG = "1314520-MineFragment"
}
+
+ private suspend fun getinviteCode(): ApiResponse? =
+ 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..78d5f09 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,24 @@ package com.example.myapplication.ui.mine.myotherpages
import android.os.Bundle
import android.view.LayoutInflater
+import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.fragment.app.Fragment
-import com.example.myapplication.R
import android.widget.FrameLayout
-import com.google.android.material.bottomsheet.BottomSheetDialogFragment
-import com.google.android.material.button.MaterialButton
-import com.google.android.material.textfield.TextInputLayout
-import java.util.*
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import com.example.myapplication.R
+import com.example.myapplication.network.ApiResponse
+import com.example.myapplication.network.RetrofitClient
+import com.example.myapplication.network.feedbackRequest
+import com.google.android.material.textfield.TextInputEditText
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import com.example.myapplication.network.BehaviorReporter
class FeedbackFragment : Fragment() {
-
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -26,9 +31,56 @@ class FeedbackFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
- // 设置关闭按钮点击事件
+ // 关闭按钮
view.findViewById(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
+ }
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "feedback",
+ "element_id" to "commit_btn",
+ "content" to feedbackText
+ )
+ CoroutineScope(Dispatchers.Main).launch {
+ val response = submitFeedback(feedbackRequest(content = feedbackText))
+ if (response?.code == 0) {
+ Toast.makeText(context, "Feedback submitted successfully", Toast.LENGTH_SHORT).show()
+ parentFragmentManager.popBackStack()
+ } else {
+ Toast.makeText(context, "Failed to submit feedback", Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
}
-}
\ 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..5a6e0ea 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,393 @@
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
+import com.example.myapplication.network.BehaviorReporter
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) {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "person_info",
+ "element_id" to "avatar_edit",
+ )
+
+ 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/java/com/example/myapplication/ui/shop/ShopFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt
index 0b14ad5..b768366 100644
--- a/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/shop/ShopFragment.kt
@@ -29,6 +29,7 @@ import com.google.android.material.appbar.AppBarLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
+import com.example.myapplication.network.BehaviorReporter
class ShopFragment : Fragment(R.layout.fragment_shop) {
@@ -239,6 +240,10 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
}
}
}
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "shop_item_list",
+ )
}
}
@@ -400,10 +405,22 @@ class ShopFragment : Fragment(R.layout.fragment_shop) {
AuthEventBus.emit(AuthEvent.OpenGlobalPage(R.id.goldCoinRechargeFragment))
}
view.findViewById(R.id.skinButton).setOnClickListener {
+ // 使用事件总线打开我的皮肤页面
findNavController().navigate(R.id.action_shopfragment_to_myskin)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "shop",
+ "element_id" to "my_skin_btn",
+ )
}
view.findViewById(R.id.searchButton).setOnClickListener {
+ // 使用事件总线打开搜索页面
findNavController().navigate(R.id.action_shopfragment_to_searchfragment)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "shop",
+ "element_id" to "search_btn",
+ )
}
}
diff --git a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt
index 4aafab6..4b5ba6d 100644
--- a/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt
+++ b/app/src/main/java/com/example/myapplication/ui/shop/ThemeCardAdapter.kt
@@ -17,6 +17,7 @@ import com.example.myapplication.network.AuthEvent
import com.example.myapplication.network.AuthEventBus
import com.example.myapplication.network.themeStyle
import com.google.android.material.card.MaterialCardView
+import com.example.myapplication.network.BehaviorReporter
class ThemeCardAdapter : ListAdapter(DiffCallback) {
@@ -56,6 +57,11 @@ class ThemeCardAdapter : ListAdapter Unit,
@@ -94,11 +96,18 @@ class MySkinAdapter(
}
holder.itemView.setOnClickListener {
+
if (editMode) {
if (selected) selectedIds.remove(item.id) else selectedIds.add(item.id)
onSelectionChanged(selectedIds.size)
notifyItemChanged(position)
} else {
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "my_skin",
+ "element_id" to "theme_card",
+ "theme_id" to item.id
+ )
// 跳转到主题详情页面
val bundle = Bundle().apply {
putInt("themeId", item.id)
diff --git a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt
index 00f6959..f36a359 100644
--- a/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt
+++ b/app/src/main/java/com/example/myapplication/ui/shop/search/SearchFragment.kt
@@ -23,6 +23,7 @@ import com.example.myapplication.ui.shop.ThemeCardAdapter
import com.google.android.flexbox.FlexboxLayout
import com.google.android.flexbox.FlexboxLayout.LayoutParams
import kotlinx.coroutines.launch
+import com.example.myapplication.network.BehaviorReporter
@@ -86,7 +87,13 @@ class SearchFragment : Fragment() {
// 把搜索词放进 Bundle
val bundle = bundleOf("search_keyword" to keyword)
-
+
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "search",
+ "element_id" to "search_submit",
+ "keyword" to bundle,
+ )
// 跳转时带上 bundle
findNavController().navigate(
R.id.action_searchFragment_to_searchResultFragment,
@@ -102,6 +109,10 @@ class SearchFragment : Fragment() {
// 清空历史记录
view.findViewById(R.id.iv_delete_history).setOnClickListener {
clearHistory()
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "search",
+ )
}
}
@@ -155,8 +166,13 @@ class SearchFragment : Fragment() {
tv.setOnClickListener {
etInput.setText(keyword)
etInput.setSelection(keyword.length)
+ BehaviorReporter.report(
+ isNewUser = false,
+ "page_id" to "search",
+ "element_id" to "history_item",
+ "keyword" to keyword,
+ )
}
-
return tv
}
diff --git a/app/src/main/res/drawable/ai_caard_bg.xml b/app/src/main/res/drawable/ai_caard_bg.xml
new file mode 100644
index 0000000..9ee15f6
--- /dev/null
+++ b/app/src/main/res/drawable/ai_caard_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
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 0000000..d3c9809
Binary files /dev/null and b/app/src/main/res/drawable/associate_close.png differ
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 0000000..99249b9
Binary files /dev/null and b/app/src/main/res/drawable/first_add.png differ
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/keyboard_button_bg4.xml b/app/src/main/res/drawable/keyboard_button_bg4.xml
index 235ad94..087d556 100644
--- a/app/src/main/res/drawable/keyboard_button_bg4.xml
+++ b/app/src/main/res/drawable/keyboard_button_bg4.xml
@@ -1,5 +1,5 @@
-
+
\ 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 0000000..52a253d
Binary files /dev/null and b/app/src/main/res/drawable/operation_add.png differ
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 0000000..1a580a9
Binary files /dev/null and b/app/src/main/res/drawable/pop_collapse.png differ
diff --git a/app/src/main/res/drawable/record.png b/app/src/main/res/drawable/record.png
new file mode 100644
index 0000000..c92289f
Binary files /dev/null and b/app/src/main/res/drawable/record.png differ
diff --git a/app/src/main/res/drawable/round_bg_others_add.png b/app/src/main/res/drawable/round_bg_others_add.png
new file mode 100644
index 0000000..5bf988a
Binary files /dev/null and b/app/src/main/res/drawable/round_bg_others_add.png differ
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 0000000..922be08
Binary files /dev/null and b/app/src/main/res/drawable/second_add.png differ
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 0000000..1e474cc
Binary files /dev/null and b/app/src/main/res/drawable/third_add.png differ
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" />
+
+
+
+
+
diff --git a/app/src/main/res/layout/ai_keyboard.xml b/app/src/main/res/layout/ai_keyboard.xml
index adb2348..a84958e 100644
--- a/app/src/main/res/layout/ai_keyboard.xml
+++ b/app/src/main/res/layout/ai_keyboard.xml
@@ -13,6 +13,7 @@
android:layout_marginTop="3dp"
android:paddingStart="12dp"
android:paddingEnd="8dp">
+
+ app:layout_constraintBottom_toBottomOf="parent" />
-
+
+
-
+
+
+
+ android:src="@drawable/copy_the_message" />
+
-
+
-
-
-
-
-
-
-
-
+ app:justifyContent="flex_start"
+ app:alignContent="flex_start"/>
-
-
-
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:id="@+id/keyboard_button_1"
+ android:layout_marginStart="4dp"
+ android:orientation="vertical">
-
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+ android:orientation="vertical" />
+
-
-
diff --git a/app/src/main/res/layout/bottom_page_list1.xml b/app/src/main/res/layout/bottom_page_list1.xml
index 525dedf..afcdd61 100644
--- a/app/src/main/res/layout/bottom_page_list1.xml
+++ b/app/src/main/res/layout/bottom_page_list1.xml
@@ -62,18 +62,20 @@
android:text="Loading..."
android:textSize="10sp"
android:textColor="#1B1F1A" />
-
-
+ 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_ai_persona_card.xml b/app/src/main/res/layout/item_ai_persona_card.xml
new file mode 100644
index 0000000..2b258e4
--- /dev/null
+++ b/app/src/main/res/layout/item_ai_persona_card.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_keyboard_character.xml b/app/src/main/res/layout/item_keyboard_character.xml
new file mode 100644
index 0000000..2d15d1d
--- /dev/null
+++ b/app/src/main/res/layout/item_keyboard_character.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
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..2260d70 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/number_keyboard.xml b/app/src/main/res/layout/number_keyboard.xml
index 73c40fb..5f8fd9d 100644
--- a/app/src/main/res/layout/number_keyboard.xml
+++ b/app/src/main/res/layout/number_keyboard.xml
@@ -68,8 +68,8 @@
android:orientation="horizontal">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/layout/symbol_keyboard.xml b/app/src/main/res/layout/symbol_keyboard.xml
index 0cf04cf..62596e7 100644
--- a/app/src/main/res/layout/symbol_keyboard.xml
+++ b/app/src/main/res/layout/symbol_keyboard.xml
@@ -62,16 +62,16 @@
android:gravity="center_horizontal"
android:orientation="horizontal">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -82,16 +82,16 @@
android:gravity="center_horizontal"
android:orientation="horizontal">
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -101,13 +101,13 @@
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="horizontal">
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -117,9 +117,9 @@
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="horizontal">
-
-
-
-
+
+
+
+
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" />
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
9
Shift
Space
- Backspace
+ Backspace
ABC/123
,
.
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 @@
+
+
+
+