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 @@ + + + +