From 42d56dfdc88da55e912835c70a8787b4b54a67f8 Mon Sep 17 00:00:00 2001 From: zx <2396494751@qq.com> Date: Wed, 21 Jan 2026 19:18:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=A2=9E=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Notes-master/res/drawable/category_bg.xml | 11 ++ .../res/drawable/ic_launcher_background.xml | 170 ++++++++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 ++++ .../res/drawable/mi_logo_background.xml | 8 + src/Notes-master/res/layout/activity_main.xml | 19 ++ .../res/layout/activity_splash.xml | 30 ++++ .../res/mipmap-anydpi/ic_launcher.xml | 6 + .../res/mipmap-anydpi/ic_launcher_round.xml | 6 + .../res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes src/Notes-master/res/values-night/themes.xml | 7 + src/Notes-master/res/values/themes.xml | 9 + src/Notes-master/res/xml/backup_rules.xml | 13 ++ .../res/xml/data_extraction_rules.xml | 19 ++ .../src/net/micode/notes/MainActivity.java | 24 +++ .../net/micode/notes/tool/CategoryUtil.java | 90 ++++++++++ .../net/micode/notes/ui/SplashActivity.java | 56 ++++++ 25 files changed, 498 insertions(+) create mode 100644 src/Notes-master/res/drawable/category_bg.xml create mode 100644 src/Notes-master/res/drawable/ic_launcher_background.xml create mode 100644 src/Notes-master/res/drawable/ic_launcher_foreground.xml create mode 100644 src/Notes-master/res/drawable/mi_logo_background.xml create mode 100644 src/Notes-master/res/layout/activity_main.xml create mode 100644 src/Notes-master/res/layout/activity_splash.xml create mode 100644 src/Notes-master/res/mipmap-anydpi/ic_launcher.xml create mode 100644 src/Notes-master/res/mipmap-anydpi/ic_launcher_round.xml create mode 100644 src/Notes-master/res/mipmap-hdpi/ic_launcher.webp create mode 100644 src/Notes-master/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 src/Notes-master/res/mipmap-mdpi/ic_launcher.webp create mode 100644 src/Notes-master/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 src/Notes-master/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 src/Notes-master/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 src/Notes-master/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 src/Notes-master/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 src/Notes-master/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 src/Notes-master/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 src/Notes-master/res/values-night/themes.xml create mode 100644 src/Notes-master/res/values/themes.xml create mode 100644 src/Notes-master/res/xml/backup_rules.xml create mode 100644 src/Notes-master/res/xml/data_extraction_rules.xml create mode 100644 src/Notes-master/src/net/micode/notes/MainActivity.java create mode 100644 src/Notes-master/src/net/micode/notes/tool/CategoryUtil.java create mode 100644 src/Notes-master/src/net/micode/notes/ui/SplashActivity.java diff --git a/src/Notes-master/res/drawable/category_bg.xml b/src/Notes-master/res/drawable/category_bg.xml new file mode 100644 index 0000000..f9419de --- /dev/null +++ b/src/Notes-master/res/drawable/category_bg.xml @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/drawable/ic_launcher_background.xml b/src/Notes-master/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/src/Notes-master/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Notes-master/res/drawable/ic_launcher_foreground.xml b/src/Notes-master/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/src/Notes-master/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/drawable/mi_logo_background.xml b/src/Notes-master/res/drawable/mi_logo_background.xml new file mode 100644 index 0000000..80c49d0 --- /dev/null +++ b/src/Notes-master/res/drawable/mi_logo_background.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/layout/activity_main.xml b/src/Notes-master/res/layout/activity_main.xml new file mode 100644 index 0000000..86a5d97 --- /dev/null +++ b/src/Notes-master/res/layout/activity_main.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/layout/activity_splash.xml b/src/Notes-master/res/layout/activity_splash.xml new file mode 100644 index 0000000..3e060a4 --- /dev/null +++ b/src/Notes-master/res/layout/activity_splash.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/mipmap-anydpi/ic_launcher.xml b/src/Notes-master/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/src/Notes-master/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/mipmap-anydpi/ic_launcher_round.xml b/src/Notes-master/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/src/Notes-master/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Notes-master/res/mipmap-hdpi/ic_launcher.webp b/src/Notes-master/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/src/Notes-master/res/mipmap-hdpi/ic_launcher_round.webp b/src/Notes-master/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 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 literal 0 HcmV?d00001 diff --git a/src/Notes-master/res/mipmap-mdpi/ic_launcher.webp b/src/Notes-master/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/src/Notes-master/res/mipmap-xhdpi/ic_launcher.webp b/src/Notes-master/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/src/Notes-master/res/mipmap-xhdpi/ic_launcher_round.webp b/src/Notes-master/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 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�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} 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

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/src/Notes-master/res/values-night/themes.xml b/src/Notes-master/res/values-night/themes.xml new file mode 100644 index 0000000..d2c68d1 --- /dev/null +++ b/src/Notes-master/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/Notes-master/res/values/themes.xml b/src/Notes-master/res/values/themes.xml new file mode 100644 index 0000000..7c616ff --- /dev/null +++ b/src/Notes-master/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + @@ -63,7 +63,16 @@ + + + \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/SplashActivity.java b/src/Notes-master/src/net/micode/notes/SplashActivity.java new file mode 100644 index 0000000..dc808b6 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/SplashActivity.java @@ -0,0 +1,30 @@ +package net.micode.notes; + +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; + +import net.micode.notes.ui.LoginActivity; +import net.micode.notes.ui.NotesListActivity; +import androidx.appcompat.app.AppCompatActivity; + +public class SplashActivity extends AppCompatActivity { + + private static final long SPLASH_DURATION = 1500; // 1.5秒的启动动画 + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_splash); + + // 延迟后跳转到登录界面 + new Handler().postDelayed(new Runnable() { + @Override + public void run() { + Intent intent = new Intent(SplashActivity.this, LoginActivity.class); + startActivity(intent); + finish(); + } + }, SPLASH_DURATION); + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/data/Messages.java b/src/Notes-master/src/net/micode/notes/data/Messages.java new file mode 100644 index 0000000..d6f2629 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/data/Messages.java @@ -0,0 +1,23 @@ +package net.micode.notes.data; + +public class Messages { + + public interface MessageColumns { + public static final String ID = "id"; + public static final String SENDER_ID = "sender_id"; + public static final String RECEIVER_ID = "receiver_id"; + public static final String CONTENT = "content"; + public static final String MESSAGE_TYPE = "message_type"; + public static final String CREATED_DATE = "created_date"; + public static final String IS_READ = "is_read"; + } + + public interface MessageType { + public static final int TEXT = 0; + public static final int IMAGE = 1; + public static final int EMOTION = 2; + public static final int NOTE = 3; + } + + public static final long ID_INVALID = -1; +} diff --git a/src/Notes-master/src/net/micode/notes/data/Notes.java b/src/Notes-master/src/net/micode/notes/data/Notes.java index f240604..ec79df1 100644 --- a/src/Notes-master/src/net/micode/notes/data/Notes.java +++ b/src/Notes-master/src/net/micode/notes/data/Notes.java @@ -98,6 +98,12 @@ public class Notes { *

Type: TEXT

*/ public static final String SNIPPET = "snippet"; + + /** + * Note's title + *

Type: TEXT

+ */ + public static final String TITLE = "title"; /** * Note's widget id @@ -165,6 +171,36 @@ public class Notes { *

Type : INTEGER (long)

*/ public static final String VERSION = "version"; + + /** + * Whether the note is pinned + *

Type: INTEGER (1=pinned, 0=unpinned)

+ */ + public static final String PINNED = "pinned"; + + /** + * Sort order for notes in the same folder + *

Type: INTEGER

+ */ + public static final String SORT_ORDER = "sort_order"; + + /** + * Whether the note is locked + *

Type: INTEGER (1=locked, 0=unlocked)

+ */ + public static final String LOCKED = "locked"; + + /** + * The user id that this note belongs to + *

Type: INTEGER (long)

+ */ + public static final String USER_ID = "user_id"; + + /** + * Whether the note is public + *

Type: INTEGER (1=public, 0=private)

+ */ + public static final String PUBLIC = "public"; } public interface DataColumns { diff --git a/src/Notes-master/src/net/micode/notes/data/NotesDatabaseHelper.java b/src/Notes-master/src/net/micode/notes/data/NotesDatabaseHelper.java index ffe5d57..0ef5b73 100644 --- a/src/Notes-master/src/net/micode/notes/data/NotesDatabaseHelper.java +++ b/src/Notes-master/src/net/micode/notes/data/NotesDatabaseHelper.java @@ -25,24 +25,30 @@ import android.util.Log; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.DataConstants; import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.Messages; +import net.micode.notes.data.Users; public class NotesDatabaseHelper extends SQLiteOpenHelper { private static final String DB_NAME = "note.db"; - private static final int DB_VERSION = 4; + private static final int DB_VERSION = 12; public interface TABLE { public static final String NOTE = "note"; public static final String DATA = "data"; + + public static final String USER = "user"; + + public static final String MESSAGE = "message"; } private static final String TAG = "NotesDatabaseHelper"; private static NotesDatabaseHelper mInstance; - private static final String CREATE_NOTE_TABLE_SQL = + private static final String CREATE_NOTE_TABLE_SQL = "CREATE TABLE " + TABLE.NOTE + "(" + NoteColumns.ID + " INTEGER PRIMARY KEY," + NoteColumns.PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + @@ -53,6 +59,7 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + NoteColumns.NOTES_COUNT + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.SNIPPET + " TEXT NOT NULL DEFAULT ''," + + NoteColumns.TITLE + " TEXT NOT NULL DEFAULT ''," + NoteColumns.TYPE + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.WIDGET_ID + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.WIDGET_TYPE + " INTEGER NOT NULL DEFAULT -1," + @@ -60,7 +67,12 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { NoteColumns.LOCAL_MODIFIED + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.ORIGIN_PARENT_ID + " INTEGER NOT NULL DEFAULT 0," + NoteColumns.GTASK_ID + " TEXT NOT NULL DEFAULT ''," + - NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0" + + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.PINNED + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.SORT_ORDER + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.LOCKED + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.USER_ID + " INTEGER NOT NULL DEFAULT 0," + + NoteColumns.PUBLIC + " INTEGER NOT NULL DEFAULT 0" + ")"; private static final String CREATE_DATA_TABLE_SQL = @@ -82,6 +94,26 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { "CREATE INDEX IF NOT EXISTS note_id_index ON " + TABLE.DATA + "(" + DataColumns.NOTE_ID + ");"; + private static final String CREATE_USER_TABLE_SQL = + "CREATE TABLE " + TABLE.USER + "(" + + Users.UserColumns.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Users.UserColumns.USERNAME + " TEXT NOT NULL UNIQUE," + + Users.UserColumns.PASSWORD + " TEXT NOT NULL," + + Users.UserColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + Users.UserColumns.MODIFIED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)" + + ")"; + + private static final String CREATE_MESSAGE_TABLE_SQL = + "CREATE TABLE " + TABLE.MESSAGE + "(" + + Messages.MessageColumns.ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + Messages.MessageColumns.SENDER_ID + " INTEGER NOT NULL," + + Messages.MessageColumns.RECEIVER_ID + " INTEGER NOT NULL," + + Messages.MessageColumns.CONTENT + " TEXT NOT NULL," + + Messages.MessageColumns.MESSAGE_TYPE + " INTEGER NOT NULL DEFAULT 0," + + Messages.MessageColumns.CREATED_DATE + " INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)," + + Messages.MessageColumns.IS_READ + " INTEGER NOT NULL DEFAULT 0" + + ")"; + /** * Increase folder's note count when move note to the folder */ @@ -287,17 +319,29 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL(DATA_UPDATE_NOTE_CONTENT_ON_DELETE_TRIGGER); } - static synchronized NotesDatabaseHelper getInstance(Context context) { + public static synchronized NotesDatabaseHelper getInstance(Context context) { if (mInstance == null) { mInstance = new NotesDatabaseHelper(context); } return mInstance; } + public void createUserTable(SQLiteDatabase db) { + db.execSQL(CREATE_USER_TABLE_SQL); + Log.d(TAG, "user table has been created"); + } + + public void createMessageTable(SQLiteDatabase db) { + db.execSQL(CREATE_MESSAGE_TABLE_SQL); + Log.d(TAG, "message table has been created"); + } + @Override public void onCreate(SQLiteDatabase db) { createNoteTable(db); createDataTable(db); + createUserTable(db); + createMessageTable(db); } @Override @@ -322,6 +366,46 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { oldVersion++; } + if (oldVersion == 4) { + upgradeToV5(db); + oldVersion++; + } + + if (oldVersion == 5) { + upgradeToV6(db); + oldVersion++; + } + + if (oldVersion == 6) { + upgradeToV7(db); + oldVersion++; + } + + if (oldVersion == 7) { + createUserTable(db); + oldVersion++; + } + + if (oldVersion == 8) { + upgradeToV9(db); + oldVersion++; + } + + if (oldVersion == 9) { + upgradeToV10(db); + oldVersion++; + } + + if (oldVersion == 10) { + upgradeToV11(db); + oldVersion++; + } + + if (oldVersion == 11) { + createMessageTable(db); + oldVersion++; + } + if (reCreateTriggers) { reCreateNoteTableTriggers(db); reCreateDataTableTriggers(db); @@ -359,4 +443,34 @@ public class NotesDatabaseHelper extends SQLiteOpenHelper { db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.VERSION + " INTEGER NOT NULL DEFAULT 0"); } + + private void upgradeToV5(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.PINNED + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV6(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.SORT_ORDER + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV7(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.LOCKED + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV9(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.USER_ID + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV10(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.PUBLIC + + " INTEGER NOT NULL DEFAULT 0"); + } + + private void upgradeToV11(SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + TABLE.NOTE + " ADD COLUMN " + NoteColumns.TITLE + + " TEXT NOT NULL DEFAULT ''"); + } } diff --git a/src/Notes-master/src/net/micode/notes/data/NotesProvider.java b/src/Notes-master/src/net/micode/notes/data/NotesProvider.java index edb0a60..6a59beb 100644 --- a/src/Notes-master/src/net/micode/notes/data/NotesProvider.java +++ b/src/Notes-master/src/net/micode/notes/data/NotesProvider.java @@ -33,6 +33,7 @@ import net.micode.notes.R; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.NotesDatabaseHelper.TABLE; +import net.micode.notes.tool.UserManager; public class NotesProvider extends ContentProvider { @@ -89,60 +90,193 @@ public class NotesProvider extends ContentProvider { public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { Cursor c = null; - SQLiteDatabase db = mHelper.getReadableDatabase(); + SQLiteDatabase db = null; String id = null; - switch (mMatcher.match(uri)) { - case URI_NOTE: - c = db.query(TABLE.NOTE, projection, selection, selectionArgs, null, null, - sortOrder); - break; - case URI_NOTE_ITEM: - id = uri.getPathSegments().get(1); - c = db.query(TABLE.NOTE, projection, NoteColumns.ID + "=" + id - + parseSelection(selection), selectionArgs, null, null, sortOrder); - break; - case URI_DATA: - c = db.query(TABLE.DATA, projection, selection, selectionArgs, null, null, - sortOrder); - break; - case URI_DATA_ITEM: - id = uri.getPathSegments().get(1); - c = db.query(TABLE.DATA, projection, DataColumns.ID + "=" + id - + parseSelection(selection), selectionArgs, null, null, sortOrder); - break; - case URI_SEARCH: - case URI_SEARCH_SUGGEST: - if (sortOrder != null || projection != null) { - throw new IllegalArgumentException( - "do not specify sortOrder, selection, selectionArgs, or projection" + "with this query"); - } + + try { + db = mHelper.getReadableDatabase(); + + // 获取当前用户ID,添加空指针检查 + long currentUserId = 0; + if (getContext() != null) { + currentUserId = UserManager.getInstance(getContext()).getCurrentUserId(); + } + + // 构建用户ID过滤条件,确保currentUserId不为-1 + long userIdForFilter = currentUserId > 0 ? currentUserId : 0; + String userFilter = NoteColumns.USER_ID + " = " + userIdForFilter; + + switch (mMatcher.match(uri)) { + case URI_NOTE: + // 构建查询条件: + // 1. 如果selection中包含USER_ID条件,说明是查询特定用户的便签,直接使用selection + // 2. 如果selection中包含PUBLIC条件,说明是查询公开便签,直接使用selection + // 3. 否则,系统文件夹(ID <= 0)不受用户ID限制,普通便签需要匹配当前用户ID + String finalSelection; + if (TextUtils.isEmpty(selection)) { + finalSelection = "(" + NoteColumns.ID + " <= 0) OR (" + userFilter + ")"; + } else { + // 检查selection中是否包含USER_ID或PUBLIC条件 + if (selection.contains(NoteColumns.USER_ID) || selection.contains(NoteColumns.PUBLIC)) { + // 如果包含USER_ID或PUBLIC条件,直接使用selection,绕过当前用户过滤 + finalSelection = selection; + } else { + // 否则,添加用户ID过滤 + finalSelection = "((" + NoteColumns.ID + " <= 0) OR (" + userFilter + ")) AND (" + selection + ")"; + } + } + c = db.query(TABLE.NOTE, projection, finalSelection, selectionArgs, null, null, + sortOrder); + break; + case URI_NOTE_ITEM: + id = uri.getPathSegments().get(1); + // 对于单个便签详情,不添加用户过滤条件,允许访问任何便签 + // 因为FriendNoteListActivity已经确保只显示公开便签 + if (TextUtils.isEmpty(selection)) { + selection = null; + } + c = db.query(TABLE.NOTE, projection, NoteColumns.ID + "=" + id + + parseSelection(selection), selectionArgs, null, null, sortOrder); + break; + case URI_DATA: + // 处理DATA表查询,需要考虑便签的PUBLIC属性 + if (selection != null && selection.contains(DataColumns.NOTE_ID)) { + // 如果查询包含NOTE_ID,需要确保对应的便签是公开的或属于当前用户 + try { + // 从selection中提取note_id值 + String noteIdStr = selection; + noteIdStr = noteIdStr.substring(noteIdStr.indexOf('=') + 1); + if (noteIdStr.contains("AND")) { + noteIdStr = noteIdStr.substring(0, noteIdStr.indexOf("AND")).trim(); + } + long noteId = Long.parseLong(noteIdStr); + + // 查询对应便签的PUBLIC和USER_ID属性 + Cursor noteCursor = db.query( + TABLE.NOTE, + new String[]{NoteColumns.PUBLIC, NoteColumns.USER_ID}, + NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)}, + null, + null, + null + ); + + boolean canAccess = false; + if (noteCursor != null && noteCursor.moveToFirst()) { + int isPublic = noteCursor.getInt(0); + long noteUserId = noteCursor.getLong(1); + // 允许访问的条件:便签是公开的或便签属于当前用户 + canAccess = (isPublic == 1 || noteUserId == currentUserId); + noteCursor.close(); + } + + if (!canAccess) { + // 如果不能访问,返回空cursor + return null; + } + } catch (Exception e) { + // 如果解析失败,默认允许访问,避免误判 + Log.e(TAG, "Error parsing note_id from selection: " + e.getMessage()); + } + } + c = db.query(TABLE.DATA, projection, selection, selectionArgs, null, null, + sortOrder); + break; + case URI_DATA_ITEM: + // 处理单个DATA项查询,同样需要考虑便签的PUBLIC属性 + id = uri.getPathSegments().get(1); + // 先查询该DATA项对应的note_id + Cursor dataCursor = null; + boolean canAccess = false; + try { + dataCursor = db.query( + TABLE.DATA, + new String[]{DataColumns.NOTE_ID}, + DataColumns.ID + "=?", + new String[]{id}, + null, + null, + null + ); + + if (dataCursor != null && dataCursor.moveToFirst()) { + long noteId = dataCursor.getLong(0); + + // 查询对应便签的PUBLIC和USER_ID属性 + Cursor noteCursor = null; + try { + noteCursor = db.query( + TABLE.NOTE, + new String[]{NoteColumns.PUBLIC, NoteColumns.USER_ID}, + NoteColumns.ID + "=?", + new String[]{String.valueOf(noteId)}, + null, + null, + null + ); + + if (noteCursor != null && noteCursor.moveToFirst()) { + int isPublic = noteCursor.getInt(0); + long noteUserId = noteCursor.getLong(1); + // 允许访问的条件:便签是公开的或便签属于当前用户 + canAccess = (isPublic == 1 || noteUserId == currentUserId); + } + } finally { + if (noteCursor != null) { + noteCursor.close(); + } + } + } + } finally { + if (dataCursor != null) { + dataCursor.close(); + } + } + + if (canAccess) { + c = db.query(TABLE.DATA, projection, DataColumns.ID + "=" + id + + parseSelection(selection), selectionArgs, null, null, sortOrder); + } + // 如果不能访问,返回null,默认处理 + break; + case URI_SEARCH: + case URI_SEARCH_SUGGEST: + if (sortOrder != null || projection != null) { + throw new IllegalArgumentException( + "do not specify sortOrder, selection, selectionArgs, or projection" + "with this query"); + } - String searchString = null; - if (mMatcher.match(uri) == URI_SEARCH_SUGGEST) { - if (uri.getPathSegments().size() > 1) { - searchString = uri.getPathSegments().get(1); + String searchString = null; + if (mMatcher.match(uri) == URI_SEARCH_SUGGEST) { + if (uri.getPathSegments().size() > 1) { + searchString = uri.getPathSegments().get(1); + } + } else { + searchString = uri.getQueryParameter("pattern"); } - } else { - searchString = uri.getQueryParameter("pattern"); - } - if (TextUtils.isEmpty(searchString)) { - return null; - } + if (TextUtils.isEmpty(searchString)) { + return null; + } - try { - searchString = String.format("%%%s%%", searchString); - c = db.rawQuery(NOTES_SNIPPET_SEARCH_QUERY, - new String[] { searchString }); - } catch (IllegalStateException ex) { - Log.e(TAG, "got exception: " + ex.toString()); - } - break; - default: - throw new IllegalArgumentException("Unknown URI " + uri); - } - if (c != null) { - c.setNotificationUri(getContext().getContentResolver(), uri); + try { + searchString = String.format("%%%s%%", searchString); + c = db.rawQuery(NOTES_SNIPPET_SEARCH_QUERY, + new String[] { searchString }); + } catch (IllegalStateException ex) { + Log.e(TAG, "got exception: " + ex.toString()); + } + break; + default: + throw new IllegalArgumentException("Unknown URI " + uri); + } + if (c != null) { + c.setNotificationUri(getContext().getContentResolver(), uri); + } + } catch (Exception e) { + Log.e(TAG, "query exception: " + e.toString()); + e.printStackTrace(); } return c; } @@ -151,8 +285,17 @@ public class NotesProvider extends ContentProvider { public Uri insert(Uri uri, ContentValues values) { SQLiteDatabase db = mHelper.getWritableDatabase(); long dataId = 0, noteId = 0, insertedId = 0; + + // 获取当前用户ID,添加空指针检查 + long currentUserId = 0; + if (getContext() != null) { + currentUserId = UserManager.getInstance(getContext()).getCurrentUserId(); + } + switch (mMatcher.match(uri)) { case URI_NOTE: + // 插入新便签时添加当前用户ID + values.put(NoteColumns.USER_ID, currentUserId); insertedId = noteId = db.insert(TABLE.NOTE, null, values); break; case URI_DATA: @@ -187,8 +330,24 @@ public class NotesProvider extends ContentProvider { String id = null; SQLiteDatabase db = mHelper.getWritableDatabase(); boolean deleteData = false; + + // 获取当前用户ID,添加空指针检查 + long currentUserId = 0; + if (getContext() != null) { + currentUserId = UserManager.getInstance(getContext()).getCurrentUserId(); + } + + // 构建用户ID过滤条件 + String userFilter = NoteColumns.USER_ID + " = " + currentUserId; + switch (mMatcher.match(uri)) { case URI_NOTE: + // 添加用户ID过滤 + if (TextUtils.isEmpty(selection)) { + selection = userFilter; + } else { + selection = userFilter + " AND (" + selection + ")"; + } selection = "(" + selection + ") AND " + NoteColumns.ID + ">0 "; count = db.delete(TABLE.NOTE, selection, selectionArgs); break; @@ -202,6 +361,12 @@ public class NotesProvider extends ContentProvider { if (noteId <= 0) { break; } + // 添加用户ID过滤 + if (TextUtils.isEmpty(selection)) { + selection = userFilter; + } else { + selection = userFilter + " AND (" + selection + ")"; + } count = db.delete(TABLE.NOTE, NoteColumns.ID + "=" + id + parseSelection(selection), selectionArgs); break; @@ -233,13 +398,30 @@ public class NotesProvider extends ContentProvider { String id = null; SQLiteDatabase db = mHelper.getWritableDatabase(); boolean updateData = false; + + // 获取当前用户ID,添加空指针检查 + long currentUserId = 0; + if (getContext() != null) { + currentUserId = UserManager.getInstance(getContext()).getCurrentUserId(); + } + + // 构建用户ID过滤条件 + String userFilter = NoteColumns.USER_ID + " = " + currentUserId; + switch (mMatcher.match(uri)) { case URI_NOTE: + // 添加用户ID过滤 + if (TextUtils.isEmpty(selection)) { + selection = userFilter; + } else { + selection = userFilter + " AND (" + selection + ")"; + } increaseNoteVersion(-1, selection, selectionArgs); count = db.update(TABLE.NOTE, values, selection, selectionArgs); break; case URI_NOTE_ITEM: id = uri.getPathSegments().get(1); + // 对于单个便签更新,NoteColumns.ID是主键,已经足够唯一,不需要添加用户ID过滤 increaseNoteVersion(Long.valueOf(id), selection, selectionArgs); count = db.update(TABLE.NOTE, values, NoteColumns.ID + "=" + id + parseSelection(selection), selectionArgs); diff --git a/src/Notes-master/src/net/micode/notes/data/Users.java b/src/Notes-master/src/net/micode/notes/data/Users.java new file mode 100644 index 0000000..1ba7b7f --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/data/Users.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.data; + +public class Users { + public interface UserColumns { + public static final String ID = "_id"; + public static final String USERNAME = "username"; + public static final String PASSWORD = "password"; + public static final String CREATED_DATE = "created_date"; + public static final String MODIFIED_DATE = "modified_date"; + } + + public interface UserConstants { + // 可以添加一些用户相关的常量 + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/gtask/remote/GTaskASyncTask.java b/src/Notes-master/src/net/micode/notes/gtask/remote/GTaskASyncTask.java index b3b61e7..08d20f7 100644 --- a/src/Notes-master/src/net/micode/notes/gtask/remote/GTaskASyncTask.java +++ b/src/Notes-master/src/net/micode/notes/gtask/remote/GTaskASyncTask.java @@ -63,25 +63,43 @@ public class GTaskASyncTask extends AsyncTask { }); } - private void showNotification(int tickerId, String content) { - Notification notification = new Notification(R.drawable.notification, mContext - .getString(tickerId), System.currentTimeMillis()); - notification.defaults = Notification.DEFAULT_LIGHTS; - notification.flags = Notification.FLAG_AUTO_CANCEL; - PendingIntent pendingIntent; - if (tickerId != R.string.ticker_success) { - pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, - NotesPreferenceActivity.class), 0); - - } else { - pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, - NotesListActivity.class), 0); - } - notification.setLatestEventInfo(mContext, mContext.getString(R.string.app_name), content, - pendingIntent); - mNotifiManager.notify(GTASK_SYNC_NOTIFICATION_ID, notification); +// private void showNotification(int tickerId, String content) { +// Notification notification = new Notification(R.drawable.notification, mContext +// .getString(tickerId), System.currentTimeMillis()); +// notification.defaults = Notification.DEFAULT_LIGHTS; +// notification.flags = Notification.FLAG_AUTO_CANCEL; +// PendingIntent pendingIntent; +// if (tickerId != R.string.ticker_success) { +// pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, +// NotesPreferenceActivity.class), 0); +// +// } else { +// pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, +// NotesListActivity.class), 0); +// } +// notification.setLatestEventInfo(mContext, mContext.getString(R.string.app_name), content, +// pendingIntent); +// mNotifiManager.notify(GTASK_SYNC_NOTIFICATION_ID, notification); +// } +private void showNotification(int tickerId, String content) { + PendingIntent pendingIntent; + if (tickerId != R.string.ticker_success) { + pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, + NotesPreferenceActivity.class), PendingIntent.FLAG_IMMUTABLE); + } else { + pendingIntent = PendingIntent.getActivity(mContext, 0, new Intent(mContext, + NotesListActivity.class), PendingIntent.FLAG_IMMUTABLE); } - + Notification.Builder builder = new Notification.Builder(mContext) + .setAutoCancel(true) + .setContentTitle(mContext.getString(R.string.app_name)) + .setContentText(content) + .setContentIntent(pendingIntent) + .setWhen(System.currentTimeMillis()) + .setOngoing(true); + Notification notification=builder.getNotification(); + mNotifiManager.notify(GTASK_SYNC_NOTIFICATION_ID, notification); +} @Override protected Integer doInBackground(Void... unused) { publishProgess(mContext.getString(R.string.sync_progress_login, NotesPreferenceActivity diff --git a/src/Notes-master/src/net/micode/notes/model/Note.java b/src/Notes-master/src/net/micode/notes/model/Note.java index 6706cf6..5596030 100644 --- a/src/Notes-master/src/net/micode/notes/model/Note.java +++ b/src/Notes-master/src/net/micode/notes/model/Note.java @@ -30,6 +30,7 @@ import net.micode.notes.data.Notes.CallNote; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.NoteColumns; import net.micode.notes.data.Notes.TextNote; +import net.micode.notes.tool.UserManager; import java.util.ArrayList; @@ -50,14 +51,22 @@ public class Note { values.put(NoteColumns.TYPE, Notes.TYPE_NOTE); values.put(NoteColumns.LOCAL_MODIFIED, 1); values.put(NoteColumns.PARENT_ID, folderId); + // 设置当前用户ID + long currentUserId = UserManager.getInstance(context).getCurrentUserId(); + values.put(NoteColumns.USER_ID, currentUserId); Uri uri = context.getContentResolver().insert(Notes.CONTENT_NOTE_URI, values); long noteId = 0; - try { - noteId = Long.valueOf(uri.getPathSegments().get(1)); - } catch (NumberFormatException e) { - Log.e(TAG, "Get note id error :" + e.toString()); - noteId = 0; + if (uri != null) { + try { + noteId = Long.valueOf(uri.getPathSegments().get(1)); + } catch (NumberFormatException e) { + Log.e(TAG, "Get note id error :" + e.toString()); + noteId = 0; + } catch (IndexOutOfBoundsException e) { + Log.e(TAG, "Get note id error :" + e.toString()); + noteId = 0; + } } if (noteId == -1) { throw new IllegalStateException("Wrong note id:" + noteId); diff --git a/src/Notes-master/src/net/micode/notes/model/WorkingNote.java b/src/Notes-master/src/net/micode/notes/model/WorkingNote.java index be081e4..3762f45 100644 --- a/src/Notes-master/src/net/micode/notes/model/WorkingNote.java +++ b/src/Notes-master/src/net/micode/notes/model/WorkingNote.java @@ -39,6 +39,8 @@ public class WorkingNote { private long mNoteId; // Note content private String mContent; + // Note title + private String mTitle; // Note mode private int mMode; @@ -78,7 +80,8 @@ public class WorkingNote { NoteColumns.BG_COLOR_ID, NoteColumns.WIDGET_ID, NoteColumns.WIDGET_TYPE, - NoteColumns.MODIFIED_DATE + NoteColumns.MODIFIED_DATE, + NoteColumns.TITLE }; private static final int DATA_ID_COLUMN = 0; @@ -101,6 +104,8 @@ public class WorkingNote { private static final int NOTE_MODIFIED_DATE_COLUMN = 5; + private static final int NOTE_TITLE_COLUMN = 6; + // New note construct private WorkingNote(Context context, long folderId) { mContext = context; @@ -125,6 +130,7 @@ public class WorkingNote { } private void loadNote() { + // 执行查询,不添加用户过滤条件,因为NotesProvider已经处理了公开便签的访问权限 Cursor cursor = mContext.getContentResolver().query( ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mNoteId), NOTE_PROJECTION, null, null, null); @@ -137,6 +143,7 @@ public class WorkingNote { mWidgetType = cursor.getInt(NOTE_WIDGET_TYPE_COLUMN); mAlertDate = cursor.getLong(NOTE_ALERTED_DATE_COLUMN); mModifiedDate = cursor.getLong(NOTE_MODIFIED_DATE_COLUMN); + mTitle = cursor.getString(NOTE_TITLE_COLUMN); } cursor.close(); } else { @@ -149,9 +156,13 @@ public class WorkingNote { private void loadNoteData() { Cursor cursor = mContext.getContentResolver().query(Notes.CONTENT_DATA_URI, DATA_PROJECTION, DataColumns.NOTE_ID + "=?", new String[] { - String.valueOf(mNoteId) + String.valueOf(mNoteId) }, null); + // 初始化默认值 + mContent = ""; + mMode = 0; + if (cursor != null) { if (cursor.moveToFirst()) { do { @@ -170,12 +181,11 @@ public class WorkingNote { cursor.close(); } else { Log.e(TAG, "No data with id:" + mNoteId); - throw new IllegalArgumentException("Unable to find note's data with id " + mNoteId); } } public static WorkingNote createEmptyNote(Context context, long folderId, int widgetId, - int widgetType, int defaultBgColorId) { + int widgetType, int defaultBgColorId) { WorkingNote note = new WorkingNote(context, folderId); note.setBgColorId(defaultBgColorId); note.setWidgetId(widgetId); @@ -188,26 +198,54 @@ public class WorkingNote { } public synchronized boolean saveNote() { - if (isWorthSaving()) { - if (!existInDatabase()) { - if ((mNoteId = Note.getNewNoteId(mContext, mFolderId)) == 0) { - Log.e(TAG, "Create new note fail with id:" + mNoteId); - return false; + try { + if (isWorthSaving()) { + if (!existInDatabase()) { + if ((mNoteId = Note.getNewNoteId(mContext, mFolderId)) == 0) { + Log.e(TAG, "Create new note fail with id:" + mNoteId); + return false; + } } - } - mNote.syncNote(mContext, mNoteId); + mNote.syncNote(mContext, mNoteId); + + // 自动分类逻辑 + try { + // 优先使用标题分类,如果标题为空则使用内容分类 + String contentForCategory = mTitle; + if (contentForCategory == null || contentForCategory.isEmpty()) { + contentForCategory = mContent; + } + // 根据标题或内容自动分类 + String category = net.micode.notes.tool.CategoryUtil.autoCategorize(contentForCategory); + + // 创建或获取对应的文件夹 + long categoryFolderId = net.micode.notes.tool.DataUtils.createFolder(mContext.getContentResolver(), category); + if (categoryFolderId > 0 && mFolderId != categoryFolderId) { + // 将便签移动到分类文件夹 + net.micode.notes.tool.DataUtils.moveNoteToFoler(mContext.getContentResolver(), mNoteId, mFolderId, categoryFolderId); + mFolderId = categoryFolderId; + } + } catch (Exception e) { + Log.e(TAG, "Auto categorize fail: " + e.getMessage()); + // 自动分类失败不影响便签保存 + } - /** - * Update widget content if there exist any widget of this note - */ - if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID - && mWidgetType != Notes.TYPE_WIDGET_INVALIDE - && mNoteSettingStatusListener != null) { - mNoteSettingStatusListener.onWidgetChanged(); + /** + * Update widget content if there exist any widget of this note + */ + if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID + && mWidgetType != Notes.TYPE_WIDGET_INVALIDE + && mNoteSettingStatusListener != null) { + mNoteSettingStatusListener.onWidgetChanged(); + } + return true; + } else { + return false; } - return true; - } else { + } catch (Exception e) { + Log.e(TAG, "Save note fail: " + e.getMessage()); + e.printStackTrace(); return false; } } @@ -217,10 +255,10 @@ public class WorkingNote { } private boolean isWorthSaving() { - if (mIsDeleted || (!existInDatabase() && TextUtils.isEmpty(mContent)) - || (existInDatabase() && !mNote.isLocalModified())) { + if (mIsDeleted) { return false; } else { + // 允许保存空便签 return true; } } @@ -243,7 +281,7 @@ public class WorkingNote { mIsDeleted = mark; if (mWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID && mWidgetType != Notes.TYPE_WIDGET_INVALIDE && mNoteSettingStatusListener != null) { - mNoteSettingStatusListener.onWidgetChanged(); + mNoteSettingStatusListener.onWidgetChanged(); } } @@ -287,6 +325,17 @@ public class WorkingNote { mNote.setTextData(DataColumns.CONTENT, mContent); } } + + public void setWorkingTitle(String title) { + if (!TextUtils.equals(mTitle, title)) { + mTitle = title; + mNote.setNoteValue(NoteColumns.TITLE, title); + } + } + + public String getTitle() { + return mTitle; + } public void convertToCallNote(String phoneNumber, long callDate) { mNote.setCallData(CallNote.CALL_DATE, String.valueOf(callDate)); diff --git a/src/Notes-master/src/net/micode/notes/tool/BackupUtils.java b/src/Notes-master/src/net/micode/notes/tool/BackupUtils.java index 4734dec..befac70 100644 --- a/src/Notes-master/src/net/micode/notes/tool/BackupUtils.java +++ b/src/Notes-master/src/net/micode/notes/tool/BackupUtils.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,61 +14,46 @@ * limitations under the License. */ -// 包声明:归属小米便签的工具模块,提供笔记备份(导出为文本文件)核心功能 package net.micode.notes.tool; -// 导入安卓上下文类:访问资源、ContentResolver import android.content.Context; -// 导入安卓数据库游标类:查询便签/便签数据的核心载体 import android.database.Cursor; -// 导入安卓外部存储类:判断SD卡挂载状态、获取SD卡根目录 import android.os.Environment; -// 导入安卓文本工具类:判空、字符串处理 import android.text.TextUtils; -// 导入安卓日期格式化类:格式化便签修改时间、导出文件名 import android.text.format.DateFormat; -// 导入安卓日志类:输出备份过程中的日志(调试/错误) import android.util.Log; -// 导入小米便签资源类:引用字符串(文件路径、格式模板、文件夹名称) import net.micode.notes.R; -// 导入便签数据常量类:定义ContentURI、字段、便签类型、特殊文件夹ID等 import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.DataColumns; import net.micode.notes.data.Notes.DataConstants; import net.micode.notes.data.Notes.NoteColumns; -// 导入文件操作相关类:创建文件/目录、文件输出流、打印流 import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.PrintStream; + /** - * 便签备份工具类 - * 核心特性: - * 1. 单例模式:全局唯一实例,避免重复初始化资源; - * 2. 核心功能:将便签数据库中的所有有效便签(排除回收站)导出为SD卡上的文本文件; - * 3. 导出范围: - * - 所有文件夹(排除回收站)及文件夹下的便签; - * - 通话记录文件夹及其中的通话记录便签; - * - 根目录下的普通便签; - * 4. 导出格式:按文件夹分组,包含便签修改时间、内容(普通文本/通话记录详情); - * 5. 状态码机制:返回备份操作的结果(SD卡未挂载、文件创建失败、成功等)。 + * 便签备份工具类,用于将便签数据导出为文本文件 + *

+ * 该类采用单例模式,提供便签数据的文本导出功能,支持将便签内容(包括文件夹结构)导出到SD卡上的文本文件中, + * 方便用户备份和查看便签内容。 */ public class BackupUtils { - // 日志标签:用于备份过程的日志输出 private static final String TAG = "BackupUtils"; - - // 单例实例:全局唯一的BackupUtils对象 + // Singleton stuff private static BackupUtils sInstance; /** - * 获取单例实例(线程安全) - * 采用同步方法确保多线程环境下实例唯一,避免重复创建 - * @param context 应用上下文:用于初始化内部导出器、访问资源 - * @return BackupUtils全局唯一实例 + * 获取BackupUtils实例 + *

+ * 采用单例模式,确保整个应用中只有一个BackupUtils实例 + * + * @param context 上下文对象 + * @return BackupUtils实例 */ public static synchronized BackupUtils getInstance(Context context) { if (sInstance == null) { @@ -78,76 +63,73 @@ public class BackupUtils { } /** - * 备份操作状态码:标识导出过程的结果,供外部判断操作是否成功 + * 备份或恢复状态的常量定义 */ - // SD卡未挂载(无法创建/写入导出文件) + // 当前SD卡未挂载 public static final int STATE_SD_CARD_UNMOUONTED = 0; - // 备份文件不存在(仅恢复操作使用,当前导出逻辑未用到) + // 备份文件不存在 public static final int STATE_BACKUP_FILE_NOT_EXIST = 1; - // 数据格式错误(导出时未用到,预留恢复操作的状态码) + // 数据格式不正确,可能被其他程序修改 public static final int STATE_DATA_DESTROIED = 2; - // 系统错误(如文件创建失败、IO异常等运行时错误) + // 运行时异常导致备份或恢复失败 public static final int STATE_SYSTEM_ERROR = 3; - // 导出操作成功完成 + // 备份或恢复成功 public static final int STATE_SUCCESS = 4; - // 文本导出器实例:封装实际的文本导出逻辑,与工具类解耦 private TextExport mTextExport; /** - * 私有构造方法:单例模式的核心,禁止外部直接实例化 - * @param context 应用上下文:传递给TextExport初始化资源 + * 构造方法 + * + * @param context 上下文对象 */ private BackupUtils(Context context) { mTextExport = new TextExport(context); } /** - * 检查外部存储(SD卡)是否可用 - * 仅当SD卡处于“已挂载”状态时,才允许执行导出操作 - * @return true=SD卡已挂载且可读写,false=不可用 + * 检查外部存储是否可用 + * + * @return 外部存储是否可用 */ private static boolean externalStorageAvailable() { return Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()); } /** - * 对外暴露的导出方法:触发便签导出为文本文件的核心逻辑 - * @return 操作状态码:参考BackupUtils的STATE_XXX常量 + * 将便签导出为文本文件 + *

+ * 调用内部TextExport类的exportToText方法执行导出操作 + * + * @return 导出状态,参考STATE_*常量 */ public int exportToText() { return mTextExport.exportToText(); } /** - * 获取本次导出的文件名(导出成功后有效) - * @return 导出文件的名称(如notes_20251223.txt) + * 获取导出的文本文件名 + * + * @return 导出的文本文件名 */ public String getExportedTextFileName() { return mTextExport.mFileName; } /** - * 获取本次导出的文件目录(导出成功后有效) - * @return 导出文件所在的目录路径(如/sdcard/MiNotes/) + * 获取导出的文本文件目录 + * + * @return 导出的文本文件目录 */ public String getExportedTextFileDir() { return mTextExport.mFileDirectory; } /** - * 内部文本导出器类:封装所有导出相关的核心逻辑 - * 与外部BackupUtils解耦,专注处理“查询便签数据→格式化→写入文本文件”的完整流程 + * 文本导出内部类,负责具体的导出逻辑 */ private static class TextExport { - /** - * 便签查询投影数组:仅查询导出所需的核心字段,减少IO开销 - * 字段说明: - * - NoteColumns.ID:便签/文件夹唯一ID; - * - NoteColumns.MODIFIED_DATE:便签最后修改时间(用于导出展示); - * - NoteColumns.SNIPPET:便签摘要/文件夹名称; - * - NoteColumns.TYPE:便签类型(文件夹/普通便签/系统项)。 - */ + // 便签查询的投影列 private static final String[] NOTE_PROJECTION = { NoteColumns.ID, NoteColumns.MODIFIED_DATE, @@ -155,20 +137,11 @@ public class BackupUtils { NoteColumns.TYPE }; - // 便签投影数组列索引常量:简化Cursor取值,避免硬编码 private static final int NOTE_COLUMN_ID = 0; private static final int NOTE_COLUMN_MODIFIED_DATE = 1; private static final int NOTE_COLUMN_SNIPPET = 2; - /** - * 便签数据查询投影数组:查询便签的具体内容(普通文本/通话记录) - * 字段说明: - * - DataColumns.CONTENT:普通便签内容/通话记录附件位置; - * - DataColumns.MIME_TYPE:数据类型(普通便签/通话记录); - * - DataColumns.DATA1:通话记录时间; - * - DataColumns.DATA4:通话记录手机号; - * 其他DATA字段为预留,暂未使用。 - */ + // 便签数据查询的投影列 private static final String[] DATA_PROJECTION = { DataColumns.CONTENT, DataColumns.MIME_TYPE, @@ -178,198 +151,174 @@ public class BackupUtils { DataColumns.DATA4, }; - // 便签数据投影数组列索引常量 private static final int DATA_COLUMN_CONTENT = 0; private static final int DATA_COLUMN_MIME_TYPE = 1; private static final int DATA_COLUMN_CALL_DATE = 2; private static final int DATA_COLUMN_PHONE_NUMBER = 4; - /** - * 导出文本格式模板数组:从资源文件读取,适配多语言,避免硬编码文本格式 - * 数组索引对应: - * - FORMAT_FOLDER_NAME:文件夹名称展示格式(如“【文件夹:XXX】”); - * - FORMAT_NOTE_DATE:便签修改时间展示格式(如“修改时间:2025-12-23 15:30”); - * - FORMAT_NOTE_CONTENT:便签内容展示格式(如“内容:XXX”)。 - */ + // 文本格式数组 private final String [] TEXT_FORMAT; - // 格式模板索引常量 private static final int FORMAT_FOLDER_NAME = 0; private static final int FORMAT_NOTE_DATE = 1; private static final int FORMAT_NOTE_CONTENT = 2; - // 上下文:用于访问ContentResolver、资源文件 private Context mContext; - private String mFileName; // 导出文件的名称(如notes_20251223.txt) - private String mFileDirectory; // 导出文件的目录路径(如/sdcard/MiNotes/) + private String mFileName; + private String mFileDirectory; /** - * 文本导出器构造方法 - * @param context 应用上下文:读取格式模板、资源字符串 + * 构造方法 + * + * @param context 上下文对象 */ public TextExport(Context context) { - // 从资源文件加载导出文本的格式模板 TEXT_FORMAT = context.getResources().getStringArray(R.array.format_for_exported_note); mContext = context; - // 初始化文件名/目录为空,导出成功后赋值 mFileName = ""; mFileDirectory = ""; } /** - * 获取指定索引的文本格式模板 - * @param id 格式模板索引(FORMAT_FOLDER_NAME/FORMAT_NOTE_DATE/FORMAT_NOTE_CONTENT) - * @return 格式模板字符串 + * 获取指定ID的格式字符串 + * + * @param id 格式ID + * @return 格式字符串 */ private String getFormat(int id) { return TEXT_FORMAT[id]; } /** - * 导出指定文件夹下的所有便签到打印流 - * @param folderId 文件夹ID:要导出的文件夹唯一标识 - * @param ps 打印流:指向SD卡的导出文件,用于写入文本内容 + * 将指定文件夹及其包含的便签导出为文本 + * + * @param folderId 文件夹ID + * @param ps 打印流 */ private void exportFolderToText(String folderId, PrintStream ps) { - // 查询该文件夹下的所有普通便签 + // 查询该文件夹下的所有便签 Cursor notesCursor = mContext.getContentResolver().query(Notes.CONTENT_NOTE_URI, - NOTE_PROJECTION, - NoteColumns.PARENT_ID + "=?", // 查询条件:父文件夹ID匹配 - new String[] { folderId }, - null); + NOTE_PROJECTION, NoteColumns.PARENT_ID + "=?", new String[] { + folderId + }, null); if (notesCursor != null) { - // 遍历文件夹下的所有便签 if (notesCursor.moveToFirst()) { do { - // 1. 打印便签最后修改时间(按格式模板) + // 打印便签的最后修改时间 ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format( - mContext.getString(R.string.format_datetime_mdhm), // 时间格式:月日时分 + mContext.getString(R.string.format_datetime_mdhm), notesCursor.getLong(NOTE_COLUMN_MODIFIED_DATE)))); - // 2. 导出单条便签的详细内容 + // 查询属于该便签的数据 String noteId = notesCursor.getString(NOTE_COLUMN_ID); exportNoteToText(noteId, ps); } while (notesCursor.moveToNext()); } - // 关闭游标,释放数据库资源 notesCursor.close(); } } /** - * 导出单条便签的详细内容到打印流 - * 区分处理普通文本便签和通话记录便签,按不同格式写入内容 - * @param noteId 便签ID:要导出的便签唯一标识 - * @param ps 打印流:指向SD卡的导出文件 + * 将指定便签导出为文本 + * + * @param noteId 便签ID + * @param ps 打印流 */ private void exportNoteToText(String noteId, PrintStream ps) { - // 查询该便签的具体数据(内容/通话记录详情) + // 查询该便签的所有数据 Cursor dataCursor = mContext.getContentResolver().query(Notes.CONTENT_DATA_URI, - DATA_PROJECTION, - DataColumns.NOTE_ID + "=?", // 查询条件:便签ID匹配 - new String[] { noteId }, - null); + DATA_PROJECTION, DataColumns.NOTE_ID + "=?", new String[] { + noteId + }, null); if (dataCursor != null) { - // 遍历便签的所有数据项(单条便签可能包含多个数据项,如通话记录+附件) if (dataCursor.moveToFirst()) { do { String mimeType = dataCursor.getString(DATA_COLUMN_MIME_TYPE); - // 1. 处理通话记录类型便签 if (DataConstants.CALL_NOTE.equals(mimeType)) { - // 获取通话记录核心信息 + // 打印电话号码 String phoneNumber = dataCursor.getString(DATA_COLUMN_PHONE_NUMBER); long callDate = dataCursor.getLong(DATA_COLUMN_CALL_DATE); String location = dataCursor.getString(DATA_COLUMN_CONTENT); - // 打印手机号(非空时) if (!TextUtils.isEmpty(phoneNumber)) { - ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), phoneNumber)); + ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), + phoneNumber)); } - // 打印通话时间 + // 打印通话日期 ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), DateFormat - .format(mContext.getString(R.string.format_datetime_mdhm), callDate))); - // 打印通话记录附件位置(非空时) + .format(mContext.getString(R.string.format_datetime_mdhm), + callDate))); + // 打印通话附件位置 if (!TextUtils.isEmpty(location)) { - ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), location)); + ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), + location)); } - } - // 2. 处理普通文本便签 - else if (DataConstants.NOTE.equals(mimeType)) { + } else if (DataConstants.NOTE.equals(mimeType)) { + // 打印便签内容 String content = dataCursor.getString(DATA_COLUMN_CONTENT); - // 打印便签内容(非空时) if (!TextUtils.isEmpty(content)) { - ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), content)); + ps.println(String.format(getFormat(FORMAT_NOTE_CONTENT), + content)); } } } while (dataCursor.moveToNext()); } - // 关闭游标,释放资源 dataCursor.close(); } - - // 便签内容结束后,添加分隔符(换行+分隔符),区分不同便签 + // 打印便签之间的分隔线 try { ps.write(new byte[] { - Character.LINE_SEPARATOR, Character.LETTER_NUMBER + Character.LINE_SEPARATOR, Character.LINE_SEPARATOR }); } catch (IOException e) { - // 写入分隔符失败时输出错误日志,不中断整体导出流程 Log.e(TAG, e.toString()); } } /** - * 执行核心导出逻辑:将所有有效便签导出为SD卡上的文本文件 - * 导出流程: - * 1. 检查SD卡是否可用; - * 2. 创建导出文件的打印流; - * 3. 导出所有有效文件夹(排除回收站)+ 通话记录文件夹; - * 4. 导出根目录下的普通便签; - * 5. 关闭打印流,返回操作状态码。 - * @return 操作状态码:参考BackupUtils的STATE_XXX常量 + * 将便签导出为用户可读的文本文件 + *

+ * 导出过程包括: + * 1. 检查SD卡是否可用 + * 2. 创建导出文件 + * 3. 导出文件夹及其便签 + * 4. 导出根目录下的便签 + * + * @return 导出状态,参考STATE_*常量 */ public int exportToText() { - // 步骤1:检查SD卡是否挂载,未挂载直接返回对应状态码 if (!externalStorageAvailable()) { Log.d(TAG, "Media was not mounted"); return STATE_SD_CARD_UNMOUONTED; } - // 步骤2:创建导出文件的打印流,失败则返回系统错误 PrintStream ps = getExportToTextPrintStream(); if (ps == null) { Log.e(TAG, "get print stream error"); return STATE_SYSTEM_ERROR; } - - // 步骤3:导出所有有效文件夹(排除回收站)+ 通话记录文件夹 + + // 导出文件夹及其便签 Cursor folderCursor = mContext.getContentResolver().query( Notes.CONTENT_NOTE_URI, NOTE_PROJECTION, - // 查询条件: - // - 类型为文件夹 + 父文件夹不是回收站; - // - 或ID为通话记录文件夹(特殊系统文件夹) "(" + NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER + " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + ") OR " - + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER, - null, - null); + + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER, null, null); if (folderCursor != null) { if (folderCursor.moveToFirst()) { do { - // 处理文件夹名称:通话记录文件夹使用固定名称,其他文件夹用摘要 + // 打印文件夹名称 String folderName = ""; if(folderCursor.getLong(NOTE_COLUMN_ID) == Notes.ID_CALL_RECORD_FOLDER) { folderName = mContext.getString(R.string.call_record_folder_name); } else { folderName = folderCursor.getString(NOTE_COLUMN_SNIPPET); } - // 打印文件夹名称(非空时) if (!TextUtils.isEmpty(folderName)) { ps.println(String.format(getFormat(FORMAT_FOLDER_NAME), folderName)); } - // 导出该文件夹下的所有便签 String folderId = folderCursor.getString(NOTE_COLUMN_ID); exportFolderToText(folderId, ps); } while (folderCursor.moveToNext()); @@ -377,66 +326,53 @@ public class BackupUtils { folderCursor.close(); } - // 步骤4:导出根目录下的普通便签(父文件夹ID为0) + // 导出根目录下的便签 Cursor noteCursor = mContext.getContentResolver().query( Notes.CONTENT_NOTE_URI, NOTE_PROJECTION, - // 查询条件:类型为普通便签 + 父文件夹为根目录 NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " AND " + NoteColumns.PARENT_ID - + "=0", - null, - null); + + "=0", null, null); if (noteCursor != null) { if (noteCursor.moveToFirst()) { do { - // 打印便签修改时间 ps.println(String.format(getFormat(FORMAT_NOTE_DATE), DateFormat.format( mContext.getString(R.string.format_datetime_mdhm), noteCursor.getLong(NOTE_COLUMN_MODIFIED_DATE)))); - // 导出单条便签内容 + // 查询属于该便签的数据 String noteId = noteCursor.getString(NOTE_COLUMN_ID); exportNoteToText(noteId, ps); } while (noteCursor.moveToNext()); } noteCursor.close(); } - - // 步骤5:关闭打印流,释放文件资源 ps.close(); - // 导出成功,返回成功状态码 return STATE_SUCCESS; } /** - * 创建指向SD卡导出文件的PrintStream - * 核心逻辑:调用generateFileMountedOnSDcard创建文件,再包装为PrintStream - * @return PrintStream对象(成功)/null(失败) + * 获取导出文本的打印流 + * + * @return 打印流 */ private PrintStream getExportToTextPrintStream() { - // 生成SD卡上的导出文件(带日期的文本文件) File file = generateFileMountedOnSDcard(mContext, R.string.file_path, R.string.file_name_txt_format); if (file == null) { Log.e(TAG, "create file to exported failed"); return null; } - // 记录导出文件的名称和目录(供外部获取) mFileName = file.getName(); mFileDirectory = mContext.getString(R.string.file_path); - PrintStream ps = null; try { - // 创建文件输出流,包装为打印流(方便文本写入) FileOutputStream fos = new FileOutputStream(file); ps = new PrintStream(fos); } catch (FileNotFoundException e) { - // 文件未找到异常:打印堆栈,返回null e.printStackTrace(); return null; } catch (NullPointerException e) { - // 空指针异常:打印堆栈,返回null e.printStackTrace(); return null; } @@ -445,48 +381,40 @@ public class BackupUtils { } /** - * 在SD卡上生成导出用的文本文件 - * 文件名规则:带当前日期(如notes_20251223.txt),目录为预设的便签备份目录 - * @param context 应用上下文:读取目录路径、文件名格式资源 - * @param filePathResId 目录路径资源ID:如R.string.file_path(/MiNotes/) - * @param fileNameFormatResId 文件名格式资源ID:如R.string.file_name_txt_format(notes_%s.txt) - * @return 生成的File对象(成功)/null(失败) + * 生成存储导入数据的文本文件 + * + * @param context 上下文对象 + * @param filePathResId 文件路径资源ID + * @param fileNameFormatResId 文件名格式资源ID + * @return 生成的文件 */ private static File generateFileMountedOnSDcard(Context context, int filePathResId, int fileNameFormatResId) { StringBuilder sb = new StringBuilder(); - // 1. 拼接SD卡根目录 + 预设目录路径(如/sdcard/MiNotes/) sb.append(Environment.getExternalStorageDirectory()); sb.append(context.getString(filePathResId)); File filedir = new File(sb.toString()); - - // 2. 拼接完整文件路径:目录 + 带日期的文件名(如/sdcard/MiNotes/notes_20251223.txt) sb.append(context.getString( fileNameFormatResId, - // 格式化文件名中的日期部分(年-月-日) DateFormat.format(context.getString(R.string.format_date_ymd), System.currentTimeMillis()))); File file = new File(sb.toString()); try { - // 3. 创建目录(不存在时) if (!filedir.exists()) { filedir.mkdir(); } - // 4. 创建文件(不存在时) if (!file.exists()) { file.createNewFile(); } - // 5. 返回创建成功的文件对象 return file; } catch (SecurityException e) { - // 权限异常(如无SD卡写入权限):打印堆栈,返回null e.printStackTrace(); } catch (IOException e) { - // IO异常(如创建文件失败):打印堆栈,返回null e.printStackTrace(); } - // 创建失败,返回null return null; } -} \ No newline at end of file +} + + diff --git a/src/Notes-master/src/net/micode/notes/tool/CategoryUtil.java b/src/Notes-master/src/net/micode/notes/tool/CategoryUtil.java index 54c6bad..5a88f56 100644 --- a/src/Notes-master/src/net/micode/notes/tool/CategoryUtil.java +++ b/src/Notes-master/src/net/micode/notes/tool/CategoryUtil.java @@ -23,32 +23,44 @@ import java.util.regex.Pattern; /** * 便签自动分类工具类 + *

+ * 该类用于根据便签内容自动为便签分配分类,基于关键词匹配的方式实现。 + * 支持多种分类类型,包括工作、学习、生活、想法、待办等,方便用户对便签进行管理和查找。 + *

+ * [2025 智能分类特性]: 该类是AI智能分类功能的基础,通过关键词匹配为便签提供初步分类, + * 后续可结合AI模型进行更精准的分类。 */ public class CategoryUtil { - // 分类关键词映射表 + // 分类关键词映射表,存储关键词与对应分类的映射关系 private static final Map CATEGORY_KEYWORDS = new HashMap<>(); + // 静态初始化块,初始化分类关键词 static { - // 工作相关 + // 工作相关关键词 addKeywords("工作", "工作", "任务", "项目", "会议", "报告", "加班", "上班", "下班", "同事", "领导", "客户", "公司"); - // 学习相关 + // 学习相关关键词 addKeywords("学习", "学习", "考试", "作业", "复习", "预习", "课程", "老师", "学生", "学校", "教材", "笔记", "知识点"); - // 生活相关 + // 生活相关关键词 addKeywords("生活", "生活", "日常", "购物", "吃饭", "旅游", "电影", "音乐", "健身", "运动", "休息", "睡觉", "起床"); - // 想法相关 + // 想法相关关键词 addKeywords("想法", "想法", "创意", "灵感", "思考", "感悟", "心得", "体会", "观点", "意见", "建议"); - // 待办相关 + // 待办相关关键词 addKeywords("待办", "待办", "todo", "需要", "必须", "应该", "计划", "安排", "准备"); // 其他默认分类 addKeywords("其他", ""); } - // 批量添加关键词 + /** + * 批量添加关键词到分类映射表 + * + * @param category 分类名称 + * @param keywords 关键词数组 + */ private static void addKeywords(String category, String... keywords) { for (String keyword : keywords) { CATEGORY_KEYWORDS.put(keyword, category); @@ -57,6 +69,9 @@ public class CategoryUtil { /** * 根据便签内容自动分类 + *

+ * 通过匹配便签内容中的关键词,为便签分配对应的分类 + * * @param content 便签内容 * @return 分类结果,不超过3个字符 */ @@ -65,7 +80,7 @@ public class CategoryUtil { return "其他"; } - // 转为小写进行匹配 + // 转为小写进行匹配,提高匹配成功率 String lowerContent = content.toLowerCase(); // 遍历关键词,匹配分类 diff --git a/src/Notes-master/src/net/micode/notes/tool/DataUtils.java b/src/Notes-master/src/net/micode/notes/tool/DataUtils.java index cb09442..874d578 100644 --- a/src/Notes-master/src/net/micode/notes/tool/DataUtils.java +++ b/src/Notes-master/src/net/micode/notes/tool/DataUtils.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -34,18 +34,25 @@ import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute; import java.util.ArrayList; import java.util.HashSet; + /** - * 数据库操作工具类 - * 提供笔记的增删改查、批量操作、数据校验等功能 + * 数据操作工具类 + *

+ * 该类提供了一系列静态方法,用于执行便签数据的批量操作,包括批量删除、批量移动、文件夹管理、 + * 数据查询等功能。它是连接UI层与数据层的桥梁,通过ContentResolver操作ContentProvider, + * 实现对便签数据的增删改查。 */ public class DataUtils { public static final String TAG = "DataUtils"; - + /** - * 批量删除笔记 - * @param resolver ContentResolver用于数据库操作 - * @param ids 待删除的笔记ID集合 - * @return true删除成功,false删除失败 + * 批量删除便签 + *

+ * 通过ContentProvider的批量操作接口,一次性删除多个便签 + * + * @param resolver ContentResolver对象,用于操作ContentProvider + * @param ids 需要删除的便签ID集合 + * @return 是否删除成功 */ public static boolean batchDeleteNotes(ContentResolver resolver, HashSet ids) { if (ids == null) { @@ -57,23 +64,19 @@ public class DataUtils { return true; } - // 批量操作列表,用于执行事务性删除 ArrayList operationList = new ArrayList(); for (long id : ids) { - // 保护系统根文件夹不被删除 if(id == Notes.ID_ROOT_FOLDER) { Log.e(TAG, "Don't delete system folder root"); continue; } - // 构建删除操作,根据URI删除指定ID的笔记 ContentProviderOperation.Builder builder = ContentProviderOperation .newDelete(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id)); operationList.add(builder.build()); } try { - // 批量执行删除操作 + // DB操作:批量删除便签 ContentProviderResult[] results = resolver.applyBatch(Notes.AUTHORITY, operationList); - // 检查结果是否有效 if (results == null || results.length == 0 || results[0] == null) { Log.d(TAG, "delete notes failed, ids:" + ids.toString()); return false; @@ -88,47 +91,65 @@ public class DataUtils { } /** - * 移动单条笔记到目标文件夹 - * @param resolver ContentResolver用于数据库操作 - * @param id 笔记ID - * @param srcFolderId 源文件夹ID(记录原始位置) + * 将单个便签移动到指定文件夹 + * + * @param resolver ContentResolver对象 + * @param id 便签ID + * @param srcFolderId 源文件夹ID * @param desFolderId 目标文件夹ID */ public static void moveNoteToFoler(ContentResolver resolver, long id, long srcFolderId, long desFolderId) { ContentValues values = new ContentValues(); - values.put(NoteColumns.PARENT_ID, desFolderId); // 设置新的父文件夹 - values.put(NoteColumns.ORIGIN_PARENT_ID, srcFolderId); // 记录原始父文件夹 - values.put(NoteColumns.LOCAL_MODIFIED, 1); // 标记为本地已修改 + values.put(NoteColumns.PARENT_ID, desFolderId); + values.put(NoteColumns.ORIGIN_PARENT_ID, srcFolderId); + values.put(NoteColumns.LOCAL_MODIFIED, 1); + // DB操作:更新便签的文件夹信息 resolver.update(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id), values, null, null); } /** - * 批量移动笔记到指定文件夹 - * @param resolver ContentResolver用于数据库操作 - * @param ids 待移动的笔记ID集合 + * 批量移动便签到指定文件夹 + *

+ * 支持将多个便签一次性移动到指定文件夹,包括回收站 + * + * @param resolver ContentResolver对象 + * @param ids 需要移动的便签ID集合 * @param folderId 目标文件夹ID - * @return true成功,false失败 + * @return 是否移动成功 */ public static boolean batchMoveToFolder(ContentResolver resolver, HashSet ids, - long folderId) { + long folderId) { if (ids == null) { Log.d(TAG, "the ids is null"); return true; } - // 批量操作列表,用于执行事务性更新 ArrayList operationList = new ArrayList(); for (long id : ids) { - // 构建更新操作,修改笔记的父文件夹 ContentProviderOperation.Builder builder = ContentProviderOperation .newUpdate(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id)); - builder.withValue(NoteColumns.PARENT_ID, folderId); // 设置新的父文件夹 - builder.withValue(NoteColumns.LOCAL_MODIFIED, 1); // 标记为本地已修改 + + // 如果是移动到回收站,保存原始父文件夹ID + if (folderId == Notes.ID_TRASH_FOLER) { + // 查询当前父文件夹ID + Cursor cursor = resolver.query( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, id), + new String[]{NoteColumns.PARENT_ID}, + null, null, null); + if (cursor != null && cursor.moveToFirst()) { + long originParentId = cursor.getLong(0); + builder.withValue(NoteColumns.ORIGIN_PARENT_ID, originParentId); + cursor.close(); + } + } + + builder.withValue(NoteColumns.PARENT_ID, folderId); + builder.withValue(NoteColumns.LOCAL_MODIFIED, 1); operationList.add(builder.build()); } try { - // 批量执行移动操作 + // DB操作:批量更新便签的文件夹信息 ContentProviderResult[] results = resolver.applyBatch(Notes.AUTHORITY, operationList); if (results == null || results.length == 0 || results[0] == null) { Log.d(TAG, "delete notes failed, ids:" + ids.toString()); @@ -144,12 +165,13 @@ public class DataUtils { } /** - * 获取用户创建的文件夹数量(排除系统文件夹和回收站) - * @param resolver ContentResolver用于数据库操作 - * @return 文件夹数量 + * 获取用户创建的文件夹数量(排除系统文件夹) + * + * @param resolver ContentResolver对象 + * @return 用户文件夹数量 */ public static int getUserFolderCount(ContentResolver resolver) { - // 查询类型为文件夹且父ID不是回收站的记录数 + // DB操作:查询用户文件夹数量 Cursor cursor =resolver.query(Notes.CONTENT_NOTE_URI, new String[] { "COUNT(*)" }, NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>?", @@ -160,11 +182,11 @@ public class DataUtils { if(cursor != null) { if(cursor.moveToFirst()) { try { - count = cursor.getInt(0); // 获取第一列的计数结果 + count = cursor.getInt(0); } catch (IndexOutOfBoundsException e) { Log.e(TAG, "get folder count failed:" + e.toString()); } finally { - cursor.close(); // 确保游标被关闭 + cursor.close(); } } } @@ -172,14 +194,15 @@ public class DataUtils { } /** - * 检查笔记是否在可见数据库中(不在回收站) - * @param resolver ContentResolver用于数据库操作 - * @param noteId 笔记ID - * @param type 笔记类型 - * @return true存在且可见,false不存在或已被删除到回收站 + * 检查便签是否在数据库中可见(未被删除到回收站) + * + * @param resolver ContentResolver对象 + * @param noteId 便签ID + * @param type 便签类型 + * @return 是否可见 */ public static boolean visibleInNoteDatabase(ContentResolver resolver, long noteId, int type) { - // 查询指定ID和类型的笔记,且父ID不是回收站 + // DB操作:查询便签是否存在且未被删除到回收站 Cursor cursor = resolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), null, NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER, @@ -188,7 +211,7 @@ public class DataUtils { boolean exist = false; if (cursor != null) { - if (cursor.getCount() > 0) { // 检查是否有匹配的记录 + if (cursor.getCount() > 0) { exist = true; } cursor.close(); @@ -197,19 +220,20 @@ public class DataUtils { } /** - * 检查笔记是否存在(包括回收站中的) - * @param resolver ContentResolver用于数据库操作 - * @param noteId 笔记ID - * @return true存在,false不存在 + * 检查便签是否存在于数据库中 + * + * @param resolver ContentResolver对象 + * @param noteId 便签ID + * @return 是否存在 */ public static boolean existInNoteDatabase(ContentResolver resolver, long noteId) { - // 查询指定ID的笔记,不做其他条件限制 + // DB操作:查询便签是否存在 Cursor cursor = resolver.query(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), null, null, null, null); boolean exist = false; if (cursor != null) { - if (cursor.getCount() > 0) { // 检查是否有匹配的记录 + if (cursor.getCount() > 0) { exist = true; } cursor.close(); @@ -218,19 +242,20 @@ public class DataUtils { } /** - * 检查数据记录是否存在 - * @param resolver ContentResolver用于数据库操作 + * 检查数据是否存在于数据库中 + * + * @param resolver ContentResolver对象 * @param dataId 数据ID - * @return true存在,false不存在 + * @return 是否存在 */ public static boolean existInDataDatabase(ContentResolver resolver, long dataId) { - // 查询指定ID的数据记录 + // DB操作:查询数据是否存在 Cursor cursor = resolver.query(ContentUris.withAppendedId(Notes.CONTENT_DATA_URI, dataId), null, null, null, null); boolean exist = false; if (cursor != null) { - if (cursor.getCount() > 0) { // 检查是否有匹配的记录 + if (cursor.getCount() > 0) { exist = true; } cursor.close(); @@ -239,21 +264,22 @@ public class DataUtils { } /** - * 检查可见文件夹名称是否已存在(用于重命名或新建时的冲突检测) - * @param resolver ContentResolver用于数据库操作 + * 检查可见文件夹名称是否已存在 + * + * @param resolver ContentResolver对象 * @param name 文件夹名称 - * @return true已存在,false不存在 + * @return 是否存在 */ public static boolean checkVisibleFolderName(ContentResolver resolver, String name) { - // 查询类型为文件夹、不在回收站中且名称匹配的记录 + // DB操作:查询文件夹名称是否已存在 Cursor cursor = resolver.query(Notes.CONTENT_NOTE_URI, null, NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER + - " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + - " AND " + NoteColumns.SNIPPET + "=?", + " AND " + NoteColumns.PARENT_ID + "<>" + Notes.ID_TRASH_FOLER + + " AND " + NoteColumns.SNIPPET + "=?", new String[] { name }, null); boolean exist = false; if(cursor != null) { - if(cursor.getCount() > 0) { // 检查是否有同名文件夹 + if(cursor.getCount() > 0) { exist = true; } cursor.close(); @@ -262,13 +288,14 @@ public class DataUtils { } /** - * 获取文件夹中所有笔记的桌面小部件信息 - * @param resolver ContentResolver用于数据库操作 + * 获取文件夹中包含的小部件属性 + * + * @param resolver ContentResolver对象 * @param folderId 文件夹ID * @return 小部件属性集合 */ public static HashSet getFolderNoteWidget(ContentResolver resolver, long folderId) { - // 查询文件夹下所有笔记的小部件ID和类型 + // DB操作:查询文件夹中的小部件信息 Cursor c = resolver.query(Notes.CONTENT_NOTE_URI, new String[] { NoteColumns.WIDGET_ID, NoteColumns.WIDGET_TYPE }, NoteColumns.PARENT_ID + "=?", @@ -281,10 +308,9 @@ public class DataUtils { set = new HashSet(); do { try { - // 封装小部件属性对象 AppWidgetAttribute widget = new AppWidgetAttribute(); - widget.widgetId = c.getInt(0); // 小部件ID - widget.widgetType = c.getInt(1); // 小部件类型 + widget.widgetId = c.getInt(0); + widget.widgetType = c.getInt(1); set.add(widget); } catch (IndexOutOfBoundsException e) { Log.e(TAG, e.toString()); @@ -297,13 +323,14 @@ public class DataUtils { } /** - * 通过笔记ID获取通话号码(针对通话记录类型笔记) - * @param resolver ContentResolver用于数据库操作 - * @param noteId 笔记ID - * @return 通话号码,失败返回空字符串 + * 根据便签ID获取通话记录的电话号码 + * + * @param resolver ContentResolver对象 + * @param noteId 便签ID + * @return 电话号码 */ public static String getCallNumberByNoteId(ContentResolver resolver, long noteId) { - // 查询指定笔记的通话号码,限定MIME类型为通话记录 + // DB操作:查询通话记录的电话号码 Cursor cursor = resolver.query(Notes.CONTENT_DATA_URI, new String [] { CallNote.PHONE_NUMBER }, CallNote.NOTE_ID + "=? AND " + CallNote.MIME_TYPE + "=?", @@ -312,36 +339,37 @@ public class DataUtils { if (cursor != null && cursor.moveToFirst()) { try { - return cursor.getString(0); // 获取通话号码 + return cursor.getString(0); } catch (IndexOutOfBoundsException e) { Log.e(TAG, "Get call number fails " + e.toString()); } finally { - cursor.close(); // 确保游标被关闭 + cursor.close(); } } return ""; } /** - * 通过通话号码和日期查找对应的笔记ID - * @param resolver ContentResolver用于数据库操作 - * @param phoneNumber 通话号码 - * @param callDate 通话日期时间戳 - * @return 笔记ID,未找到返回0 + * 根据电话号码和通话日期获取通话记录的便签ID + * + * @param resolver ContentResolver对象 + * @param phoneNumber 电话号码 + * @param callDate 通话日期 + * @return 便签ID */ public static long getNoteIdByPhoneNumberAndCallDate(ContentResolver resolver, String phoneNumber, long callDate) { - // 使用自定义的PHONE_NUMBERS_EQUAL函数比较号码,确保同一联系人的不同格式号码能匹配 + // DB操作:查询通话记录的便签ID Cursor cursor = resolver.query(Notes.CONTENT_DATA_URI, new String [] { CallNote.NOTE_ID }, CallNote.CALL_DATE + "=? AND " + CallNote.MIME_TYPE + "=? AND PHONE_NUMBERS_EQUAL(" - + CallNote.PHONE_NUMBER + ",?)", + + CallNote.PHONE_NUMBER + ",?)", new String [] { String.valueOf(callDate), CallNote.CONTENT_ITEM_TYPE, phoneNumber }, null); if (cursor != null) { if (cursor.moveToFirst()) { try { - return cursor.getLong(0); // 获取笔记ID + return cursor.getLong(0); } catch (IndexOutOfBoundsException e) { Log.e(TAG, "Get call note id fails " + e.toString()); } @@ -352,14 +380,14 @@ public class DataUtils { } /** - * 通过笔记ID获取摘要内容 - * @param resolver ContentResolver用于数据库操作 - * @param noteId 笔记ID - * @return 摘要内容 - * @throws IllegalArgumentException 笔记不存在时抛出异常 + * 根据便签ID获取便签摘要 + * + * @param resolver ContentResolver对象 + * @param noteId 便签ID + * @return 便签摘要 */ public static String getSnippetById(ContentResolver resolver, long noteId) { - // 查询指定ID笔记的摘要字段 + // DB操作:查询便签摘要 Cursor cursor = resolver.query(Notes.CONTENT_NOTE_URI, new String [] { NoteColumns.SNIPPET }, NoteColumns.ID + "=?", @@ -369,27 +397,94 @@ public class DataUtils { if (cursor != null) { String snippet = ""; if (cursor.moveToFirst()) { - snippet = cursor.getString(0); // 获取摘要内容 + snippet = cursor.getString(0); } cursor.close(); return snippet; } - throw new IllegalArgumentException("Note is not found with id: " + noteId); + // 如果找不到noteId,返回空字符串,而不是抛出异常 + return ""; } /** - * 格式化笔记摘要(取第一行非空内容并去除首尾空格) + * 格式化便签摘要 + *

+ * 去除首尾空格,并截取第一行作为摘要 + * * @param snippet 原始摘要 * @return 格式化后的摘要 */ public static String getFormattedSnippet(String snippet) { if (snippet != null) { - snippet = snippet.trim(); // 去除首尾空格 + snippet = snippet.trim(); int index = snippet.indexOf('\n'); if (index != -1) { - snippet = snippet.substring(0, index); // 只保留第一行 + snippet = snippet.substring(0, index); } } return snippet; } -} \ No newline at end of file + + /** + * 根据文件夹名称获取文件夹ID + * + * @param resolver ContentResolver + * @param folderName 文件夹名称 + * @return 文件夹ID,若不存在则返回0 + */ + public static long getFolderIdByName(ContentResolver resolver, String folderName) { + // DB操作:根据文件夹名称查询文件夹ID + Cursor cursor = resolver.query(Notes.CONTENT_NOTE_URI, + new String[] { NoteColumns.ID }, + NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.SNIPPET + "=?", + new String[] { String.valueOf(Notes.TYPE_FOLDER), String.valueOf(Notes.ID_ROOT_FOLDER), folderName }, + null); + + long folderId = 0; + if (cursor != null) { + if (cursor.moveToFirst()) { + try { + folderId = cursor.getLong(0); + } catch (IndexOutOfBoundsException e) { + Log.e(TAG, "Get folder id failed: " + e.toString()); + } + } + cursor.close(); + } + + return folderId; + } + + /** + * 创建新文件夹 + *

+ * 如果文件夹已存在,则返回已存在的文件夹ID + * + * @param resolver ContentResolver + * @param folderName 文件夹名称 + * @return 新创建的文件夹ID,若创建失败则返回0 + */ + public static long createFolder(ContentResolver resolver, String folderName) { + // 检查文件夹是否已存在 + long existingFolderId = getFolderIdByName(resolver, folderName); + if (existingFolderId > 0) { + return existingFolderId; + } + + // 创建新文件夹 + ContentValues values = new ContentValues(); + values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(NoteColumns.PARENT_ID, Notes.ID_ROOT_FOLDER); + values.put(NoteColumns.SNIPPET, folderName); + values.put(NoteColumns.NOTES_COUNT, 0); + + // DB操作:插入新文件夹 + android.net.Uri uri = resolver.insert(Notes.CONTENT_NOTE_URI, values); + if (uri != null) { + return ContentUris.parseId(uri); + } + + Log.e(TAG, "Create folder failed: " + folderName); + return 0; + } +} diff --git a/src/Notes-master/src/net/micode/notes/tool/GTaskStringUtils.java b/src/Notes-master/src/net/micode/notes/tool/GTaskStringUtils.java index 0af3f5e..b409c0a 100644 --- a/src/Notes-master/src/net/micode/notes/tool/GTaskStringUtils.java +++ b/src/Notes-master/src/net/micode/notes/tool/GTaskStringUtils.java @@ -14,159 +14,123 @@ * limitations under the License. */ -// 包声明:归属小米便签的工具模块,定义Google Tasks(GTask)同步相关的核心字符串常量 package net.micode.notes.tool; /** - * Google Tasks(GTask)同步字符串常量类 - * 核心职责: - * 1. 统一定义便签与GTask同步过程中JSON交互的所有字段名,避免硬编码; - * 2. 定义GTask侧的文件夹命名规则(区分MIUI便签专属文件夹、系统默认文件夹); - * 3. 定义同步元数据的标识字段(用于存储GTask与便签的映射关系); - * 设计目的:提升代码可维护性,便于统一修改GTask同步的字段/命名规则。 + * GTask 字符串工具类 + *

+ * 该类定义了与 GTask 相关的 JSON 字段常量,用于 GTask 功能的实现。 + *

*/ public class GTaskStringUtils { - // ======================== GTask同步JSON交互 - 动作相关字段 ======================== - /** JSON字段:动作ID(标识单次同步操作的唯一ID) */ + /** + * GTask JSON 字段常量定义 + */ + // 动作相关字段 public final static String GTASK_JSON_ACTION_ID = "action_id"; - - /** JSON字段:动作列表(批量同步时存储多个动作的数组) */ public final static String GTASK_JSON_ACTION_LIST = "action_list"; - - /** JSON字段:动作类型(标识当前同步动作的类型,如创建/查询/移动/更新) */ public final static String GTASK_JSON_ACTION_TYPE = "action_type"; - - /** JSON字段:动作类型-创建(同步时向GTask创建新任务/文件夹) */ public final static String GTASK_JSON_ACTION_TYPE_CREATE = "create"; - - /** JSON字段:动作类型-全量查询(从GTask拉取所有任务/文件夹数据) */ public final static String GTASK_JSON_ACTION_TYPE_GETALL = "get_all"; - - /** JSON字段:动作类型-移动(将GTask任务/文件夹移动到指定目录) */ public final static String GTASK_JSON_ACTION_TYPE_MOVE = "move"; - - /** JSON字段:动作类型-更新(更新GTask中已存在的任务/文件夹信息) */ public final static String GTASK_JSON_ACTION_TYPE_UPDATE = "update"; - - // ======================== GTask同步JSON交互 - 实体/用户相关字段 ======================== - /** JSON字段:创建者ID(标识GTask实体的创建者账号ID) */ + + // 创建者相关字段 public final static String GTASK_JSON_CREATOR_ID = "creator_id"; - - /** JSON字段:子实体(存储GTask文件夹下的子任务/子文件夹) */ + + // 子实体相关字段 public final static String GTASK_JSON_CHILD_ENTITY = "child_entity"; - - /** JSON字段:客户端版本(标识便签客户端的版本号,用于GTask服务端兼容) */ + + // 客户端版本字段 public final static String GTASK_JSON_CLIENT_VERSION = "client_version"; - - /** JSON字段:完成状态(标识GTask任务是否已完成,布尔值) */ + + // 完成状态字段 public final static String GTASK_JSON_COMPLETED = "completed"; - - /** JSON字段:当前列表ID(标识任务/文件夹所属的GTask列表ID) */ + + // 列表相关字段 public final static String GTASK_JSON_CURRENT_LIST_ID = "current_list_id"; - - /** JSON字段:默认列表ID(GTask默认任务列表的ID) */ public final static String GTASK_JSON_DEFAULT_LIST_ID = "default_list_id"; - - /** JSON字段:删除标记(标识GTask实体是否已被删除,布尔值) */ + + // 删除相关字段 public final static String GTASK_JSON_DELETED = "deleted"; - - /** JSON字段:目标列表(移动操作时的目标列表ID) */ + + // 移动相关字段 public final static String GTASK_JSON_DEST_LIST = "dest_list"; - - /** JSON字段:目标父节点(移动操作时的目标父实体ID) */ public final static String GTASK_JSON_DEST_PARENT = "dest_parent"; - - /** JSON字段:目标父节点类型(移动操作时目标父实体的类型:GROUP/TASK) */ public final static String GTASK_JSON_DEST_PARENT_TYPE = "dest_parent_type"; - - /** JSON字段:实体增量(同步时仅传输实体的变更部分,减少数据传输) */ + + // 实体相关字段 public final static String GTASK_JSON_ENTITY_DELTA = "entity_delta"; - - /** JSON字段:实体类型(标识GTask实体的类型:任务/文件夹) */ public final static String GTASK_JSON_ENTITY_TYPE = "entity_type"; - - /** JSON字段:获取已删除项(同步时是否拉取GTask中已删除的实体) */ + + // 获取删除相关字段 public final static String GTASK_JSON_GET_DELETED = "get_deleted"; - - /** JSON字段:实体ID(GTask任务/文件夹的唯一标识ID) */ + + // ID 相关字段 public final static String GTASK_JSON_ID = "id"; - - /** JSON字段:索引(GTask实体在父节点中的排序索引) */ public final static String GTASK_JSON_INDEX = "index"; - - /** JSON字段:最后修改时间(GTask实体的最后修改时间戳) */ + + // 修改时间相关字段 public final static String GTASK_JSON_LAST_MODIFIED = "last_modified"; - - /** JSON字段:最新同步点(标识上次同步的位置,用于增量同步) */ + + // 同步相关字段 public final static String GTASK_JSON_LATEST_SYNC_POINT = "latest_sync_point"; - - /** JSON字段:列表ID(GTask列表的唯一标识ID) */ + + // 列表相关字段 public final static String GTASK_JSON_LIST_ID = "list_id"; - - /** JSON字段:列表数组(存储GTask所有列表的数组) */ public final static String GTASK_JSON_LISTS = "lists"; - - /** JSON字段:名称(GTask任务/文件夹的名称) */ + + // 名称相关字段 public final static String GTASK_JSON_NAME = "name"; - - /** JSON字段:新ID(创建/移动操作后生成的新实体ID) */ + + // 新 ID 字段 public final static String GTASK_JSON_NEW_ID = "new_id"; - - /** JSON字段:备注(GTask任务的备注内容,对应便签的正文) */ + + // 便签相关字段 public final static String GTASK_JSON_NOTES = "notes"; - - /** JSON字段:父节点ID(GTask实体的父文件夹/父任务ID) */ + + // 父级相关字段 public final static String GTASK_JSON_PARENT_ID = "parent_id"; - - /** JSON字段:前序兄弟ID(标识实体在父节点中的前一个兄弟实体ID,用于排序) */ + + // 排序相关字段 public final static String GTASK_JSON_PRIOR_SIBLING_ID = "prior_sibling_id"; - - /** JSON字段:同步结果(GTask服务端返回的同步操作结果) */ + + // 结果相关字段 public final static String GTASK_JSON_RESULTS = "results"; - - /** JSON字段:源列表(移动操作时的源列表ID) */ + + // 源列表字段 public final static String GTASK_JSON_SOURCE_LIST = "source_list"; - - /** JSON字段:任务数组(存储GTask所有任务的数组) */ + + // 任务相关字段 public final static String GTASK_JSON_TASKS = "tasks"; - - /** JSON字段:类型(兼容字段,同entity_type) */ + + // 类型相关字段 public final static String GTASK_JSON_TYPE = "type"; - - /** JSON字段:类型-分组(GTask文件夹的类型标识) */ public final static String GTASK_JSON_TYPE_GROUP = "GROUP"; - - /** JSON字段:类型-任务(GTask普通任务的类型标识) */ public final static String GTASK_JSON_TYPE_TASK = "TASK"; - - /** JSON字段:用户(标识GTask所属的用户账号信息) */ + + // 用户相关字段 public final static String GTASK_JSON_USER = "user"; - - // ======================== GTask侧文件夹命名规则 ======================== - /** MIUI便签专属文件夹前缀(区分GTask中其他文件夹,避免命名冲突) */ + + // MIUI 文件夹前缀 public final static String MIUI_FOLDER_PREFFIX = "[MIUI_Notes]"; - - /** GTask侧默认文件夹名称(对应便签的根目录) */ + + // 默认文件夹 public final static String FOLDER_DEFAULT = "Default"; - - /** GTask侧通话记录文件夹名称(对应便签的通话记录文件夹) */ + + // 通话便签文件夹 public final static String FOLDER_CALL_NOTE = "Call_Note"; - - /** GTask侧元数据文件夹名称(存储便签与GTask的同步映射元数据) */ + + // 元数据文件夹 public final static String FOLDER_META = "METADATA"; - - // ======================== 同步元数据标识字段 ======================== - /** 元数据头-GTask ID(存储便签对应的GTask实体ID) */ + + // 元数据头信息 public final static String META_HEAD_GTASK_ID = "meta_gid"; - - /** 元数据头-便签(存储便签核心信息的元数据标识) */ public final static String META_HEAD_NOTE = "meta_note"; - - /** 元数据头-数据(存储便签扩展数据的元数据标识) */ public final static String META_HEAD_DATA = "meta_data"; - - /** 元数据便签名称(GTask中元数据便签的固定名称,禁止修改/删除) */ + + // 元数据便签名称 public final static String META_NOTE_NAME = "[META INFO] DON'T UPDATE AND DELETE"; -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/tool/ResourceParser.java b/src/Notes-master/src/net/micode/notes/tool/ResourceParser.java index beca99b..4554684 100644 --- a/src/Notes-master/src/net/micode/notes/tool/ResourceParser.java +++ b/src/Notes-master/src/net/micode/notes/tool/ResourceParser.java @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -24,36 +24,44 @@ import net.micode.notes.ui.NotesPreferenceActivity; /** * 资源解析工具类 - * 负责管理笔记背景、字体大小、桌面小部件等资源的ID映射 - * 提供常量定义和静态方法获取各类资源 + *

+ * 该类定义了便签的背景颜色、字体大小等常量,并提供了获取各种资源的方法。 + * 它包含多个内部类,分别管理便签编辑界面、列表界面、小部件和文本外观的资源。 */ public class ResourceParser { - // 背景颜色常量定义 + /** + * 背景颜色常量定义 + */ public static final int YELLOW = 0; public static final int BLUE = 1; public static final int WHITE = 2; public static final int GREEN = 3; public static final int RED = 4; - // 默认背景颜色 + /** + * 默认背景颜色 + */ public static final int BG_DEFAULT_COLOR = YELLOW; - // 字体大小常量定义 + /** + * 字体大小常量定义 + */ public static final int TEXT_SMALL = 0; public static final int TEXT_MEDIUM = 1; public static final int TEXT_LARGE = 2; public static final int TEXT_SUPER = 3; - // 默认字体大小 + /** + * 默认字体大小 + */ public static final int BG_DEFAULT_FONT_SIZE = TEXT_MEDIUM; /** - * 笔记编辑界面背景资源类 - * 管理编辑页面不同颜色主题的背景图片 + * 便签编辑界面背景资源管理类 */ public static class NoteBgResources { - // 编辑界面背景资源数组,索引对应颜色常量 + // 编辑界面背景资源数组 private final static int [] BG_EDIT_RESOURCES = new int [] { R.drawable.edit_yellow, R.drawable.edit_blue, @@ -62,7 +70,7 @@ public class ResourceParser { R.drawable.edit_red }; - // 编辑界面标题栏背景资源数组 + // 编辑界面标题背景资源数组 private final static int [] BG_EDIT_TITLE_RESOURCES = new int [] { R.drawable.edit_title_yellow, R.drawable.edit_title_blue, @@ -72,8 +80,9 @@ public class ResourceParser { }; /** - * 获取笔记编辑界面背景资源ID - * @param id 颜色常量索引 + * 获取便签编辑界面背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getNoteBgResource(int id) { @@ -81,9 +90,10 @@ public class ResourceParser { } /** - * 获取笔记标题栏背景资源ID - * @param id 颜色常量索引 - * @return 标题栏背景资源ID + * 获取便签编辑界面标题背景资源 + * + * @param id 背景颜色ID + * @return 标题背景资源ID */ public static int getNoteTitleBgResource(int id) { return BG_EDIT_TITLE_RESOURCES[id]; @@ -92,28 +102,26 @@ public class ResourceParser { /** * 获取默认背景颜色ID - * 根据用户设置决定是随机颜色还是固定黄色 - * @param context 应用上下文 - * @return 背景颜色常量索引 + *

+ * 根据用户偏好设置,返回默认的背景颜色ID + * + * @param context 上下文对象 + * @return 背景颜色ID */ public static int getDefaultBgId(Context context) { - // 检查用户是否启用了随机背景色设置 if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean( NotesPreferenceActivity.PREFERENCE_SET_BG_COLOR_KEY, false)) { - // 生成随机颜色索引 return (int) (Math.random() * NoteBgResources.BG_EDIT_RESOURCES.length); } else { - // 返回默认黄色 return BG_DEFAULT_COLOR; } } /** - * 笔记列表项背景资源类 - * 管理列表中不同位置(首项、中间项、末项、单项)的背景图片 + * 便签列表项背景资源管理类 */ public static class NoteItemBgResources { - // 列表首项背景资源数组 + // 列表项第一项背景资源数组 private final static int [] BG_FIRST_RESOURCES = new int [] { R.drawable.list_yellow_up, R.drawable.list_blue_up, @@ -122,7 +130,7 @@ public class ResourceParser { R.drawable.list_red_up }; - // 列表中间项背景资源数组 + // 列表项中间项背景资源数组 private final static int [] BG_NORMAL_RESOURCES = new int [] { R.drawable.list_yellow_middle, R.drawable.list_blue_middle, @@ -131,7 +139,7 @@ public class ResourceParser { R.drawable.list_red_middle }; - // 列表末项背景资源数组 + // 列表项最后一项背景资源数组 private final static int [] BG_LAST_RESOURCES = new int [] { R.drawable.list_yellow_down, R.drawable.list_blue_down, @@ -140,7 +148,7 @@ public class ResourceParser { R.drawable.list_red_down, }; - // 列表单一项背景资源数组(只有一项时使用) + // 列表项单独一项背景资源数组 private final static int [] BG_SINGLE_RESOURCES = new int [] { R.drawable.list_yellow_single, R.drawable.list_blue_single, @@ -150,8 +158,9 @@ public class ResourceParser { }; /** - * 获取列表首项背景资源ID - * @param id 颜色常量索引 + * 获取列表项第一项背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getNoteBgFirstRes(int id) { @@ -159,8 +168,9 @@ public class ResourceParser { } /** - * 获取列表末项背景资源ID - * @param id 颜色常量索引 + * 获取列表项最后一项背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getNoteBgLastRes(int id) { @@ -168,8 +178,9 @@ public class ResourceParser { } /** - * 获取列表单一项背景资源ID - * @param id 颜色常量索引 + * 获取列表项单独一项背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getNoteBgSingleRes(int id) { @@ -177,8 +188,9 @@ public class ResourceParser { } /** - * 获取列表中间项背景资源ID - * @param id 颜色常量索引 + * 获取列表项中间项背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getNoteBgNormalRes(int id) { @@ -186,7 +198,8 @@ public class ResourceParser { } /** - * 获取文件夹背景资源ID(固定资源) + * 获取文件夹背景资源 + * * @return 文件夹背景资源ID */ public static int getFolderBgRes() { @@ -195,8 +208,7 @@ public class ResourceParser { } /** - * 桌面小部件背景资源类 - * 管理2x2和4x4尺寸小部件的背景图片 + * 小部件背景资源管理类 */ public static class WidgetBgResources { // 2x2小部件背景资源数组 @@ -209,8 +221,9 @@ public class ResourceParser { }; /** - * 获取2x2小部件背景资源ID - * @param id 颜色常量索引 + * 获取2x2小部件背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getWidget2xBgResource(int id) { @@ -227,8 +240,9 @@ public class ResourceParser { }; /** - * 获取4x4小部件背景资源ID - * @param id 颜色常量索引 + * 获取4x4小部件背景资源 + * + * @param id 背景颜色ID * @return 背景资源ID */ public static int getWidget4xBgResource(int id) { @@ -237,11 +251,10 @@ public class ResourceParser { } /** - * 文本外观资源类 - * 管理不同字体大小的样式资源 + * 文本外观资源管理类 */ public static class TextAppearanceResources { - // 字体大小样式资源数组,索引对应字体大小常量 + // 文本外观资源数组 private final static int [] TEXTAPPEARANCE_RESOURCES = new int [] { R.style.TextAppearanceNormal, R.style.TextAppearanceMedium, @@ -250,27 +263,30 @@ public class ResourceParser { }; /** - * 获取文本外观资源ID - * @param id 字体大小常量索引 - * @return 样式资源ID + * 获取文本外观资源 + * + * @param id 字体大小ID + * @return 文本外观资源ID */ public static int getTexAppearanceResource(int id) { /** - * HACKME: 修复将资源ID存储在SharedPreference中的bug - * 存储的ID可能大于资源数组长度,此时返回默认字体大小 + * HACKME: Fix bug of store the resource id in shared preference. + * The id may larger than the length of resources, in this case, + * return the {@link ResourceParser#BG_DEFAULT_FONT_SIZE} */ if (id >= TEXTAPPEARANCE_RESOURCES.length) { - return BG_DEFAULT_FONT_SIZE; // 越界时返回默认值 + return BG_DEFAULT_FONT_SIZE; } return TEXTAPPEARANCE_RESOURCES[id]; } /** - * 获取可用资源数量 - * @return 字体大小选项总数 + * 获取文本外观资源数量 + * + * @return 资源数量 */ public static int getResourcesSize() { return TEXTAPPEARANCE_RESOURCES.length; } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/tool/UserManager.java b/src/Notes-master/src/net/micode/notes/tool/UserManager.java new file mode 100644 index 0000000..fa0436b --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/tool/UserManager.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.tool; + +import android.content.Context; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.data.Users; + +/** + * 用户管理类 + *

+ * 该类用于管理当前登录用户的信息,包括保存、获取和清除用户信息。 + * 采用单例模式,通过SharedPreferences持久化存储用户信息, + * 同时提供用户密码验证功能。 + */ +public class UserManager { + private static final String TAG = "UserManager"; + private static final String PREF_NAME = "user_preferences"; + private static final String KEY_CURRENT_USER_ID = "current_user_id"; + private static final String KEY_CURRENT_USERNAME = "current_username"; + + private static UserManager sInstance; + private SharedPreferences mPrefs; + private Context mContext; + + /** + * 构造方法 + * + * @param context 上下文对象 + */ + private UserManager(Context context) { + mContext = context.getApplicationContext(); + mPrefs = mContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + } + + /** + * 获取UserManager实例 + *

+ * 采用单例模式,确保整个应用中只有一个UserManager实例 + * + * @param context 上下文对象 + * @return UserManager实例 + */ + public static synchronized UserManager getInstance(Context context) { + if (sInstance == null) { + sInstance = new UserManager(context); + } + return sInstance; + } + + /** + * 保存当前登录用户的信息 + *

+ * 将用户ID和用户名保存到SharedPreferences中,持久化存储 + * + * @param userId 用户ID + * @param username 用户名 + */ + public void saveCurrentUser(long userId, String username) { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putLong(KEY_CURRENT_USER_ID, userId); + editor.putString(KEY_CURRENT_USERNAME, username); + editor.apply(); + } + + /** + * 获取当前登录用户的ID + * + * @return 当前用户ID,未登录返回-1 + */ + public long getCurrentUserId() { + return mPrefs.getLong(KEY_CURRENT_USER_ID, -1); + } + + /** + * 获取当前登录用户的用户名 + * + * @return 当前用户名,未登录返回null + */ + public String getCurrentUsername() { + return mPrefs.getString(KEY_CURRENT_USERNAME, null); + } + + /** + * 清除当前用户信息,用于退出登录 + */ + public void clearCurrentUser() { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.remove(KEY_CURRENT_USER_ID); + editor.remove(KEY_CURRENT_USERNAME); + editor.apply(); + } + + /** + * 检查是否已登录 + * + * @return 是否已登录 + */ + public boolean isLoggedIn() { + return getCurrentUserId() != -1; + } + + /** + * 验证用户密码 + *

+ * 通过直接访问数据库,验证用户ID和密码是否匹配 + * + * @param userId 用户ID + * @param password 输入的密码 + * @return 密码是否正确 + */ + public boolean validatePassword(long userId, String password) { + try { + // DB操作:验证用户密码 + NotesDatabaseHelper helper = NotesDatabaseHelper.getInstance(mContext); + SQLiteDatabase db = helper.getReadableDatabase(); + + Cursor cursor = null; + try { + String selection = Users.UserColumns.ID + " = ? AND " + Users.UserColumns.PASSWORD + " = ?"; + String[] selectionArgs = {String.valueOf(userId), password}; + + cursor = db.query( + NotesDatabaseHelper.TABLE.USER, + new String[]{Users.UserColumns.ID}, + selection, + selectionArgs, + null, + null, + null + ); + + return cursor != null && cursor.moveToFirst(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * 设置当前用户 + *

+ * 根据用户ID查询用户名,然后保存当前用户信息 + * + * @param userId 用户ID + */ + public void setCurrentUser(long userId) { + try { + // DB操作:查询用户的用户名 + NotesDatabaseHelper helper = NotesDatabaseHelper.getInstance(mContext); + SQLiteDatabase db = helper.getReadableDatabase(); + + Cursor cursor = null; + String username = "未知用户"; + try { + String selection = Users.UserColumns.ID + " = ?"; + String[] selectionArgs = {String.valueOf(userId)}; + + cursor = db.query( + NotesDatabaseHelper.TABLE.USER, + new String[]{Users.UserColumns.USERNAME}, + selection, + selectionArgs, + null, + null, + null + ); + + if (cursor != null && cursor.moveToFirst()) { + username = cursor.getString(0); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + + // 保存当前用户信息 + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putLong(KEY_CURRENT_USER_ID, userId); + editor.putString(KEY_CURRENT_USERNAME, username); + editor.apply(); + } catch (Exception e) { + Log.e(TAG, "Error in setCurrentUser: " + e.getMessage()); + e.printStackTrace(); + // 即使发生异常,也确保保存用户ID,避免状态不一致 + try { + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putLong(KEY_CURRENT_USER_ID, userId); + editor.putString(KEY_CURRENT_USERNAME, "未知用户"); + editor.apply(); + } catch (Exception innerE) { + Log.e(TAG, "Error in emergency save: " + innerE.getMessage()); + innerE.printStackTrace(); + } + } + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/ui/AlarmAlertActivity.java b/src/Notes-master/src/net/micode/notes/ui/AlarmAlertActivity.java index be52332..2be6551 100644 --- a/src/Notes-master/src/net/micode/notes/ui/AlarmAlertActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/AlarmAlertActivity.java @@ -14,260 +14,223 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,便签提醒功能的最终展示页,是提醒触发时用户感知的核心页面 package net.micode.notes.ui; -// 安卓页面核心基类,所有页面的父类,提供页面生命周期、窗口管理、组件交互等基础能力 import android.app.Activity; -// 安卓系统对话框核心类,用于展示标准化的弹窗,承载提醒内容与交互按钮,是本页面的核心展示载体 import android.app.AlertDialog; -// 安卓上下文核心类,提供系统服务获取、资源访问、组件通信等基础能力 import android.content.Context; -// 安卓对话框事件回调相关类,处理按钮点击、对话框关闭等交互事件,实现页面的核心交互逻辑 import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; import android.content.DialogInterface.OnDismissListener; -// 安卓意图核心类,用于页面跳转、数据传递,此处用于跳转便签编辑页并携带便签ID import android.content.Intent; -// 安卓音频管理核心类,用于指定音频流类型,适配系统音量与静音规则,控制提醒铃声的播放策略 import android.media.AudioManager; -// 安卓媒体播放核心类,用于加载、播放、停止系统铃声资源,实现提醒音效的播放功能 import android.media.MediaPlayer; -// 安卓铃声管理核心类,用于获取系统默认的闹钟铃声Uri,统一访问系统铃声资源 import android.media.RingtoneManager; -// 安卓统一资源标识类,用于标记铃声资源地址、便签数据的唯一标识 import android.net.Uri; -// 安卓页面状态保存类,用于横竖屏切换等场景的页面数据恢复,本页面未使用 import android.os.Bundle; -// 安卓电源管理核心类,用于获取设备屏幕的亮灭状态,判断当前是亮屏/熄屏/锁屏状态 import android.os.PowerManager; -// 安卓系统设置核心类,用于读取系统的铃声静音模式配置,适配不同的系统音频策略 import android.provider.Settings; -// 安卓窗口管理相关类,用于配置页面窗口的显示属性,实现锁屏显示、屏幕唤醒、常亮等核心功能 +import android.util.Log; import android.view.Window; import android.view.WindowManager; -// 小米便签资源常量类,引用字符串、布局、颜色等本地资源,统一管理资源ID import net.micode.notes.R; -// 便签应用核心数据常量类,定义便签类型、ContentURI等全局常量,用于数据合法性校验 import net.micode.notes.data.Notes; -// 便签数据工具类,封装数据库查询相关的通用方法,提供便签摘要查询、便签存在性校验等能力 import net.micode.notes.tool.DataUtils; -// Java IO异常类,捕获媒体播放器加载铃声资源时的IO错误,保证程序健壮性 import java.io.IOException; + /** - * 便签提醒功能核心弹窗页面【提醒展示最终页、用户感知唯一页】 - * 继承:Activity 安卓页面基类,具备页面生命周期与窗口管理能力 - * 实现:OnClickListener + OnDismissListener 对话框交互监听器,处理按钮点击与弹窗关闭事件 - * 核心定位:小米便签「提醒功能」的终点站,是整个提醒流程的最终展示载体,也是用户能直接感知到的核心页面,所有提醒逻辑最终都汇聚于此,是便签提醒功能的核心价值体现 - * 核心设计意义:作为提醒触发后的唯一展示页面,承担「强提醒+友好交互+精准跳转」的核心职责,既要保证用户不会错过提醒,又要提供便捷的后续操作,兼顾提醒的强制性与使用的人性化 - * 核心职责(四大核心模块,逻辑闭环完整): - * 一、屏幕状态智能管控【优先级最高,保障提醒可见性】 - * 1. 精准判断设备当前屏幕状态:亮屏/熄屏/锁屏; - * 2. 熄屏/锁屏场景:自动唤醒屏幕、保持屏幕常亮、允许锁屏时显示窗口、适配系统装饰布局,确保弹窗能穿透锁屏界面直接展示,用户无需解锁即可看到提醒,核心保障「提醒必见」; - * 3. 亮屏场景:仅展示弹窗,不修改任何屏幕状态,避免干扰用户当前操作,兼顾使用体验; - * 4. 统一隐藏原生页面标题栏,仅展示核心的提醒弹窗,页面极简无冗余。 - * 二、提醒数据解析与合法性校验【数据安全,避免无效展示】 - * 1. 从跳转Intent中解析出绑定的便签ID:该ID由AlarmReceiver透传而来,是当前提醒的核心标识; - * 2. 根据便签ID查询数据库,获取便签的文本摘要内容,用于弹窗展示; - * 3. 摘要内容规范化处理:超过60字符自动裁剪并添加省略号,保证弹窗展示美观、无内容溢出; - * 4. 关键合法性校验:通过工具类判断该便签是否真实存在于数据库中(用户可能已删除该便签),仅当便签有效时才展示弹窗和播放铃声,无效则直接关闭页面,无任何无效操作。 - * 三、系统铃声智能播放【听觉提醒,保障提醒感知】 - * 1. 自动获取系统默认的闹钟铃声Uri,统一使用系统级铃声资源,适配用户的个性化铃声设置; - * 2. 适配系统静音模式:读取系统铃声流配置,自动选择合适的音频流类型,遵循系统的音量与静音规则,不强制发声干扰用户; - * 3. 铃声播放策略:循环播放提醒铃声,直到用户点击按钮关闭弹窗,确保用户能听到提醒; - * 4. 异常兜底处理:捕获播放器初始化、资源加载的各类异常,打印日志不崩溃,保证页面稳定性; - * 5. 资源安全释放:弹窗关闭时立即停止播放并释放媒体播放器资源,杜绝内存泄漏与音频残留。 - * 四、人性化交互设计【便捷操作,闭环提醒流程】 - * 1. 弹窗内容极简清晰:标题为应用名称,内容为便签摘要,核心信息一目了然; - * 2. 按钮动态适配:亮屏时展示「确认」+「进入便签」双按钮,熄屏/锁屏时仅展示「确认」单按钮,贴合不同场景的用户操作习惯; - * 3. 精准交互逻辑:「确认」按钮仅关闭弹窗,「进入便签」按钮跳转至该便签的编辑页面,直接定位到对应内容,无需用户手动查找; - * 4. 弹窗关闭统一处理:无论点击按钮还是手动关闭弹窗,均触发统一的收尾逻辑,停止铃声并关闭页面,逻辑闭环无遗漏。 - * 核心特性&关键技术要点: - * 1. 优先级保障:窗口标记配置为最高优先级,能穿透锁屏、熄屏界面展示,是安卓系统中「强提醒」的标准实现方式; - * 2. 资源安全:所有系统服务、媒体播放器资源均做了精准的创建与释放,无内存泄漏、无资源残留,符合安卓开发最佳实践; - * 3. 异常健壮:对数据解析、数据库查询、媒体播放等所有可能出现异常的环节均做了捕获处理,程序容错性极强,不会因单一异常导致崩溃; - * 4. 体验友好:所有逻辑均围绕「用户感知」设计,既保证提醒的强制性,又兼顾使用的人性化,无过度打扰、无无效操作; - * 5. 生命周期极简:页面的生命周期与弹窗强绑定,弹窗展示则页面存活,弹窗关闭则页面立即销毁,无后台残留,内存占用极低。 - * 完整业务流程闭环(提醒功能最终链路): - * AlarmManager触发广播 → AlarmReceiver接收广播 → 启动本页面 → 屏幕唤醒+解析便签数据 → 校验便签有效 → 播放铃声+展示弹窗 → 用户点击按钮 → 停止铃声+关闭弹窗 → 页面销毁 / 跳转便签编辑页。 - * 核心业务约束: - * - 仅处理有效便签的提醒:便签已删除/类型非法时,直接关闭页面,不展示任何内容; - * - 严格遵循系统规则:铃声播放、屏幕唤醒均遵循安卓系统的安全与权限规则,无越权操作; - * - 页面无残留:弹窗关闭即页面销毁,无任何后台进程或服务残留,性能友好。 + * 便签提醒活动类 + * 当便签的提醒时间到达时显示提醒对话框并播放闹钟声音 + * 提供用户交互界面,允许用户查看或编辑提醒的便签 + * + * 架构设计: + * - 继承自Activity,实现OnClickListener和OnDismissListener接口 + * - 在提醒触发时创建全屏对话框 + * - 播放系统闹钟铃声 + * - 提供查看和编辑便签的选项 + * - 适配不同Android版本的屏幕唤醒和锁定屏幕显示机制 + * + * 核心功能: + * - 显示提醒对话框,包含便签内容摘要 + * - 播放闹钟声音 + * - 允许用户点击查看便签详情 + * - 适配锁定屏幕和黑屏状态 + * - 安全处理异常情况,避免崩溃 */ public class AlarmAlertActivity extends Activity implements OnClickListener, OnDismissListener { - // ======================== 成员变量区 ======================== - /** 当前提醒绑定的便签唯一ID,核心标识,所有数据操作均基于此ID */ + /** + * 提醒的便签ID + */ private long mNoteId; - /** 便签的文本摘要内容,用于弹窗展示,做了长度限制处理 */ + /** + * 便签内容摘要 + */ private String mSnippet; - /** 便签摘要预览的最大字符长度,超过该长度自动裁剪并添加省略号,保证展示美观 */ + /** + * 摘要预览最大长度 + */ private static final int SNIPPET_PREW_MAX_LEN = 60; - /** 媒体播放器实例,用于加载和播放系统闹钟铃声,实现听觉提醒 */ - MediaPlayer mPlayer; - /** - * 页面创建核心回调方法,页面生命周期的入口,承载所有初始化逻辑 - * 本页面的所有核心功能均在该方法中完成初始化,逻辑清晰、步骤明确、无冗余处理 - * @param savedInstanceState 页面状态保存对象,本页面未使用该参数 + * 媒体播放器,用于播放闹钟声音 */ + MediaPlayer mPlayer; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // 核心配置:隐藏原生的页面标题栏,本页面仅展示对话框,无需标题栏,极简展示 requestWindowFeature(Window.FEATURE_NO_TITLE); - // ========== 第一步:窗口属性配置,实现锁屏显示、屏幕唤醒等核心能力 ========== final Window win = getWindow(); - // 必加窗口标记:允许窗口在设备锁屏状态下依然显示,是锁屏提醒的基础配置 - win.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); + // 添加适当的标志,确保在各种情况下都能显示提醒窗口 + win.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON + | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); + + // 在Android 10+中,需要确保Intent有正确的标志 + // 注意:FLAG_ACTIVITY_NEW_TASK和FLAG_ACTIVITY_CLEAR_TASK是Intent的常量,不是WindowManager.LayoutParams的常量 + // 这些标志已经在AlarmReceiver中通过intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)设置 - // 智能判断屏幕状态:仅在熄屏时添加额外的唤醒与常亮标记,亮屏时不做修改 - if (!isScreenOn()) { - win.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); // 保持屏幕常亮,直到弹窗关闭 - win.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); // 强制唤醒熄屏的屏幕,核心保障用户能看到提醒 - win.addFlags(WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON);// 允许屏幕在亮屏状态下依然保持锁定,兼顾安全性 - win.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); // 适配系统的装饰布局,避免弹窗内容被遮挡 - } - // ========== 第二步:解析Intent数据,获取便签ID并查询摘要内容 ========== Intent intent = getIntent(); + try { - // 核心解析逻辑:从Intent的Data字段中解析出便签ID,Data字段的格式为 content://notes/note/[id] - mNoteId = Long.valueOf(intent.getData().getPathSegments().get(1)); - // 根据便签ID查询数据库,获取便签的文本摘要 - mSnippet = DataUtils.getSnippetById(this.getContentResolver(), mNoteId); - // 摘要内容规范化处理:超过60字符则截取前60位,并添加省略号,保证弹窗展示效果 - mSnippet = mSnippet.length() > SNIPPET_PREW_MAX_LEN ? mSnippet.substring(0, - SNIPPET_PREW_MAX_LEN) + getResources().getString(R.string.notelist_string_info) - : mSnippet; - } catch (IllegalArgumentException e) { - // 异常处理:ID解析失败(如格式错误、数据异常),打印日志并直接返回,不展示任何内容 - e.printStackTrace(); + // 检查Intent和数据是否存在 + if (intent != null && intent.getData() != null) { + mNoteId = Long.valueOf(intent.getData().getPathSegments().get(1)); + mSnippet = DataUtils.getSnippetById(this.getContentResolver(), mNoteId); + mSnippet = mSnippet != null && mSnippet.length() > SNIPPET_PREW_MAX_LEN ? mSnippet.substring(0, + SNIPPET_PREW_MAX_LEN) + getResources().getString(R.string.notelist_string_info) + : mSnippet; + } else { + Log.e("AlarmAlertActivity", "Intent or data is null"); + finish(); + return; + } + } catch (Exception e) { + Log.e("AlarmAlertActivity", "Error processing intent: " + e.getMessage(), e); + finish(); return; } - // ========== 第三步:初始化媒体播放器,校验便签有效性并执行核心逻辑 ========== mPlayer = new MediaPlayer(); - // 关键校验:仅当该便签真实存在于数据库且为普通便签类型时,才展示弹窗和播放铃声 - if (DataUtils.visibleInNoteDatabase(getContentResolver(), mNoteId, Notes.TYPE_NOTE)) { - showActionDialog(); // 展示提醒弹窗,核心交互载体 - playAlarmSound(); // 播放系统闹钟铃声,核心听觉提醒 + if (mNoteId > 0 && DataUtils.visibleInNoteDatabase(getContentResolver(), mNoteId, Notes.TYPE_NOTE)) { + showActionDialog(); + playAlarmSound(); } else { - // 便签已被删除或类型非法:直接关闭页面,无任何展示与播放,避免无效操作 finish(); } } - /** - * 私有工具方法:判断当前设备的屏幕是否处于亮屏状态 - * @return boolean true=屏幕亮屏(解锁/未解锁但亮屏) false=屏幕熄屏/锁屏 - */ private boolean isScreenOn() { - // 获取系统电源管理服务实例 PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); - // 返回当前屏幕的亮灭状态 - return pm.isScreenOn(); + // 在Android 10+中,isScreenOn()方法已被弃用,应使用isInteractive() + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT_WATCH) { + return pm.isInteractive(); + } else { + return pm.isScreenOn(); + } } - /** - * 核心功能方法:初始化并播放系统默认的闹钟铃声,实现听觉提醒 - * 完整逻辑:获取系统铃声Uri → 适配系统静音模式 → 初始化播放器 → 循环播放 → 异常捕获,全链路健壮处理 - */ private void playAlarmSound() { - // 第一步:获取系统默认的闹钟铃声Uri,使用系统级铃声资源,适配用户个性化设置 - Uri url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM); - - // 第二步:读取系统铃声静音模式配置,判断闹钟音频流是否受静音规则影响,适配系统策略 - int silentModeStreams = Settings.System.getInt(getContentResolver(), - Settings.System.MODE_RINGER_STREAMS_AFFECTED, 0); - - // 第三步:设置音频流类型,核心适配逻辑,保证铃声播放遵循系统音量规则 - if ((silentModeStreams & (1 << AudioManager.STREAM_ALARM)) != 0) { - mPlayer.setAudioStreamType(silentModeStreams); - } else { - // 默认使用闹钟专属音频流,优先级高于普通媒体流,不会被媒体音量影响 - mPlayer.setAudioStreamType(AudioManager.STREAM_ALARM); + // 添加空检查,避免崩溃 + if (mPlayer == null) { + return; } - - // 第四步:初始化媒体播放器并启动循环播放,捕获所有可能的异常,保证程序稳定 + try { - mPlayer.setDataSource(this, url); // 设置铃声资源的数据源 - mPlayer.prepare(); // 同步准备播放器,加载铃声资源 - mPlayer.setLooping(true); // 核心配置:循环播放铃声,直到用户手动关闭 - mPlayer.start(); // 启动铃声播放,触发听觉提醒 - } catch (IllegalArgumentException e) { - e.printStackTrace(); - } catch (SecurityException e) { - e.printStackTrace(); - } catch (IllegalStateException e) { - e.printStackTrace(); - } catch (IOException e) { + Uri url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_ALARM); + if (url == null) { + // 如果没有默认的闹钟铃声,使用系统默认铃声 + url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_RINGTONE); + } + if (url == null) { + // 如果没有系统默认铃声,使用通知铃声 + url = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_NOTIFICATION); + } + if (url == null) { + // 如果都没有,直接返回 + return; + } + + int silentModeStreams = Settings.System.getInt(getContentResolver(), + Settings.System.MODE_RINGER_STREAMS_AFFECTED, 0); + + if ((silentModeStreams & (1 << AudioManager.STREAM_ALARM)) != 0) { + mPlayer.setAudioStreamType(silentModeStreams); + } else { + mPlayer.setAudioStreamType(AudioManager.STREAM_ALARM); + } + + mPlayer.setDataSource(this, url); + mPlayer.prepare(); + mPlayer.setLooping(true); + mPlayer.start(); + } catch (Exception e) { + // 捕获所有异常,避免崩溃 e.printStackTrace(); + // 发生异常时,释放播放器资源 + if (mPlayer != null) { + try { + mPlayer.release(); + mPlayer = null; + } catch (Exception ex) { + ex.printStackTrace(); + } + } } } - /** - * 核心功能方法:创建并展示便签提醒的核心弹窗,是页面的核心展示载体 - * 弹窗设计:标题为应用名,内容为便签摘要,按钮动态适配屏幕状态,绑定交互监听器,实现核心交互逻辑 - */ private void showActionDialog() { AlertDialog.Builder dialog = new AlertDialog.Builder(this); - dialog.setTitle(R.string.app_name); // 弹窗标题:固定为应用名称,简洁明了 - dialog.setMessage(mSnippet); // 弹窗核心内容:便签的文本摘要,展示提醒的核心信息 - dialog.setPositiveButton(R.string.notealert_ok, this); // 确认按钮,绑定点击事件监听器 - - // 智能按钮适配:仅在屏幕亮屏时展示「进入便签」按钮,熄屏/锁屏时不展示,贴合操作场景 + dialog.setTitle(R.string.app_name); + // 如果mSnippet为空,显示默认提示信息 + if (mSnippet == null || mSnippet.isEmpty()) { + dialog.setMessage(getString(R.string.set_remind_time_message)); + } else { + dialog.setMessage(mSnippet); + } + dialog.setPositiveButton(R.string.notealert_ok, this); if (isScreenOn()) { dialog.setNegativeButton(R.string.notealert_enter, this); } - - // 展示弹窗并绑定关闭监听器,无论何种方式关闭弹窗,均触发统一的收尾逻辑 dialog.show().setOnDismissListener(this); } - /** - * 对话框按钮点击事件回调方法,实现弹窗的核心交互逻辑 - * @param dialog 触发点击事件的对话框实例 - * @param which 点击的按钮类型,区分「确认」和「进入便签」按钮 - */ public void onClick(DialogInterface dialog, int which) { switch (which) { case DialogInterface.BUTTON_NEGATIVE: - // 点击「进入便签」按钮:跳转至该便签的编辑页面,精准定位到对应内容 Intent intent = new Intent(this, NoteEditActivity.class); - intent.setAction(Intent.ACTION_VIEW); // 设置跳转动作:查看/编辑便签 - intent.putExtra(Intent.EXTRA_UID, mNoteId); // 传递便签ID,目标页面根据ID查询并展示内容 + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, mNoteId); startActivity(intent); break; default: - // 点击「确认」按钮:无额外业务逻辑,仅关闭弹窗,收尾逻辑由OnDismissListener统一处理 break; } } - /** - * 对话框关闭事件回调方法,页面的统一收尾逻辑入口 - * 无论通过何种方式关闭弹窗(点击按钮、手动关闭),均执行该方法,保证逻辑闭环无遗漏 - * @param dialog 被关闭的对话框实例 - */ public void onDismiss(DialogInterface dialog) { - stopAlarmSound(); // 核心收尾:停止铃声播放并释放媒体资源,杜绝内存泄漏与音频残留 - finish(); // 关闭当前页面,释放所有资源,页面生命周期结束 + stopAlarmSound(); + finish(); } - /** - * 私有工具方法:停止铃声播放并安全释放媒体播放器资源 - * 核心作用:资源清理,是安卓媒体播放的强制规范,避免内存泄漏和音频通道占用 - */ private void stopAlarmSound() { if (mPlayer != null) { - mPlayer.stop(); // 立即停止铃声播放 - mPlayer.release(); // 释放播放器的所有资源,包括音频通道、内存等 - mPlayer = null; // 置空引用,便于GC回收,彻底杜绝内存泄漏 + try { + mPlayer.stop(); + mPlayer.release(); + mPlayer = null; + } catch (Exception e) { + // 捕获所有异常,避免崩溃 + e.printStackTrace(); + mPlayer = null; + } } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/AlarmInitReceiver.java b/src/Notes-master/src/net/micode/notes/ui/AlarmInitReceiver.java index 9cb875f..b9166eb 100644 --- a/src/Notes-master/src/net/micode/notes/ui/AlarmInitReceiver.java +++ b/src/Notes-master/src/net/micode/notes/ui/AlarmInitReceiver.java @@ -14,129 +14,86 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,便签提醒功能的初始化核心广播接收器,负责重启恢复所有未过期提醒 package net.micode.notes.ui; -// 安卓系统闹钟服务核心类,用于注册、设置、取消定时闹钟,是便签提醒的核心调度组件 import android.app.AlarmManager; -// 安卓延迟意图核心类,封装广播/页面跳转意图,交由AlarmManager在指定时间触发,核心桥梁类 import android.app.PendingIntent; -// 安卓广播核心基类,继承此类实现广播监听与处理能力,为本类的核心父类 import android.content.BroadcastReceiver; -// 安卓ContentURI拼接工具类,用于将Uri和数据ID拼接,生成唯一标识Uri,便于数据精准匹配 import android.content.ContentUris; -// 安卓上下文核心类,提供系统服务获取、内容解析器访问、组件通信等基础能力 import android.content.Context; -// 安卓意图核心类,组件间通信的核心载体,用于封装广播意图并携带数据 import android.content.Intent; -// 安卓数据库游标核心类,用于遍历查询数据库返回的结果集,读取便签提醒数据 import android.database.Cursor; -// 便签应用核心数据常量类,定义数据库Uri、便签类型、字段名等全局常量,统一管理 import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.NoteColumns; + /** - * 便签提醒初始化专属广播接收器【提醒恢复核心类】 - * 继承:BroadcastReceiver 安卓系统广播接收器基类,具备监听并处理广播事件的核心能力 - * 核心定位:小米便签「提醒功能」的保障类,解决「设备重启/应用进程被杀」后提醒丢失的核心问题,是便签提醒可靠性的关键支撑 - * 核心设计意义:安卓系统的AlarmManager注册的闹钟,在设备重启后会全部失效,应用进程被系统回收后也可能丢失未触发的闹钟,本类就是为了解决该问题,实现提醒的持久化保障 - * 核心职责: - * 1. 监听系统/应用的初始化类广播,包含「设备开机完成广播、应用启动广播、系统刷新广播」等核心触发时机; - * 2. 接收到广播后,通过ContentResolver查询便签数据库中所有符合条件的有效提醒数据; - * 3. 精准筛选:仅查询「提醒时间戳大于当前时间(未过期)」+「便签类型为普通便签TYPE_NOTE」的记录,排除文件夹/系统项/已过期提醒,无无效处理; - * 4. 遍历有效提醒数据,为每一条记录重新创建广播意图与延迟意图,通过AlarmManager完成闹钟的重新注册; - * 5. 保证所有未过期的便签提醒,在设备重启/应用重启后都能恢复如初,按时触发,无任何遗漏。 - * 核心特性&关键技术要点: - * 1. 精准数据筛选:查询条件双重过滤,既保证时效性(未过期),又保证数据合法性(普通便签),避免无效数据处理,降低性能损耗; - * 2. 轻量查询设计:自定义投影数组仅查询「便签ID、提醒时间戳」两个核心字段,不查询冗余内容,减少数据库IO开销与内存占用; - * 3. 唯一标识绑定:通过ContentUris拼接便签ID到Intent的Data字段,为每个提醒生成唯一的意图标识,精准绑定提醒与对应便签,无串号风险; - * 4. 强力唤醒保障:使用AlarmManager.RTC_WAKEUP闹钟类型,基于系统绝对时间,触发时会强制唤醒休眠的设备(亮屏/唤醒CPU),确保提醒必达,不会因设备休眠漏提醒; - * 5. 资源安全释放:查询数据库后及时关闭Cursor游标,释放数据库连接与内存资源,杜绝内存泄漏风险,符合安卓开发最佳实践; - * 6. 无状态极简设计:本类无成员变量、无复杂逻辑、无内存占用,仅在广播触发时执行一次性初始化逻辑,执行完成后立即释放资源,性能友好。 - * 核心业务约束: - * - 不处理已过期的提醒:提醒时间小于当前时间的记录直接过滤,无需注册,避免无效操作; - * - 不处理非普通便签:仅对TYPE_NOTE类型生效,文件夹(TYPE_FOLDER)等无提醒功能的类型直接排除; - * - 不处理无提醒的便签:仅查询ALERTED_DATE字段大于0的记录,无提醒的便签不会进入查询结果。 - * 完整业务闭环: - * 便签设置提醒 → AlarmManager注册闹钟 → 设备重启/应用重启 → 本接收器接收初始化广播 → 查询数据库有效提醒 → 重新注册所有闹钟 → 提醒时间到达 → AlarmReceiver触发 → 展示提醒弹窗。 + * 闹钟初始化广播接收器 + * 在系统启动时自动初始化所有待处理的便签提醒闹钟 + * 确保设备重启后,所有设置了提醒的便签仍然能够按时触发 + * + * 架构设计: + * - 继承自BroadcastReceiver,监听系统启动完成广播 + * - 通过ContentResolver查询所有设置了未来提醒时间的便签 + * - 为每个符合条件的便签重新设置AlarmManager闹钟 + * - 适配不同Android版本的AlarmManager API变化 + * + * 核心功能: + * - 系统启动后自动初始化闹钟 + * - 查询所有未触发的提醒便签 + * - 为每个便签设置准确的闹钟 + * - 适配Android 6.0+的setExact方法 + * - 适配Android 12+的PendingIntent flag要求 */ public class AlarmInitReceiver extends BroadcastReceiver { /** - * 数据库查询投影数组【按需查询,性能优化】 - * 设计目的:指定本次数据库查询仅需要返回的字段,不查询冗余字段,减少数据库IO传输数据量,降低内存消耗,提升查询效率 - * 字段说明:仅包含两个核心必要字段,满足业务需求的最小化查询 + * 查询投影列,包含便签ID和提醒日期 */ private static final String [] PROJECTION = new String [] { - NoteColumns.ID, // 数组索引0:便签的唯一主键ID,用于绑定提醒与便签、生成唯一Intent标识 - NoteColumns.ALERTED_DATE // 数组索引1:便签设置的提醒时间戳,毫秒级,用于设置闹钟的触发时间 + NoteColumns.ID, + NoteColumns.ALERTED_DATE }; - // 投影数组列索引常量【硬编码优化】 - // 设计目的:将数组索引封装为常量,替代代码中的硬编码数字,提升代码可读性、可维护性,避免索引写错导致的程序异常 - private static final int COLUMN_ID = 0; // 对应PROJECTION[0],便签ID列索引 - private static final int COLUMN_ALERTED_DATE = 1; // 对应PROJECTION[1],提醒时间戳列索引 - /** - * 广播接收核心回调方法,广播触发时由安卓系统自动调用 - * 本类的唯一核心方法,承载了「查询有效提醒+重新注册闹钟」的全部核心逻辑,逻辑清晰、步骤明确、无冗余处理 - * @param context 广播接收器的运行上下文对象,不可为空,核心作用:获取ContentResolver查询数据库、获取AlarmManager系统服务、创建Intent意图 - * @param intent 触发本次广播的意图对象,携带广播的触发类型信息,本类无需解析该意图内容,仅做触发执行 + * 便签ID列索引 + */ + private static final int COLUMN_ID = 0; + /** + * 提醒日期列索引 */ + private static final int COLUMN_ALERTED_DATE = 1; + @Override public void onReceive(Context context, Intent intent) { - // 第一步:获取当前系统时间戳,作为筛选「未过期提醒」的核心阈值,只处理提醒时间在当前时间之后的记录 long currentDate = System.currentTimeMillis(); - - // 第二步:通过ContentResolver查询便签数据库,获取所有符合条件的有效提醒数据 - // 核心查询参数说明: - // 1. uri:查询地址 → Notes.CONTENT_NOTE_URI 普通便签的专属Uri,精准定位查询表 - // 2. projection:查询字段 → 自定义的PROJECTION数组,仅查ID和提醒时间戳 - // 3. selection:查询条件 → 双重过滤:提醒时间>当前时间 且 便签类型为普通便签,精准筛选有效数据 - // 4. selectionArgs:条件参数 → 传入当前时间戳的字符串形式,防止SQL注入,符合安卓安全规范 - // 5. sortOrder:排序规则 → null,使用数据库默认排序,无需额外排序,提升查询效率 Cursor c = context.getContentResolver().query(Notes.CONTENT_NOTE_URI, PROJECTION, NoteColumns.ALERTED_DATE + ">? AND " + NoteColumns.TYPE + "=" + Notes.TYPE_NOTE, new String[] { String.valueOf(currentDate) }, null); - // 第三步:遍历查询结果,为每个有效提醒重新注册闹钟 - if (c != null) { // 游标非空校验:避免查询结果为空时的空指针异常,代码健壮性保障 - // 游标移动到第一条数据,存在有效提醒时进入循环遍历逻辑 + if (c != null) { if (c.moveToFirst()) { do { - // 3.1 从游标中读取当前行的核心数据:便签ID、提醒触发时间戳 - long alertDate = c.getLong(COLUMN_ALERTED_DATE); // 该便签的提醒触发时间,毫秒级 - long noteId = c.getLong(COLUMN_ID); // 该便签的唯一主键ID - - // 3.2 创建广播意图:指定意图的目标为AlarmReceiver,即提醒时间到达时,需要触发的广播接收器 + long alertDate = c.getLong(COLUMN_ALERTED_DATE); Intent sender = new Intent(context, AlarmReceiver.class); - // 核心关键:将便签ID拼接至Intent的Data字段,生成唯一的Uri标识,精准绑定该提醒与对应便签 - // 作用:AlarmReceiver接收到广播时,可通过该Uri解析出便签ID,进而查询便签详情展示提醒内容,无串号风险 - sender.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId)); - - // 3.3 创建PendingIntent延迟意图:封装上述广播意图,交由AlarmManager管理,在指定时间触发广播 - // PendingIntent.getBroadcast参数说明:上下文、请求码(此处传0即可)、待封装的广播意图、flags标记(默认0) - // 核心作用:PendingIntent是一种授权意图,允许系统在未来的指定时间,以当前应用的身份发送该广播,是闹钟触发的核心载体 - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, 0); - - // 3.4 获取系统AlarmManager服务实例,系统级的闹钟调度核心服务 + sender.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, c.getLong(COLUMN_ID))); + // 使用适当的PendingIntent flag,确保在Android 12+中正常工作 + int flags = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0; + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, sender, flags); AlarmManager alermManager = (AlarmManager) context .getSystemService(Context.ALARM_SERVICE); - - // 3.5 核心操作:重新注册闹钟,完成提醒恢复 - // alermManager.set参数说明: - // 1. type → AlarmManager.RTC_WAKEUP 最核心的闹钟类型,基于系统绝对时间,触发时唤醒设备CPU/屏幕,保证提醒必达 - // 2. triggerAtTime → 闹钟触发的具体时间,即从数据库读取的提醒时间戳alertDate - // 3. operation → 待触发的PendingIntent延迟意图,触发时发送广播至AlarmReceiver - alermManager.set(AlarmManager.RTC_WAKEUP, alertDate, pendingIntent); - - } while (c.moveToNext()); // 循环遍历游标,处理所有有效提醒数据 + // 在Android 6.0+中,使用setExact方法以确保准确的提醒时间 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + alermManager.setExact(AlarmManager.RTC_WAKEUP, alertDate, pendingIntent); + } else { + alermManager.set(AlarmManager.RTC_WAKEUP, alertDate, pendingIntent); + } + } while (c.moveToNext()); } - // 第四步:必须关闭游标,释放数据库资源与内存,杜绝内存泄漏,安卓数据库操作的强制规范 c.close(); } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/AlarmReceiver.java b/src/Notes-master/src/net/micode/notes/ui/AlarmReceiver.java index 0f59670..4a33790 100644 --- a/src/Notes-master/src/net/micode/notes/ui/AlarmReceiver.java +++ b/src/Notes-master/src/net/micode/notes/ui/AlarmReceiver.java @@ -14,50 +14,43 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,便签提醒功能的广播接收核心类,承接闹钟触发逻辑 package net.micode.notes.ui; -// 安卓广播核心基类,继承此类实现广播接收能力,监听系统/应用发送的广播事件 import android.content.BroadcastReceiver; -// 安卓上下文核心类,提供应用运行环境、组件启动、资源访问等基础能力 import android.content.Context; -// 安卓意图核心类,用于组件间通信、页面跳转、数据传递的核心载体 import android.content.Intent; /** - * 便签提醒功能专属广播接收器【轻量级广播处理类】 - * 继承:BroadcastReceiver 安卓系统广播接收器基类,具备监听并处理广播的核心能力 - * 核心定位:小米便签「提醒功能」的核心中转类,是闹钟触发与提醒弹窗展示的唯一桥梁,无任何业务逻辑,只做事件转发 - * 核心职责: - * 1. 监听由系统AlarmManager定时发送的闹钟广播,该广播在用户设置的便签提醒时间到达时触发; - * 2. 接收到广播后,无缝承接Intent中携带的便签提醒数据(便签ID、标题、内容等); - * 3. 对Intent做标准化配置,指定跳转目标页面并补充必要的启动标记; - * 4. 启动便签提醒弹窗页面,完成提醒事件的最终展示,触发用户感知。 - * 核心特性&关键注意点: - * 1. 无状态设计:该类无任何成员变量、无初始化逻辑、无复杂处理,是纯功能性的极简类,内存占用极低; - * 2. 必加启动标记:BroadcastReceiver 属于四大组件之一,运行时无独立的Activity任务栈上下文,启动Activity时必须添加 FLAG_ACTIVITY_NEW_TASK 标记,否则会抛出运行时异常,这是安卓的硬性规范; - * 3. 数据透传:广播携带的所有Intent数据会完整透传给目标页面,无任何数据丢失或修改,保证提醒内容的正确性; - * 4. 生命周期极简:广播接收器的onReceive方法执行时间被系统严格限制,本类逻辑极致轻量化,可在瞬时完成执行,无超时风险; - * 5. 唯一触发源:该接收器只响应便签应用自身注册的闹钟广播,不接收其他任何广播事件,功能单一无干扰。 - * 典型业务流程闭环: - * 便签编辑页设置提醒时间 → 后台通过AlarmManager注册定时闹钟 → 提醒时间到达,系统发送广播 → 本接收器接收广播 → 启动AlarmAlertActivity → 展示便签提醒弹窗,响铃/震动提醒用户。 - * 典型使用场景:唯一用途就是接收便签的提醒广播,无其他任何业务场景。 + * 闹钟广播接收器 + * 接收AlarmManager触发的闹钟广播,启动AlarmAlertActivity显示便签提醒 + * 作为闹钟触发和提醒显示之间的桥梁 + * + * 架构设计: + * - 继承自BroadcastReceiver,监听闹钟触发广播 + * - 接收到广播后,创建指向AlarmAlertActivity的Intent + * - 添加FLAG_ACTIVITY_NEW_TASK标志,确保在非活动上下文下也能启动活动 + * - 启动AlarmAlertActivity显示提醒界面 + * - 包含异常处理,确保广播接收过程不会崩溃 + * + * 核心功能: + * - 接收闹钟触发广播 + * - 启动提醒显示活动 + * - 确保启动过程的安全性和稳定性 */ public class AlarmReceiver extends BroadcastReceiver { - - /** - * 广播接收核心回调方法,广播触发时系统自动调用该方法 - * 该方法是本类的唯一核心方法,承载了所有的广播处理逻辑,极简且高效 - * @param context 广播接收器的运行上下文对象,不可为空,用于启动目标Activity组件 - * @param intent 触发本次广播的意图对象,内部携带了完整的便签提醒数据,也是页面跳转的核心数据载体 - */ @Override public void onReceive(Context context, Intent intent) { - // 第一步:为当前Intent指定跳转的目标页面,将广播意图转为页面跳转意图 - intent.setClass(context, AlarmAlertActivity.class); - // 第二步:添加必选的新任务启动标记,解决广播无任务栈启动Activity的上下文问题,规避运行时异常 - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - // 第三步:启动提醒弹窗页面,完成广播事件的最终转发,展示便签提醒内容 - context.startActivity(intent); + // 添加空检查,避免崩溃 + if (context == null || intent == null) { + return; + } + try { + intent.setClass(context, AlarmAlertActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } catch (Exception e) { + // 捕获所有异常,避免崩溃 + e.printStackTrace(); + } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/ChatActivity.java b/src/Notes-master/src/net/micode/notes/ui/ChatActivity.java new file mode 100644 index 0000000..ab87c88 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/ui/ChatActivity.java @@ -0,0 +1,438 @@ +package net.micode.notes.ui; + +import android.app.ActionBar; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; +import android.text.TextUtils; +import android.graphics.Color; + +import net.micode.notes.R; +import net.micode.notes.data.Messages; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.tool.UserManager; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 聊天活动 + *

+ * 该类负责实现用户与好友之间的聊天功能,包括消息的发送、接收和显示。 + * 它通过NotesDatabaseHelper操作消息数据表,实现聊天记录的存储和加载。 + */ +public class ChatActivity extends Activity { + private static final String TAG = "ChatActivity"; + + private ListView mChatListView; + private EditText mMessageEditText; + private Button mSendButton; + private ChatAdapter mChatAdapter; + private List mMessageList; + + private NotesDatabaseHelper mDbHelper; + private SQLiteDatabase mDb; + private UserManager mUserManager; + + private long mCurrentUserId; + private long mFriendId; + private String mFriendUsername; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + try { + setContentView(R.layout.chat_activity); + + // 设置ActionBar + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + } + + // 获取Intent参数 + mFriendId = getIntent().getLongExtra("friend_id", -1); + mFriendUsername = getIntent().getStringExtra("friend_username"); + + if (mFriendId == -1 || mFriendUsername == null) { + Toast.makeText(this, "无效的好友信息", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + // 设置ActionBar标题为好友用户名 + if (actionBar != null) { + actionBar.setTitle(mFriendUsername); + } + + // 初始化数据库 + mDbHelper = NotesDatabaseHelper.getInstance(this); + if (mDbHelper != null) { + mDb = mDbHelper.getWritableDatabase(); + } + + // 初始化UserManager + mUserManager = UserManager.getInstance(this); + if (mUserManager != null) { + mCurrentUserId = mUserManager.getCurrentUserId(); + } + + // 初始化界面控件 + mChatListView = findViewById(R.id.chat_list); + mMessageEditText = findViewById(R.id.message_edit_text); + mSendButton = findViewById(R.id.send_button); + + // 初始化消息列表 + mMessageList = new ArrayList<>(); + mChatAdapter = new ChatAdapter(this, mMessageList, mCurrentUserId); + mChatListView.setAdapter(mChatAdapter); + + // 设置发送按钮点击事件 + mSendButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + sendMessage(); + } + }); + + // 加载聊天记录 + loadChatHistory(); + } catch (Exception e) { + Log.e(TAG, "Error in onCreate: " + e.getMessage(), e); + Toast.makeText(this, "聊天界面初始化失败", Toast.LENGTH_SHORT).show(); + finish(); + } + } + + @Override + protected void onResume() { + super.onResume(); + + try { + // 重新获取当前用户ID,确保在账号切换后能使用正确的用户ID + if (mUserManager != null) { + mCurrentUserId = mUserManager.getCurrentUserId(); + Log.d(TAG, "Updated current user ID to: " + mCurrentUserId); + } + + // 更新适配器的当前用户ID + if (mChatAdapter != null) { + mChatAdapter.mCurrentUserId = mCurrentUserId; + } + + // 重新加载聊天记录 + loadChatHistory(); + } catch (Exception e) { + Log.e(TAG, "Error in onResume: " + e.getMessage(), e); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 关闭数据库连接 + if (mDb != null && mDb.isOpen()) { + mDb.close(); + } + } + + @Override + public boolean onOptionsItemSelected(android.view.MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // 返回上一级活动 + finish(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + /** + * 加载聊天记录 + */ + private void loadChatHistory() { + mMessageList.clear(); + + // 查询聊天记录 + String sql = "SELECT * FROM " + NotesDatabaseHelper.TABLE.MESSAGE + " WHERE " + + "(" + Messages.MessageColumns.SENDER_ID + " = ? AND " + Messages.MessageColumns.RECEIVER_ID + " = ?) OR " + + "(" + Messages.MessageColumns.SENDER_ID + " = ? AND " + Messages.MessageColumns.RECEIVER_ID + " = ?) " + + "ORDER BY " + Messages.MessageColumns.CREATED_DATE + " ASC"; + + Cursor cursor = mDb.rawQuery(sql, new String[]{ + String.valueOf(mCurrentUserId), String.valueOf(mFriendId), + String.valueOf(mFriendId), String.valueOf(mCurrentUserId) + }); + + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(Messages.MessageColumns.ID)); + long senderId = cursor.getLong(cursor.getColumnIndexOrThrow(Messages.MessageColumns.SENDER_ID)); + long receiverId = cursor.getLong(cursor.getColumnIndexOrThrow(Messages.MessageColumns.RECEIVER_ID)); + String content = cursor.getString(cursor.getColumnIndexOrThrow(Messages.MessageColumns.CONTENT)); + int messageType = cursor.getInt(cursor.getColumnIndexOrThrow(Messages.MessageColumns.MESSAGE_TYPE)); + long createdDate = cursor.getLong(cursor.getColumnIndexOrThrow(Messages.MessageColumns.CREATED_DATE)); + int isRead = cursor.getInt(cursor.getColumnIndexOrThrow(Messages.MessageColumns.IS_READ)); + + mMessageList.add(new ChatMessage(id, senderId, receiverId, content, messageType, createdDate, isRead)); + } + cursor.close(); + } + + // 通知适配器数据变化 + mChatAdapter.notifyDataSetChanged(); + + // 滚动到底部 + if (!mMessageList.isEmpty()) { + mChatListView.setSelection(mMessageList.size() - 1); + } + + // 将接收到的消息标记为已读 + markMessagesAsRead(); + } + + /** + * 发送消息 + */ + private void sendMessage() { + String content = mMessageEditText.getText().toString().trim(); + if (content.isEmpty()) { + Toast.makeText(this, "消息内容不能为空", Toast.LENGTH_SHORT).show(); + return; + } + + // 创建消息对象 + ContentValues values = new ContentValues(); + values.put(Messages.MessageColumns.SENDER_ID, mCurrentUserId); + values.put(Messages.MessageColumns.RECEIVER_ID, mFriendId); + values.put(Messages.MessageColumns.CONTENT, content); + values.put(Messages.MessageColumns.MESSAGE_TYPE, Messages.MessageType.TEXT); + values.put(Messages.MessageColumns.CREATED_DATE, System.currentTimeMillis()); + values.put(Messages.MessageColumns.IS_READ, 0); + + // 插入消息到数据库 + long messageId = mDb.insert(NotesDatabaseHelper.TABLE.MESSAGE, null, values); + if (messageId != -1) { + // 清空输入框 + mMessageEditText.setText(""); + + // 重新加载聊天记录 + loadChatHistory(); + } else { + Toast.makeText(this, "发送失败", Toast.LENGTH_SHORT).show(); + Log.e(TAG, "Failed to send message"); + } + } + + /** + * 将接收到的消息标记为已读 + */ + private void markMessagesAsRead() { + ContentValues values = new ContentValues(); + values.put(Messages.MessageColumns.IS_READ, 1); + + int updatedRows = mDb.update(NotesDatabaseHelper.TABLE.MESSAGE, values, + Messages.MessageColumns.SENDER_ID + " = ? AND " + Messages.MessageColumns.RECEIVER_ID + " = ? AND " + Messages.MessageColumns.IS_READ + " = 0", + new String[]{String.valueOf(mFriendId), String.valueOf(mCurrentUserId)}); + + Log.d(TAG, "Marked " + updatedRows + " messages as read"); + } + + /** + * 显示便签详情 + */ + private void showNoteDetail(String noteData) { + try { + // 解析便签数据 + String[] noteParts = noteData.split("\\|"); + if (noteParts.length < 2) { + Toast.makeText(this, "无效的便签数据", Toast.LENGTH_SHORT).show(); + return; + } + + String noteTitle = noteParts[0]; + String noteContent = noteParts[1]; + + if (TextUtils.isEmpty(noteTitle)) { + noteTitle = "无标题便签"; + } + + // 创建并显示便签详情对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(noteTitle); + builder.setMessage(noteContent); + builder.setPositiveButton("确定", null); + builder.show(); + } catch (Exception e) { + Log.e(TAG, "Error showing note detail: " + e.getMessage(), e); + Toast.makeText(this, "显示便签详情失败", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 聊天消息实体类 + */ + private static class ChatMessage { + long id; + long senderId; + long receiverId; + String content; + int messageType; + long createdDate; + int isRead; + + ChatMessage(long id, long senderId, long receiverId, String content, int messageType, long createdDate, int isRead) { + this.id = id; + this.senderId = senderId; + this.receiverId = receiverId; + this.content = content; + this.messageType = messageType; + this.createdDate = createdDate; + this.isRead = isRead; + } + } + + /** + * 聊天消息适配器 + */ + private static class ChatAdapter extends BaseAdapter { + private static final int VIEW_TYPE_SENT_TEXT = 0; + private static final int VIEW_TYPE_RECEIVED_TEXT = 1; + private static final int VIEW_TYPE_SENT_NOTE = 2; + private static final int VIEW_TYPE_RECEIVED_NOTE = 3; + + private Context mContext; + private List mMessageList; + public long mCurrentUserId; + private LayoutInflater mInflater; + private SimpleDateFormat mDateFormat; + + ChatAdapter(Context context, List messageList, long currentUserId) { + mContext = context; + mMessageList = messageList; + mCurrentUserId = currentUserId; + mInflater = LayoutInflater.from(context); + mDateFormat = new SimpleDateFormat("HH:mm"); + } + + @Override + public int getCount() { + return mMessageList.size(); + } + + @Override + public Object getItem(int position) { + return mMessageList.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public int getViewTypeCount() { + return 4; // 四种视图类型:发送文本、接收文本、发送便签、接收便签 + } + + @Override + public int getItemViewType(int position) { + ChatMessage message = mMessageList.get(position); + boolean isSentByMe = message.senderId == mCurrentUserId; + if (message.messageType == Messages.MessageType.NOTE) { + return isSentByMe ? VIEW_TYPE_SENT_NOTE : VIEW_TYPE_RECEIVED_NOTE; + } else { + return isSentByMe ? VIEW_TYPE_SENT_TEXT : VIEW_TYPE_RECEIVED_TEXT; + } + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ChatMessage message = mMessageList.get(position); + boolean isSentByMe = message.senderId == mCurrentUserId; + + ViewHolder holder; + if (convertView == null) { + // 根据视图类型选择不同的布局 + int viewType = getItemViewType(position); + switch (viewType) { + case VIEW_TYPE_SENT_NOTE: + convertView = mInflater.inflate(R.layout.chat_message_sent_note_item, parent, false); + break; + case VIEW_TYPE_RECEIVED_NOTE: + convertView = mInflater.inflate(R.layout.chat_message_received_note_item, parent, false); + break; + case VIEW_TYPE_SENT_TEXT: + default: + convertView = mInflater.inflate(R.layout.chat_message_sent_item, parent, false); + break; + case VIEW_TYPE_RECEIVED_TEXT: + convertView = mInflater.inflate(R.layout.chat_message_received_item, parent, false); + break; + } + + holder = new ViewHolder(); + holder.contentTextView = convertView.findViewById(R.id.message_content); + holder.timeTextView = convertView.findViewById(R.id.message_time); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + // 设置消息内容 + if (message.messageType == Messages.MessageType.NOTE) { + // 便签类型消息,只显示便签标题 + String[] noteData = message.content.split("\\|"); + if (noteData.length >= 2) { + String noteTitle = noteData[0]; + if (TextUtils.isEmpty(noteTitle)) { + noteTitle = "无标题便签"; + } + holder.contentTextView.setText(noteTitle); + // 设置不同的颜色,区分于普通文本消息 + holder.contentTextView.setTextColor(Color.BLUE); // 使用蓝色区分便签消息 + holder.contentTextView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + // 点击便签,查看详情 + ((ChatActivity) mContext).showNoteDetail(message.content); + } + }); + } + } else { + // 普通文本消息 + holder.contentTextView.setText(message.content); + holder.contentTextView.setOnClickListener(null); + } + + // 设置消息时间 + Date date = new Date(message.createdDate); + holder.timeTextView.setText(mDateFormat.format(date)); + + return convertView; + } + + private static class ViewHolder { + TextView contentTextView; + TextView timeTextView; + } + } +} diff --git a/src/Notes-master/src/net/micode/notes/ui/DateTimePicker.java b/src/Notes-master/src/net/micode/notes/ui/DateTimePicker.java index d829df7..b9ec91b 100644 --- a/src/Notes-master/src/net/micode/notes/ui/DateTimePicker.java +++ b/src/Notes-master/src/net/micode/notes/ui/DateTimePicker.java @@ -14,190 +14,197 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,日期时间选择的核心自定义复合控件,供弹窗调用 package net.micode.notes.ui; -// Java文本格式化工具类,获取系统的上午/下午文本标识,适配多语言环境 import java.text.DateFormatSymbols; -// Java核心日历工具类,全局唯一的时间数据载体,处理所有年月日时分的计算、联动、转换逻辑 import java.util.Calendar; -// 小米便签资源文件引用,获取布局、字符串等资源ID常量 import net.micode.notes.R; -// 安卓系统核心类 - 上下文,提供控件创建、资源加载、布局填充的运行环境 + import android.content.Context; -// 安卓系统日期格式化工具类,提供系统24小时制判断、日期文本格式化能力 import android.text.format.DateFormat; -// 安卓视图体系核心类,视图基础属性配置、可见性控制、布局容器核心父类 import android.view.View; import android.widget.FrameLayout; -// 安卓数字滚轮选择器,本控件的核心子组件,实现年月日时分的滚轮滑动选择UI import android.widget.NumberPicker; /** - * 自定义日期时间复合选择控件【核心基础UI控件】 - * 继承:FrameLayout 帧布局,作为复合控件的根布局容器,承载多个子选择器 - * 核心定位:小米便签「提醒时间设置」功能的底层核心控件,被DateTimePickerDialog弹窗集成使用 - * 核心设计理念:将「日期选择+小时选择+分钟选择+上下午选择」整合为统一控件,封装所有时间联动逻辑、格式适配逻辑、数据处理逻辑,对外提供极简的调用与通信接口 - * 核心特性与职责: - * 1. 布局整合:内置日期、小时、分钟、上下午共4个NumberPicker滚轮选择器,形成一体化的时间选择UI; - * 2. 数据闭环:基于Calendar类做全局唯一的时间数据管理,所有选择操作最终同步至该对象,保证数据一致性; - * 3. 智能联动:完美处理所有时间边界联动场景,如分钟59→0时小时+1、小时23→0时日期+1、12小时制跨上下午切换等,无数据断层; - * 4. 系统适配:自动识别并适配系统的24小时制/12小时制设置,支持手动强制切换,两种制式无缝兼容,交互无感知; - * 5. 日期展示:固定展示近7天的日期列表,格式为「月.日 星期」,满足便签短周期提醒的业务需求; - * 6. 状态统一:支持控件整体启用/禁用,一键同步所有子选择器的交互状态,无需单独配置; - * 7. 标准化通信:提供时间变化的回调接口,选择操作实时触发回调,向外传递标准化的年月日时分数据; - * 8. 细节优化:分钟选择器支持长按快速滚动、上下午文本适配系统多语言、时间数值边界校验等细节体验优化; - * 9. 防抖动处理:初始化阶段屏蔽回调触发,避免初始化时的无效数据通知,提升性能与稳定性。 - * 核心优势:控件内聚性极强,所有时间相关的逻辑全部封装内部,外部调用方只需关心「设置初始时间」「获取选中时间」「监听时间变化」三个核心操作,完全无需处理内部复杂逻辑。 - * 典型业务场景:唯一使用场景为DateTimePickerDialog弹窗的核心内容视图,支撑便签的提醒时间选择功能。 + * 日期时间选择器控件 + * 提供用户界面来选择日期和时间,支持年、月、日、时、分的精确选择 + * 继承自FrameLayout,内部使用多个NumberPicker组件实现 + * + * 架构设计: + * - 继承自FrameLayout,作为容器包含多个NumberPicker + * - 内部使用Calendar实例管理日期时间数据 + * - 提供年、月、日、时、分的选择器 + * - 支持24小时制和12小时制切换 + * - 实现各种值变化监听器,处理选择逻辑 + * - 提供回调接口通知外部日期时间变化 + * + * 核心功能: + * - 日期选择:支持选择一周内的日期 + * - 时间选择:支持小时和分钟的选择 + * - 24/12小时制切换:根据系统设置或手动切换 + * - AM/PM切换:在12小时制下显示 + * - 边界处理:自动处理日期时间的边界情况(如跨天、跨小时) + * - 实时更新:选择变化时实时更新内部日期时间 + * - 回调通知:通过接口通知外部日期时间变化 + * - 状态保存:保存和恢复当前选择的日期时间 */ public class DateTimePicker extends FrameLayout { - // ======================== 基础常量区 - 控件默认配置与固定数值 ======================== - /** 控件默认启用状态:初始化时默认所有选择器均可交互 */ + /** + * 默认启用状态 + */ private static final boolean DEFAULT_ENABLE_STATE = true; - // ======================== 常量区 - 时间单位核心数值 ======================== - /** 半天小时数:12小时制的核心分界值,上午/下午的小时数上限 */ + /** + * 半天的小时数 + */ private static final int HOURS_IN_HALF_DAY = 12; - /** 全天小时数:24小时制的核心数值,一天的总小时数 */ + /** + * 一天的小时数 + */ private static final int HOURS_IN_ALL_DAY = 24; - /** 一周天数:日期选择器固定展示的天数,不可修改,适配短周期提醒业务 */ + /** + * 一周的天数 + */ private static final int DAYS_IN_ALL_WEEK = 7; - - // ======================== 常量区 - NumberPicker滚轮选择器 取值范围约束 ======================== - /** 日期选择器-最小值:固定为0,对应近7天列表的第一条数据 */ + /** + * 日期选择器最小值 + */ private static final int DATE_SPINNER_MIN_VAL = 0; - /** 日期选择器-最大值:固定为6,对应近7天列表的最后一条数据,数值等于天数减1 */ + /** + * 日期选择器最大值 + */ private static final int DATE_SPINNER_MAX_VAL = DAYS_IN_ALL_WEEK - 1; - - /** 24小时制-小时选择器-最小值:凌晨0点,时间起点 */ + /** + * 24小时制下小时选择器最小值 + */ private static final int HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW = 0; - /** 24小时制-小时选择器-最大值:深夜23点,时间终点 */ + /** + * 24小时制下小时选择器最大值 + */ private static final int HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW = 23; - - /** 12小时制-小时选择器-最小值:上午/下午的1点,无0点概念 */ + /** + * 12小时制下小时选择器最小值 + */ private static final int HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW = 1; - /** 12小时制-小时选择器-最大值:上午/下午的12点,封顶数值 */ + /** + * 12小时制下小时选择器最大值 + */ private static final int HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW = 12; - - /** 分钟选择器-最小值:整点0分,分钟起点 */ + /** + * 分钟选择器最小值 + */ private static final int MINUT_SPINNER_MIN_VAL = 0; - /** 分钟选择器-最大值:整点前1分钟,59分,分钟终点 */ + /** + * 分钟选择器最大值 + */ private static final int MINUT_SPINNER_MAX_VAL = 59; - - /** 上下午选择器-最小值:0,对应上午/AM */ + /** + * AM/PM选择器最小值 + */ private static final int AMPM_SPINNER_MIN_VAL = 0; - /** 上下午选择器-最大值:1,对应下午/PM */ + /** + * AM/PM选择器最大值 + */ private static final int AMPM_SPINNER_MAX_VAL = 1; - // ======================== 成员变量区 - UI核心组件【所有子选择器,全局持用避免重复查找】 ======================== - /** 日期滚轮选择器:核心展示近7天的格式化日期文本,如「01.15 周四」,支持滑动切换日期 */ + /** + * 日期选择器 + */ private final NumberPicker mDateSpinner; - /** 小时滚轮选择器:根据24/12小时制展示对应范围的小时数,核心时间选择组件 */ + /** + * 小时选择器 + */ private final NumberPicker mHourSpinner; - /** 分钟滚轮选择器:固定0~59的分钟数选择,支持长按快速滚动,核心时间选择组件 */ + /** + * 分钟选择器 + */ private final NumberPicker mMinuteSpinner; - /** 上下午滚轮选择器:仅12小时制显示,0=上午/AM,1=下午/PM,适配12小时制的时间展示 */ + /** + * AM/PM选择器 + */ private final NumberPicker mAmPmSpinner; - - // ======================== 成员变量区 - 核心状态与数据载体【全局核心数据,控件的大脑】 ======================== - /** 核心日历对象:全局唯一的时间数据载体,存储当前选中的完整年月日时分信息,所有选择操作最终同步至此,所有外部获取操作均来源于此,保证数据唯一可信 */ + /** + * 日期时间日历实例 + */ private Calendar mDate; - /** 日期展示文本数组:缓存近7天的格式化日期文本,供日期选择器展示使用,避免重复计算 */ + + /** + * 日期显示值数组 + */ private String[] mDateDisplayValues = new String[DAYS_IN_ALL_WEEK]; - /** 上下午状态标记:true=上午/AM,false=下午/PM,仅在12小时制下生效,控制时间计算与展示 */ + + /** + * 是否为上午 + */ private boolean mIsAm; - /** 24小时制状态标记:true=启用24小时制,隐藏上下午选择器;false=启用12小时制,显示上下午选择器 */ + + /** + * 是否使用24小时制 + */ private boolean mIs24HourView; - /** 控件整体启用状态标记:true=所有选择器可交互,false=所有选择器禁用,统一管控交互权限 */ + + /** + * 是否启用 + */ private boolean mIsEnabled = DEFAULT_ENABLE_STATE; - /** 初始化状态标记:true=控件正在初始化,屏蔽所有回调触发;false=初始化完成,正常响应所有操作与回调,防止初始化阶段的无效数据通知 */ - private boolean mInitialising; - // ======================== 成员变量区 - 回调通信接口 ======================== - /** 日期时间变化的回调监听器:外部实现该接口,接收控件的时间变化通知,是控件与外部通信的唯一桥梁 */ - private OnDateTimeChangedListener mOnDateTimeChangedListener; + /** + * 是否正在初始化 + */ + private boolean mInitialising; - // ======================== 成员变量区 - 滚轮选择器 值变化监听器【所有选择器的核心交互逻辑,内部闭环】 ======================== /** - * 日期选择器 值变化监听器:处理日期滑动切换的核心逻辑 - * 核心能力:监听日期选择器的数值变化,计算日期偏移量,同步更新核心日历对象的日期,刷新日期展示文本,最终触发时间变化回调 - * 无边界特殊处理:日期选择器固定展示近7天,滑动仅在7天内切换,无需处理跨月跨年的复杂逻辑 + * 日期时间变化监听器 */ + private OnDateTimeChangedListener mOnDateTimeChangedListener; + private NumberPicker.OnValueChangeListener mOnDateChangedListener = new NumberPicker.OnValueChangeListener() { @Override public void onValueChange(NumberPicker picker, int oldVal, int newVal) { - // 计算日期偏移量:新值-旧值 即为需要增减的天数,直接同步至核心日历对象 mDate.add(Calendar.DAY_OF_YEAR, newVal - oldVal); - // 刷新日期选择器的展示文本,保证文本与选中日期一致 updateDateControl(); - // 触发全局时间变化回调,向外通知日期已更新 onDateTimeChanged(); } }; - /** - * 小时选择器 值变化监听器:处理小时滑动切换的核心逻辑【最复杂的联动逻辑】 - * 核心能力:兼容24/12小时制的小时选择,处理所有小时边界的联动场景,包含「小时→日期」「小时→上下午」的双层联动,同步更新核心日历对象 - * 核心处理场景: - * 1. 12小时制:下午11点→12点 → 日期+1;上午12点→11点 → 日期-1;小时11↔12时自动切换上下午状态; - * 2. 24小时制:23点→0点 → 日期+1;0点→23点 → 日期-1;无上下切换逻辑; - * 3. 所有场景下,最终将选中的小时数适配转换为24小时制,同步至核心日历对象,保证数据统一。 - */ private NumberPicker.OnValueChangeListener mOnHourChangedListener = new NumberPicker.OnValueChangeListener() { @Override public void onValueChange(NumberPicker picker, int oldVal, int newVal) { - boolean isDateChanged = false; // 日期是否发生变化的标记位,默认无变化 - Calendar cal = Calendar.getInstance(); // 临时日历对象,用于处理日期偏移 - - // ========== 12小时制 小时边界特殊处理 ========== + boolean isDateChanged = false; + Calendar cal = Calendar.getInstance(); if (!mIs24HourView) { - // 场景1:下午状态下,小时从11→12,触发日期+1(跨天) if (!mIsAm && oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) { cal.setTimeInMillis(mDate.getTimeInMillis()); cal.add(Calendar.DAY_OF_YEAR, 1); isDateChanged = true; - } - // 场景2:上午状态下,小时从12→11,触发日期-1(跨天) - else if (mIsAm && oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1) { + } else if (mIsAm && oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1) { cal.setTimeInMillis(mDate.getTimeInMillis()); cal.add(Calendar.DAY_OF_YEAR, -1); isDateChanged = true; } - - // 场景3:小时在11和12之间切换时,自动翻转上下午状态(核心联动逻辑) if (oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY || oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1) { mIsAm = !mIsAm; - updateAmPmControl(); // 同步刷新上下午选择器的选中状态 + updateAmPmControl(); } - } - // ========== 24小时制 小时边界特殊处理 ========== - else { - // 场景1:小时从23→0,触发日期+1(跨天,一天的结束) + } else { if (oldVal == HOURS_IN_ALL_DAY - 1 && newVal == 0) { cal.setTimeInMillis(mDate.getTimeInMillis()); cal.add(Calendar.DAY_OF_YEAR, 1); isDateChanged = true; - } - // 场景2:小时从0→23,触发日期-1(跨天,一天的开始) - else if (oldVal == 0 && newVal == HOURS_IN_ALL_DAY - 1) { + } else if (oldVal == 0 && newVal == HOURS_IN_ALL_DAY - 1) { cal.setTimeInMillis(mDate.getTimeInMillis()); cal.add(Calendar.DAY_OF_YEAR, -1); isDateChanged = true; } } - - // ========== 统一处理:将选中的小时数转换为24小时制,同步至核心日历对象 ========== int newHour = mHourSpinner.getValue() % HOURS_IN_HALF_DAY + (mIsAm ? 0 : HOURS_IN_HALF_DAY); mDate.set(Calendar.HOUR_OF_DAY, newHour); - // 触发全局时间变化回调,向外通知小时已更新 onDateTimeChanged(); - - // ========== 日期发生变化时,同步更新核心日历对象的年月日 ========== if (isDateChanged) { setCurrentYear(cal.get(Calendar.YEAR)); setCurrentMonth(cal.get(Calendar.MONTH)); @@ -206,146 +213,82 @@ public class DateTimePicker extends FrameLayout { } }; - /** - * 分钟选择器 值变化监听器:处理分钟滑动切换的核心逻辑【分钟→小时→日期 三层联动】 - * 核心能力:监听分钟选择器的数值变化,处理分钟的边界联动场景,是最基础也是最核心的时间联动逻辑 - * 核心处理场景:分钟从59→0 触发小时+1;分钟从0→59 触发小时-1;小时变化后可能触发日期变化,自动联动处理 - * 附加能力:小时变化后,自动同步更新上下午状态与选择器,保证12小时制的展示一致性 - */ private NumberPicker.OnValueChangeListener mOnMinuteChangedListener = new NumberPicker.OnValueChangeListener() { @Override public void onValueChange(NumberPicker picker, int oldVal, int newVal) { int minValue = mMinuteSpinner.getMinValue(); int maxValue = mMinuteSpinner.getMaxValue(); - int offset = 0; // 小时偏移量,默认无偏移 - - // 场景1:分钟从59→0,触发小时+1(分钟到顶,进位到小时) + int offset = 0; if (oldVal == maxValue && newVal == minValue) { offset += 1; - } - // 场景2:分钟从0→59,触发小时-1(分钟到底,退位到小时) - else if (oldVal == minValue && newVal == maxValue) { + } else if (oldVal == minValue && newVal == maxValue) { offset -= 1; } - - // ========== 小时需要偏移时,执行联动逻辑 ========== if (offset != 0) { - mDate.add(Calendar.HOUR_OF_DAY, offset); // 同步更新核心日历对象的小时数 - mHourSpinner.setValue(getCurrentHour()); // 刷新小时选择器的选中值 - updateDateControl(); // 刷新日期选择器,小时偏移可能导致日期变化 - - // 根据新的小时数,更新上下午状态标记 + mDate.add(Calendar.HOUR_OF_DAY, offset); + mHourSpinner.setValue(getCurrentHour()); + updateDateControl(); int newHour = getCurrentHourOfDay(); - mIsAm = newHour < HOURS_IN_HALF_DAY; - updateAmPmControl(); // 同步刷新上下午选择器的选中状态 + if (newHour >= HOURS_IN_HALF_DAY) { + mIsAm = false; + updateAmPmControl(); + } else { + mIsAm = true; + updateAmPmControl(); + } } - - // ========== 统一处理:将选中的分钟数同步至核心日历对象 ========== mDate.set(Calendar.MINUTE, newVal); - // 触发全局时间变化回调,向外通知分钟已更新 onDateTimeChanged(); } }; - /** - * 上下午选择器 值变化监听器:处理上下午切换的核心逻辑【仅12小时制生效】 - * 核心能力:监听上下午选择器的切换操作,翻转上下午状态标记,同步调整核心日历对象的小时数(±12小时) - * 核心逻辑:上午→下午,小时+12;下午→上午,小时-12,保证时间数值的正确性,无数据误差 - */ private NumberPicker.OnValueChangeListener mOnAmPmChangedListener = new NumberPicker.OnValueChangeListener() { @Override public void onValueChange(NumberPicker picker, int oldVal, int newVal) { - // 翻转上下午状态标记 mIsAm = !mIsAm; - // 根据新状态,调整核心日历对象的小时数,保证时间正确 if (mIsAm) { mDate.add(Calendar.HOUR_OF_DAY, -HOURS_IN_HALF_DAY); } else { mDate.add(Calendar.HOUR_OF_DAY, HOURS_IN_HALF_DAY); } - // 刷新上下午选择器的展示状态 updateAmPmControl(); - // 触发全局时间变化回调,向外通知上下午已切换 onDateTimeChanged(); } }; - // ======================== 内部回调接口 - 日期时间变化通知【标准化通信协议】 ======================== - /** - * 日期时间变化回调接口:控件对外提供的唯一通信接口,所有时间选择操作的最终数据出口 - * 设计原则:解耦控件内部逻辑与外部业务逻辑,控件只负责提供时间选择能力,不处理任何业务逻辑 - * 调用时机:日期、小时、分钟、上下午任一选择器发生变化时,均会触发该接口的回调方法,实时传递最新的时间数据 - */ public interface OnDateTimeChangedListener { - /** - * 日期时间变化的回调方法,传递标准化的时间数据 - * @param view 当前的DateTimePicker控件实例,外部可通过该实例获取更多信息或执行操作 - * @param year 选中的年份,如 2026 - * @param month 选中的月份,遵循Calendar规范,0代表1月,11代表12月 - * @param dayOfMonth 选中的日期,当月的第几天,如 15 - * @param hourOfDay 选中的小时,固定为24小时制数值,0~23,外部无需做格式转换,直接使用 - * @param minute 选中的分钟,0~59,标准化数值 - */ void onDateTimeChanged(DateTimePicker view, int year, int month, int dayOfMonth, int hourOfDay, int minute); } - // ======================== 构造方法区 - 三级重载构造,满足不同初始化需求【核心初始化入口】 ======================== - /** - * 构造方法1:最简初始化,无参重载 - * 核心能力:使用系统当前时间作为初始值,自动适配系统的24小时制设置,一键创建控件 - * @param context 应用上下文对象,不可为空 - */ public DateTimePicker(Context context) { this(context, System.currentTimeMillis()); } - /** - * 构造方法2:指定初始时间初始化 - * 核心能力:传入指定的毫秒级时间戳作为初始值,自动适配系统的24小时制设置,灵活适配业务需求 - * @param context 应用上下文对象,不可为空 - * @param date 初始选中的时间戳,单位:毫秒,支持任意合法的时间戳 - */ public DateTimePicker(Context context, long date) { this(context, date, DateFormat.is24HourFormat(context)); } - /** - * 构造方法3:全参数核心初始化,控件的最终初始化入口,所有构造方法最终均调用此方法 - * 核心能力:一站式完成「上下文初始化+核心数据初始化+布局填充+子选择器初始化+监听器绑定+状态配置+初始时间设置」,无需外部执行任何额外配置,开箱即用 - * @param context 应用上下文对象,用于加载布局、创建控件、获取系统配置 - * @param date 初始选中的时间戳,单位:毫秒,控件打开时默认展示的时间 - * @param is24HourView 是否启用24小时制,true=启用,false=启用12小时制 - */ public DateTimePicker(Context context, long date, boolean is24HourView) { super(context); - mDate = Calendar.getInstance(); // 初始化核心日历对象,默认加载系统当前时间 - mInitialising = true; // 标记进入初始化阶段,屏蔽所有回调触发,防止无效数据通知 - // 初始化上下午状态标记:根据当前小时数判断,≥12为下午,<12为上午 + mDate = Calendar.getInstance(); + mInitialising = true; mIsAm = getCurrentHourOfDay() >= HOURS_IN_HALF_DAY; - - // 第一步:填充控件的核心布局,将xml布局文件加载至当前FrameLayout根容器 inflate(context, R.layout.datetime_picker, this); - // 第二步:初始化所有子滚轮选择器,绑定控件ID,配置基础属性与监听器 - // 初始化日期选择器:配置取值范围+绑定值变化监听器 mDateSpinner = (NumberPicker) findViewById(R.id.date); mDateSpinner.setMinValue(DATE_SPINNER_MIN_VAL); mDateSpinner.setMaxValue(DATE_SPINNER_MAX_VAL); mDateSpinner.setOnValueChangedListener(mOnDateChangedListener); - // 初始化小时选择器:仅绑定值变化监听器,取值范围在24/12小时制设置时动态配置 mHourSpinner = (NumberPicker) findViewById(R.id.hour); mHourSpinner.setOnValueChangedListener(mOnHourChangedListener); - - // 初始化分钟选择器:配置取值范围+设置长按快速滚动间隔+绑定值变化监听器,细节体验优化 mMinuteSpinner = (NumberPicker) findViewById(R.id.minute); mMinuteSpinner.setMinValue(MINUT_SPINNER_MIN_VAL); mMinuteSpinner.setMaxValue(MINUT_SPINNER_MAX_VAL); - mMinuteSpinner.setLongPressUpdateInterval(100); // 长按滚动间隔100ms,滚动更快更流畅 + mMinuteSpinner.setOnLongPressUpdateInterval(100); mMinuteSpinner.setOnValueChangedListener(mOnMinuteChangedListener); - // 初始化上下午选择器:配置取值范围+加载系统原生的AM/PM文本(适配多语言)+绑定值变化监听器 String[] stringsForAmPm = new DateFormatSymbols().getAmPmStrings(); mAmPmSpinner = (NumberPicker) findViewById(R.id.amPm); mAmPmSpinner.setMinValue(AMPM_SPINNER_MIN_VAL); @@ -353,85 +296,69 @@ public class DateTimePicker extends FrameLayout { mAmPmSpinner.setDisplayedValues(stringsForAmPm); mAmPmSpinner.setOnValueChangedListener(mOnAmPmChangedListener); - // 第三步:刷新所有子选择器的初始展示状态,保证UI与初始数据一致 + // update controls to initial state updateDateControl(); updateHourControl(); updateAmPmControl(); - // 第四步:配置24/12小时制状态,完成制式适配 set24HourView(is24HourView); - // 第五步:设置控件的初始选中时间,同步至所有选择器与核心日历对象 + // set to current time setCurrentDate(date); - // 第六步:配置控件的整体启用状态,默认启用 setEnabled(isEnabled()); - // 第七步:初始化完成,解除初始化标记,控件进入正常工作状态,可响应所有操作与回调 + // set the content descriptions mInitialising = false; } - // ======================== 重写父类方法 - 控件整体启用/禁用【统一状态管控】 ======================== - /** - * 重写FrameLayout的setEnabled方法,实现控件整体的启用/禁用控制 - * 核心能力:一键同步所有子滚轮选择器的交互状态,无需单独配置每个选择器,保证状态一致性 - * 优化点:状态未变化时直接返回,避免重复执行无效操作,提升性能 - * @param enabled true=启用,所有选择器可滑动选择;false=禁用,所有选择器不可交互,灰显 - */ @Override public void setEnabled(boolean enabled) { if (mIsEnabled == enabled) { return; } super.setEnabled(enabled); - // 同步所有子选择器的启用状态 mDateSpinner.setEnabled(enabled); mMinuteSpinner.setEnabled(enabled); mHourSpinner.setEnabled(enabled); mAmPmSpinner.setEnabled(enabled); - // 更新全局启用状态标记 mIsEnabled = enabled; } - /** - * 重写FrameLayout的isEnabled方法,获取控件的整体启用状态 - * @return true=控件已启用,false=控件已禁用 - */ @Override public boolean isEnabled() { return mIsEnabled; } - // ======================== 公共方法区 - 日期时间 取值/赋值 标准化API【外部核心调用接口,最全】 ======================== /** - * 获取当前选中的完整时间戳,外部最常用的取值方法 - * @return 选中时间的毫秒级时间戳,可直接用于存储、传输、转换,标准化输出 + * Get the current date in millis + * + * @return the current date in millis */ public long getCurrentDateInTimeMillis() { return mDate.getTimeInMillis(); } /** - * 设置当前选中的时间,通过毫秒级时间戳赋值,外部最常用的赋值方法 - * 核心能力:自动解析时间戳为年月日时分,同步至核心日历对象与所有子选择器,无需手动拆分 - * @param date 要设置的时间戳,单位:毫秒,支持任意合法的时间戳 + * Set the current date + * + * @param date The current date in millis */ public void setCurrentDate(long date) { Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(date); - // 解析时间戳为标准化的年月日时分,调用重载方法完成赋值 setCurrentDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH), cal.get(Calendar.DAY_OF_MONTH), cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)); } /** - * 设置当前选中的时间,通过标准化的年月日时分赋值,最底层的赋值方法 - * 核心能力:精细化控制每个时间维度的数值,同步至核心日历对象与所有子选择器,数据完全可控 - * @param year 年份,如 2026 - * @param month 月份,Calendar规范,0=1月 - * @param dayOfMonth 日期,当月的第几天,1~31 - * @param hourOfDay 小时,24小时制,0~23 - * @param minute 分钟,0~59 + * Set the current date + * + * @param year The current year + * @param month The current month + * @param dayOfMonth The current dayOfMonth + * @param hourOfDay The current hourOfDay + * @param minute The current minute */ public void setCurrentDate(int year, int month, int dayOfMonth, int hourOfDay, int minute) { @@ -443,17 +370,18 @@ public class DateTimePicker extends FrameLayout { } /** - * 获取当前选中的年份 - * @return 年份数值,如 2026 + * Get current year + * + * @return The current year */ public int getCurrentYear() { return mDate.get(Calendar.YEAR); } /** - * 设置当前选中的年份 - * 优化点:初始化阶段或数值未变化时直接返回,避免无效操作与回调触发 - * @param year 要设置的年份,如 2026 + * Set current year + * + * @param year The current year */ public void setCurrentYear(int year) { if (!mInitialising && year == getCurrentYear()) { @@ -465,17 +393,18 @@ public class DateTimePicker extends FrameLayout { } /** - * 获取当前选中的月份 - * @return 月份数值,遵循Calendar规范,0代表1月,11代表12月 + * Get current month in the year + * + * @return The current month in the year */ public int getCurrentMonth() { return mDate.get(Calendar.MONTH); } /** - * 设置当前选中的月份 - * 优化点:初始化阶段或数值未变化时直接返回,避免无效操作与回调触发 - * @param month 要设置的月份,Calendar规范,0=1月 + * Set current month in the year + * + * @param month The month in the year */ public void setCurrentMonth(int month) { if (!mInitialising && month == getCurrentMonth()) { @@ -487,17 +416,18 @@ public class DateTimePicker extends FrameLayout { } /** - * 获取当前选中的日期 - * @return 日期数值,当月的第几天,1~31 + * Get current day of the month + * + * @return The day of the month */ public int getCurrentDay() { return mDate.get(Calendar.DAY_OF_MONTH); } /** - * 设置当前选中的日期 - * 优化点:初始化阶段或数值未变化时直接返回,避免无效操作与回调触发 - * @param dayOfMonth 要设置的日期,1~31 + * Set current day of the month + * + * @param dayOfMonth The day of the month */ public void setCurrentDay(int dayOfMonth) { if (!mInitialising && dayOfMonth == getCurrentDay()) { @@ -509,68 +439,65 @@ public class DateTimePicker extends FrameLayout { } /** - * 获取当前选中的小时数,固定返回24小时制数值,标准化输出,外部无需转换 - * @return 小时数值,0~23 + * Get current hour in 24 hour mode, in the range (0~23) + * @return The current hour in 24 hour mode */ public int getCurrentHourOfDay() { return mDate.get(Calendar.HOUR_OF_DAY); } - /** - * 内部私有方法:获取适配当前制式的小时数 - * 核心能力:根据24/12小时制,返回对应范围的小时数,供内部选择器赋值使用,不对外暴露 - * @return 24小时制返回0~23,12小时制返回1~12,适配选择器的取值范围 - */ private int getCurrentHour() { if (mIs24HourView){ return getCurrentHourOfDay(); } else { int hour = getCurrentHourOfDay(); - // 12小时制特殊转换:0点→12点,13点→1点,保证选择器展示正确 - return hour > HOURS_IN_HALF_DAY ? hour - HOURS_IN_HALF_DAY : (hour == 0 ? HOURS_IN_HALF_DAY : hour); + if (hour > HOURS_IN_HALF_DAY) { + return hour - HOURS_IN_HALF_DAY; + } else { + return hour == 0 ? HOURS_IN_HALF_DAY : hour; + } } } /** - * 设置当前选中的小时数,接收24小时制数值,标准化输入,内部自动适配转换 - * 核心能力:自动处理24/12小时制的转换逻辑,同步更新上下午状态与选择器,数据无误差 - * 优化点:初始化阶段或数值未变化时直接返回,避免无效操作与回调触发 - * @param hourOfDay 要设置的小时数,24小时制,0~23 + * Set current hour in 24 hour mode, in the range (0~23) + * + * @param hourOfDay */ public void setCurrentHour(int hourOfDay) { if (!mInitialising && hourOfDay == getCurrentHourOfDay()) { return; } mDate.set(Calendar.HOUR_OF_DAY, hourOfDay); - - // 12小时制下的特殊处理:转换小时数+更新上下午状态 if (!mIs24HourView) { if (hourOfDay >= HOURS_IN_HALF_DAY) { mIsAm = false; - hourOfDay = hourOfDay > HOURS_IN_HALF_DAY ? hourOfDay - HOURS_IN_HALF_DAY : hourOfDay; + if (hourOfDay > HOURS_IN_HALF_DAY) { + hourOfDay -= HOURS_IN_HALF_DAY; + } } else { mIsAm = true; - hourOfDay = hourOfDay == 0 ? HOURS_IN_HALF_DAY : hourOfDay; + if (hourOfDay == 0) { + hourOfDay = HOURS_IN_HALF_DAY; + } } updateAmPmControl(); } - mHourSpinner.setValue(hourOfDay); onDateTimeChanged(); } /** - * 获取当前选中的分钟数 - * @return 分钟数值,0~59 + * Get currentMinute + * + * @return The Current Minute */ public int getCurrentMinute() { return mDate.get(Calendar.MINUTE); } /** - * 设置当前选中的分钟数 - * 优化点:初始化阶段或数值未变化时直接返回,避免无效操作与回调触发 - * @param minute 要设置的分钟数,0~59 + * Set current minute */ public void setCurrentMinute(int minute) { if (!mInitialising && minute == getCurrentMinute()) { @@ -581,110 +508,76 @@ public class DateTimePicker extends FrameLayout { onDateTimeChanged(); } - // ======================== 公共方法区 - 24小时制适配【系统级适配能力】 ======================== /** - * 判断当前控件是否启用24小时制 - * @return true=24小时制,false=12小时制 + * @return true if this is in 24 hour view else false. */ - public boolean is24HourView () { - return mIs24HourView; - } + public boolean is24HourView () { + return mIs24HourView; + } /** - * 设置控件的时间展示制式,手动强制切换24/12小时制,优先级高于系统配置 - * 核心能力:切换制式时,自动刷新小时选择器的取值范围、上下午选择器的可见性、当前小时数的展示值,无缝切换无感知 - * 优化点:状态未变化时直接返回,避免无效操作 - * @param is24HourView true=启用24小时制,隐藏上下午选择器;false=启用12小时制,显示上下午选择器 + * Set whether in 24 hour or AM/PM mode. + * + * @param is24HourView True for 24 hour mode. False for AM/PM mode. */ - public void set24HourView(boolean is24HourView) { - if (mIs24HourView == is24HourView) { - return; - } - mIs24HourView = is24HourView; - // 控制上下午选择器的可见性 + public void set24HourView(boolean is24HourView) { + if (mIs24HourView == is24HourView) { + return; + } + mIs24HourView = is24HourView; mAmPmSpinner.setVisibility(is24HourView ? View.GONE : View.VISIBLE); int hour = getCurrentHourOfDay(); - // 刷新小时选择器的取值范围 updateHourControl(); - // 重新适配并设置小时数,保证展示正确 setCurrentHour(hour); - // 刷新上下午选择器的状态 updateAmPmControl(); - } + } - // ======================== 内部私有方法区 - 控件UI刷新【核心UI更新逻辑,内部闭环】 ======================== - /** - * 刷新日期选择器的展示文本与选中状态 - * 核心能力:重新计算并生成近7天的格式化日期文本,更新至日期选择器,保证展示的日期与核心日历对象一致 - * 展示格式:固定为「MM.dd EEEE」,即「月.日 星期」,如「01.15 星期四」 - */ private void updateDateControl() { Calendar cal = Calendar.getInstance(); cal.setTimeInMillis(mDate.getTimeInMillis()); - // 计算近7天的起始日期:当前日期向前推4天,保证选中日期在列表中间位置,交互更友好 cal.add(Calendar.DAY_OF_YEAR, -DAYS_IN_ALL_WEEK / 2 - 1); - - mDateSpinner.setDisplayedValues(null); // 清空原有文本,避免残留 - // 循环生成7天的格式化日期文本 + mDateSpinner.setDisplayedValues(null); for (int i = 0; i < DAYS_IN_ALL_WEEK; ++i) { cal.add(Calendar.DAY_OF_YEAR, 1); mDateDisplayValues[i] = (String) DateFormat.format("MM.dd EEEE", cal); } - // 将生成的文本数组设置到日期选择器 mDateSpinner.setDisplayedValues(mDateDisplayValues); - // 默认选中列表中间项,即当前日期 mDateSpinner.setValue(DAYS_IN_ALL_WEEK / 2); - mDateSpinner.invalidate(); // 强制刷新UI,保证展示生效 + mDateSpinner.invalidate(); } - /** - * 刷新上下午选择器的选中状态与可见性 - * 核心能力:根据当前的24小时制状态与上下午标记,同步更新上下午选择器的展示,保证UI与数据一致 - */ private void updateAmPmControl() { if (mIs24HourView) { mAmPmSpinner.setVisibility(View.GONE); } else { - // 设置选中项:0=上午/AM,1=下午/PM int index = mIsAm ? Calendar.AM : Calendar.PM; mAmPmSpinner.setValue(index); mAmPmSpinner.setVisibility(View.VISIBLE); } } - /** - * 刷新小时选择器的取值范围 - * 核心能力:根据当前的24小时制状态,动态配置小时选择器的最小值与最大值,适配不同制式的展示需求 - */ - private void updateHourControl() { - if (mIs24HourView) { - mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW); - mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW); - } else { - mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW); - mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW); - } - } + private void updateHourControl() { + if (mIs24HourView) { + mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_24_HOUR_VIEW); + mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_24_HOUR_VIEW); + } else { + mHourSpinner.setMinValue(HOUR_SPINNER_MIN_VAL_12_HOUR_VIEW); + mHourSpinner.setMaxValue(HOUR_SPINNER_MAX_VAL_12_HOUR_VIEW); + } + } - // ======================== 公共方法区 - 回调监听器绑定【通信入口】 ======================== /** - * 设置日期时间变化的回调监听器,外部通过此方法绑定业务逻辑 - * @param callback 外部实现的OnDateTimeChangedListener接口实例,传null则取消监听 + * Set the callback that indicates the 'Set' button has been pressed. + * @param callback the callback, if null will do nothing */ public void setOnDateTimeChangedListener(OnDateTimeChangedListener callback) { mOnDateTimeChangedListener = callback; } - // ======================== 内部私有方法区 - 回调触发【核心通信逻辑,内部闭环】 ======================== - /** - * 触发日期时间变化的回调方法,所有选择操作的最终数据出口 - * 核心能力:从核心日历对象中读取标准化的年月日时分数据,调用外部绑定的监听器,完成数据传递 - * 优化点:监听器为null时直接返回,避免空指针异常 - */ private void onDateTimeChanged() { if (mOnDateTimeChangedListener != null) { mOnDateTimeChangedListener.onDateTimeChanged(this, getCurrentYear(), getCurrentMonth(), getCurrentDay(), getCurrentHourOfDay(), getCurrentMinute()); } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/DateTimePickerDialog.java b/src/Notes-master/src/net/micode/notes/ui/DateTimePickerDialog.java index 019694a..a97590a 100644 --- a/src/Notes-master/src/net/micode/notes/ui/DateTimePickerDialog.java +++ b/src/Notes-master/src/net/micode/notes/ui/DateTimePickerDialog.java @@ -14,160 +14,115 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,日期时间选择弹窗的核心封装类,集成自定义时间选择控件 package net.micode.notes.ui; -// Java日历工具类,核心时间处理载体,存储用户选中的年月日时分秒,提供时间的赋值与转换能力 import java.util.Calendar; -// 小米便签资源文件引用,获取字符串、布局等资源ID常量 import net.micode.notes.R; -// 自定义日期时间选择核心控件,本弹窗的核心内容视图,提供年月日时分滚轮选择UI import net.micode.notes.ui.DateTimePicker; import net.micode.notes.ui.DateTimePicker.OnDateTimeChangedListener; -// 安卓系统弹窗核心类,本类的父类,提供弹窗的基础展示与按钮配置能力 import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.content.DialogInterface.OnClickListener; -// 安卓系统日期格式化工具类,提供24小时制判断、时间戳转友好文本的格式化能力 import android.text.format.DateFormat; import android.text.format.DateUtils; /** - * 日期时间选择弹窗封装类【功能型弹窗核心类】 - * 继承:AlertDialog 安卓原生弹窗基类,具备系统弹窗的所有基础特性 - * 核心定位:小米便签「设置便签提醒时间」功能的专属弹窗,整合自定义DateTimePicker控件实现完整的时间选择能力 - * 核心设计思想:将「时间选择控件+弹窗载体+时间数据处理+回调通信」全部封装,对外提供极简调用API - * 核心职责: - * 1. 集成自定义DateTimePicker滚轮选择控件,作为弹窗的核心内容视图,提供年月日时分的可视化选择; - * 2. 统一管理选中的时间数据,基于Calendar类存储完整时间信息,自动处理时间联动更新逻辑; - * 3. 适配系统全局的24小时制/12小时制显示规则,自动切换时间展示格式,保证与系统行为一致; - * 4. 实时更新弹窗标题为当前选中的格式化时间文本,给用户直观的选择反馈; - * 5. 封装标准化的回调接口,选择完成后向外传递最终的毫秒级时间戳,解耦业务逻辑; - * 6. 固化弹窗的按钮交互逻辑:确定按钮触发回调、取消按钮关闭弹窗无操作,交互统一; - * 7. 时间精度控制:仅保留到分钟级,自动置空秒数,符合便签提醒的业务使用场景。 - * 典型业务场景:便签编辑页的「添加提醒」「修改提醒时间」功能弹窗,是便签日程提醒的核心交互载体。 + * 日期时间选择对话框 + * 提供用户界面来选择日期和时间,用于设置便签的提醒时间 + * 继承自AlertDialog,集成了DateTimePicker组件 + * + * 架构设计: + * - 继承自AlertDialog,实现OnClickListener接口 + * - 内部使用DateTimePicker作为日期时间选择控件 + * - 提供OnDateTimeSetListener接口回调选择结果 + * - 支持24小时制和12小时制显示 + * - 自动更新对话框标题以反映当前选择的时间 + * + * 核心功能: + * - 显示日期时间选择界面 + * - 支持年、月、日、时、分的选择 + * - 实时更新选择的日期时间 + * - 提供确定和取消按钮 + * - 回调选择的日期时间结果 + * - 适配系统的时间格式设置 */ public class DateTimePickerDialog extends AlertDialog implements OnClickListener { - // 核心时间数据载体:存储用户当前选中的完整日期时间,初始化时获取系统当前时间,秒数固定置0 + /** + * 日期时间日历实例 + */ private Calendar mDate = Calendar.getInstance(); - // 24小时制标记位:适配系统设置,决定时间的展示格式(24小时/12小时带上午下午) + /** + * 是否使用24小时制 + */ private boolean mIs24HourView; - // 时间选择完成的回调监听器:外部实现该接口接收最终选中的时间戳,核心通信桥梁 + /** + * 日期时间设置监听器 + */ private OnDateTimeSetListener mOnDateTimeSetListener; - // 自定义日期时间选择控件:本弹窗的核心内容视图,提供滚轮式年月日时分选择UI,业务核心控件 + /** + * 日期时间选择器 + */ private DateTimePicker mDateTimePicker; /** - * 日期时间选择完成的回调接口【标准化通信接口】 - * 设计原则:解耦弹窗内部逻辑与外部业务逻辑,弹窗只负责提供选择能力,不处理业务逻辑 - * 调用时机:用户点击弹窗的「确定」按钮后,触发该接口的回调方法,传递选中的时间数据 + * 日期时间设置监听器接口 */ public interface OnDateTimeSetListener { /** - * 时间选择完成的回调方法 - * @param dialog 当前的日期时间选择弹窗实例,外部可通过该实例做弹窗关闭等操作 - * @param date 用户最终选中的时间戳,单位:毫秒,秒数已固定置为0,精度到分钟级 + * 当用户设置日期时间时调用 + * @param dialog 对话框实例 + * @param date 选择的日期时间(毫秒) */ void OnDateTimeSet(AlertDialog dialog, long date); } - /** - * 构造方法:初始化日期时间选择弹窗的全部核心配置【一站式初始化】 - * 核心能力:传入上下文和初始时间戳后,自动完成「控件创建+视图绑定+数据初始化+事件绑定+按钮配置+标题更新」 - * 无需外部执行任何额外配置,开箱即用,调用简洁 - * @param context 应用上下文对象,用于创建弹窗、加载资源、适配系统设置,不可为空 - * @param date 弹窗初始化时的默认选中时间戳,单位:毫秒,支持传入指定时间做默认值展示 - */ public DateTimePickerDialog(Context context, long date) { super(context); - // 第一步:创建自定义的日期时间选择控件,作为弹窗的核心内容视图,替换原生弹窗的默认布局 mDateTimePicker = new DateTimePicker(context); setView(mDateTimePicker); - - // 第二步:为时间选择控件绑定实时变化监听器,核心联动逻辑 - // 监听用户在滚轮上的每一次选择操作,实时同步选中的时间数据到Calendar对象中 mDateTimePicker.setOnDateTimeChangedListener(new OnDateTimeChangedListener() { public void onDateTimeChanged(DateTimePicker view, int year, int month, int dayOfMonth, int hourOfDay, int minute) { - // 实时更新核心时间载体:将滚轮选中的年月日时分赋值到Calendar对象 mDate.set(Calendar.YEAR, year); mDate.set(Calendar.MONTH, month); mDate.set(Calendar.DAY_OF_MONTH, dayOfMonth); mDate.set(Calendar.HOUR_OF_DAY, hourOfDay); mDate.set(Calendar.MINUTE, minute); - // 实时更新弹窗标题:将最新选中的时间格式化后展示在弹窗顶部,给用户即时反馈 updateTitle(mDate.getTimeInMillis()); } }); - - // 第三步:初始化选中的时间数据,设置默认选中的时间戳,统一时间精度 mDate.setTimeInMillis(date); - mDate.set(Calendar.SECOND, 0); // 强制置空秒数,仅保留到分钟级,符合业务使用场景 - // 将初始化的时间数据同步到时间选择控件,保证弹窗打开时展示正确的默认时间 + mDate.set(Calendar.SECOND, 0); mDateTimePicker.setCurrentDate(mDate.getTimeInMillis()); - - // 第四步:配置弹窗的底部按钮,固化交互逻辑,符合用户操作习惯 - setButton(context.getString(R.string.datetime_dialog_ok), this); // 确定按钮,绑定当前类的点击事件 - setButton2(context.getString(R.string.datetime_dialog_cancel), (OnClickListener)null); // 取消按钮,无业务逻辑,点击仅关闭弹窗 - - // 第五步:适配系统全局的时间显示规则,自动获取系统是否开启24小时制,保证体验一致性 + setButton(context.getString(R.string.datetime_dialog_ok), this); + setButton2(context.getString(R.string.datetime_dialog_cancel), (OnClickListener)null); set24HourView(DateFormat.is24HourFormat(this.getContext())); - - // 第六步:初始化弹窗标题,将默认时间格式化后展示,完成弹窗的最终初始化 updateTitle(mDate.getTimeInMillis()); } - /** - * 公有配置方法:手动设置弹窗的时间显示格式 - * 补充能力:支持外部手动覆盖系统的24小时制设置,按需指定显示规则,适配特殊业务场景 - * @param is24HourView true=强制使用24小时制展示时间,false=强制使用12小时制展示时间 - */ public void set24HourView(boolean is24HourView) { mIs24HourView = is24HourView; } - /** - * 公有绑定方法:设置时间选择完成的回调监听器 - * 核心作用:为弹窗绑定外部的业务逻辑处理类,是弹窗向外传递数据的唯一入口 - * @param callBack 外部实现的OnDateTimeSetListener接口实例,接收最终选中的时间戳 - */ public void setOnDateTimeSetListener(OnDateTimeSetListener callBack) { mOnDateTimeSetListener = callBack; } - /** - * 私有工具方法:更新弹窗标题文本【核心格式化方法】 - * 核心职责:将毫秒级的时间戳,按照指定的格式规则,转换为用户友好的日期时间文本 - * 设计亮点:通过格式化标记位组合,灵活配置展示内容,无需手写格式化字符串,适配性更强 - * @param date 需要格式化展示的时间戳,单位:毫秒 - */ private void updateTitle(long date) { - // 定义时间格式化的标记位组合,指定需要展示的时间维度:年 + 月日 + 时分 int flag = - DateUtils.FORMAT_SHOW_YEAR | // 强制展示年份,如「2026年」 - DateUtils.FORMAT_SHOW_DATE | // 强制展示日期,如「1月15日」 - DateUtils.FORMAT_SHOW_TIME; // 强制展示时间,如「15:30」或「下午3:30」 - // 根据24小时制标记位,追加对应的格式化规则,自动切换显示格式 + DateUtils.FORMAT_SHOW_YEAR | + DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_TIME; flag |= mIs24HourView ? DateUtils.FORMAT_24HOUR : DateUtils.FORMAT_24HOUR; - // 将时间戳格式化后,设置为弹窗的标题文本,完成UI更新 setTitle(DateUtils.formatDateTime(this.getContext(), date, flag)); } - /** - * 弹窗确定按钮的点击事件处理【核心交互方法】 - * 实现OnClickListener接口的核心方法,仅响应确定按钮的点击行为 - * 核心逻辑:判断是否绑定了回调监听器,若绑定则触发回调,传递选中的时间戳,完成数据通信 - * @param arg0 触发点击事件的弹窗对话框实例 - * @param arg1 被点击按钮的索引标识,对应确定/取消等按钮类型 - */ public void onClick(DialogInterface arg0, int arg1) { - // 空值安全校验:避免未绑定回调监听器时触发空指针异常 if (mOnDateTimeSetListener != null) { - // 触发外部回调,传递弹窗实例和最终选中的时间戳,完成时间选择的业务闭环 mOnDateTimeSetListener.OnDateTimeSet(this, mDate.getTimeInMillis()); } } diff --git a/src/Notes-master/src/net/micode/notes/ui/DropdownMenu.java b/src/Notes-master/src/net/micode/notes/ui/DropdownMenu.java index 849b09f..2360cf0 100644 --- a/src/Notes-master/src/net/micode/notes/ui/DropdownMenu.java +++ b/src/Notes-master/src/net/micode/notes/ui/DropdownMenu.java @@ -14,109 +14,96 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,下拉菜单功能的统一封装工具类,全局复用 package net.micode.notes.ui; -// 安卓系统核心类 - 上下文,提供资源加载、控件创建的运行环境,必须依赖项 import android.content.Context; -// 安卓菜单体系核心类,Menu管理菜单整体结构,MenuItem表示单个菜单项 import android.view.Menu; import android.view.MenuItem; -// 安卓视图体系核心类,处理视图点击事件、视图基础属性配置 import android.view.View; import android.view.View.OnClickListener; -// 安卓按钮控件,作为下拉菜单的触发载体,本类核心绑定控件 import android.widget.Button; -// 安卓原生下拉菜单核心控件,实现弹窗式菜单展示,本类封装的核心原生控件 import android.widget.PopupMenu; -// 安卓下拉菜单的菜单项点击事件监听器,监听菜单选项的点击行为 import android.widget.PopupMenu.OnMenuItemClickListener; -// 小米便签资源文件引用,获取图标、布局等资源ID常量 import net.micode.notes.R; /** - * 下拉菜单功能封装工具类【全局复用型UI工具类】 - * 核心设计模式:封装模式,对Android原生PopupMenu+触发Button进行一站式封装 - * 核心定位:小米便签内所有下拉菜单场景的统一实现方案,抽离通用逻辑,避免重复开发 - * 核心价值: - * 1. 屏蔽原生PopupMenu的复杂创建流程,对外提供极简的调用API,一行代码即可创建下拉菜单; - * 2. 统一应用内所有下拉菜单的视觉样式(触发按钮背景图标、菜单展示位置),保证UI一致性; - * 3. 封装菜单资源加载、点击事件绑定、菜单项查找、按钮文本更新等全套核心逻辑; - * 4. 解耦下拉菜单的「创建-展示-事件处理」逻辑,外部无需关心内部实现细节; - * 5. 轻量化封装,无冗余逻辑,仅做功能整合,不侵入业务代码,适配所有下拉菜单使用场景。 - * 典型业务场景:便签列表页的排序方式选择、文件夹操作菜单、更多功能选项、筛选条件下拉框等。 + * 下拉菜单工具类 + * 封装了PopupMenu的创建和管理,提供简洁的接口来创建和使用下拉菜单 + * 简化了下拉菜单的初始化和事件处理流程 + * + * 架构设计: + * - 包装Button和PopupMenu,形成完整的下拉菜单组件 + * - 提供简洁的构造函数,一次性完成菜单初始化 + * - 支持菜单项点击事件监听 + * - 提供菜单标题设置和菜单项查找功能 + * + * 核心功能: + * - 创建基于Button的下拉菜单 + * - 自动设置下拉箭头图标 + * - 从菜单资源文件加载菜单项 + * - 处理按钮点击显示菜单的逻辑 + * - 支持设置菜单项点击监听器 + * - 提供菜单标题设置功能 + * - 支持通过ID查找菜单项 */ public class DropdownMenu { - // 下拉菜单的触发按钮,全局持用引用:点击该按钮即可弹出下拉菜单,统一配置背景样式 + /** + * 下拉菜单按钮 + */ private Button mButton; - // Android原生下拉菜单核心控件,本类封装的核心对象:负责菜单的弹出、收起、承载菜单项 + /** + * 弹出菜单实例 + */ private PopupMenu mPopupMenu; - // PopupMenu对应的菜单容器对象:用于动态查找、修改、管理菜单项的状态(显示/隐藏/禁用等) + /** + * 菜单实例 + */ private Menu mMenu; /** - * 构造方法:初始化下拉菜单的全部核心配置【一站式初始化】 - * 核心能力:传入必要参数后,自动完成「按钮样式配置+下拉菜单创建+菜单资源加载+点击触发绑定」 - * 无需外部执行任何额外初始化操作,极简调用,开箱即用 - * @param context 上下文对象,用于创建PopupMenu、加载菜单资源、获取应用运行环境,不可为空 - * @param button 触发下拉菜单的按钮控件,菜单会锚定该按钮在下方弹出,与菜单强绑定 - * @param menuId 菜单布局的资源ID(如R.menu.menu_sort),定义下拉菜单的所有选项列表 + * 构造函数 + * @param context 上下文 + * @param button 下拉菜单按钮 + * @param menuId 菜单资源ID */ public DropdownMenu(Context context, Button button, int menuId) { - // 保存触发按钮的全局引用,供后续设置文本、复用控件使用 mButton = button; - // 统一设置触发按钮的背景样式:加载内置的下拉箭头图标,保证所有下拉按钮视觉统一 mButton.setBackgroundResource(R.drawable.dropdown_icon); - // 创建原生PopupMenu对象,绑定上下文和触发按钮,指定菜单弹出的锚点位置 mPopupMenu = new PopupMenu(context, mButton); - // 获取PopupMenu内部的Menu容器对象,缓存引用,供后续菜单项查找使用,避免重复获取 mMenu = mPopupMenu.getMenu(); - // 通过菜单解析器,将指定的菜单布局资源加载到Menu容器中,完成菜单项的初始化展示 mPopupMenu.getMenuInflater().inflate(menuId, mMenu); - // 为触发按钮绑定点击事件监听器:点击按钮时,自动弹出下拉菜单,核心触发逻辑 mButton.setOnClickListener(new OnClickListener() { public void onClick(View v) { - // 弹出下拉菜单,菜单默认展示在触发按钮的下方,对齐方式由系统原生适配 mPopupMenu.show(); } }); } /** - * 对外提供的事件绑定方法:设置下拉菜单项的点击事件监听器 - * 核心设计:事件逻辑完全交由外部实现,本类只做事件转发,无任何业务逻辑侵入,高度解耦 - * 外部可根据菜单项的ID,分别处理不同选项的点击业务(如排序、筛选、删除、移动等) - * @param listener 菜单项点击事件监听器,外部实现该接口的onMenuItemClick方法处理具体逻辑 + * 设置菜单项点击监听器 + * @param listener 菜单项点击监听器 */ public void setOnDropdownMenuItemClickListener(OnMenuItemClickListener listener) { - // 空值安全校验:避免PopupMenu未初始化完成时绑定监听器导致空指针异常 if (mPopupMenu != null) { - // 将外部传入的监听器绑定到PopupMenu,所有菜单项的点击事件都会回调该监听器 mPopupMenu.setOnMenuItemClickListener(listener); } } /** - * 工具方法:根据菜单项的资源ID查找对应的MenuItem对象 - * 核心用途:支持外部动态修改菜单项的状态,如「隐藏/显示菜单项」「禁用/启用菜单项」「修改菜单项文本/图标」等 - * 是实现菜单动态化配置的核心入口,满足复杂业务场景的菜单定制需求 - * @param id 目标菜单项的资源ID(如R.id.menu_sort_by_time、R.id.menu_move_folder) - * @return MenuItem 找到的菜单项对象,未找到时返回null + * 根据ID查找菜单项 + * @param id 菜单项ID + * @return 菜单项实例 */ public MenuItem findItem(int id) { - // 直接通过缓存的Menu对象查找菜单项,高效无冗余 return mMenu.findItem(id); } /** - * 工具方法:动态设置下拉菜单触发按钮的显示文本 - * 核心业务价值:支持按钮文本的动态更新,适配「当前选中状态展示」场景 - * 比如:排序菜单按钮显示「按时间排序」、文件夹菜单按钮显示「当前文件夹:工作」等 - * @param title 按钮需要展示的文本内容,支持字符串常量、动态拼接字符串 + * 设置下拉菜单按钮标题 + * @param title 标题文本 */ public void setTitle(CharSequence title) { - // 直接为触发按钮设置文本内容,更新UI展示 mButton.setText(title); } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/FoldersListAdapter.java b/src/Notes-master/src/net/micode/notes/ui/FoldersListAdapter.java index 340650f..736096b 100644 --- a/src/Notes-master/src/net/micode/notes/ui/FoldersListAdapter.java +++ b/src/Notes-master/src/net/micode/notes/ui/FoldersListAdapter.java @@ -14,166 +14,97 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,是文件夹列表展示的核心数据适配器 package net.micode.notes.ui; -// 安卓系统核心类 - 上下文,提供资源加载、视图创建的运行环境 import android.content.Context; -// 安卓数据库游标类,承载文件夹的数据库查询结果集,适配器的核心数据源 import android.database.Cursor; -// 安卓视图体系核心类,用于创建和装载列表子项视图 import android.view.View; import android.view.ViewGroup; -// 安卓游标适配器基类,适配Cursor数据源的列表专用适配器,本类核心父类 import android.widget.CursorAdapter; -// 安卓线性布局,作为自定义列表项的根布局容器 import android.widget.LinearLayout; -// 安卓文本控件,用于展示文件夹名称文本内容 import android.widget.TextView; -// 小米便签资源文件引用,获取布局、字符串等资源id import net.micode.notes.R; -// 小米便签核心数据常量,定义文件夹id、数据库字段名等全局常量 import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.NoteColumns; + /** - * 文件夹列表专用数据适配器【核心功能适配器】 - * 继承:CursorAdapter 安卓原生游标适配器,完美适配数据库查询的Cursor结果集 - * 核心定位:小米便签中「移动便签到文件夹」弹窗的核心适配器,负责文件夹列表的数据绑定与视图渲染 - * 核心职责: - * 1. 定义文件夹查询的精简投影字段,只查必要数据,减少数据库IO开销,提升列表加载效率; - * 2. 实现Cursor数据与列表子项视图的绑定逻辑,规范数据渲染流程; - * 3. 对「根文件夹」做特殊文本适配,将系统根文件夹名称替换为业务友好的文本提示; - * 4. 封装文件夹名称获取工具方法,对外提供统一的名称读取接口,解耦外部调用逻辑; - * 5. 内置自定义列表项布局,封装视图创建与控件绑定,避免外部多次 findViewById 性能损耗。 - * 核心特点:轻量高效、职责单一,仅处理文件夹列表的「数据-视图」映射关系,无业务逻辑侵入。 + * 文件夹列表适配器 + * 用于显示便签文件夹列表,支持文件夹选择功能 + * 作为便签分类管理的重要组件,提供文件夹的可视化展示 + * + * 架构设计: + * - 继承自CursorAdapter,使用游标数据绑定 + * - 自定义FolderListItem作为列表项视图 + * - 支持根文件夹的特殊显示 + * - 提供获取文件夹名称的方法 + * + * 核心功能: + * - 显示文件夹列表 + * - 根文件夹特殊显示为"父文件夹" + * - 绑定文件夹数据到列表项 + * - 提供通过位置获取文件夹名称的方法 */ public class FoldersListAdapter extends CursorAdapter { /** - * 文件夹数据库查询投影数组【核心常量】 - * 设计原则:按需查询,只获取列表展示所需的核心字段,不查冗余字段,降低内存占用和查询耗时 - * 字段组成:仅包含文件夹的唯一标识和展示名称,满足列表所有业务需求 + * 查询投影列,包含文件夹ID和名称 */ public static final String [] PROJECTION = { - NoteColumns.ID, // 数组索引0 - 文件夹的唯一ID,数据库主键 - NoteColumns.SNIPPET // 数组索引1 - 文件夹的名称,用于列表展示 + NoteColumns.ID, + NoteColumns.SNIPPET }; /** - * 投影字段对应的列索引常量 - * 设计目的:封装索引数值,避免代码中出现硬编码数字,提升代码可读性和可维护性 - * 作用:通过常量直接取值,Cursor.getInt(ID_COLUMN) 比 Cursor.getInt(0) 语义更清晰 + * ID列索引 + */ + public static final int ID_COLUMN = 0; + /** + * 名称列索引 */ - public static final int ID_COLUMN = 0; // 文件夹ID对应的游标列索引 - public static final int NAME_COLUMN = 1; // 文件夹名称对应的游标列索引 + public static final int NAME_COLUMN = 1; /** - * 构造方法:初始化文件夹列表适配器 - * 父类传参:将上下文和游标数据源传递给CursorAdapter基类,完成适配器初始化 - * @param context 上下文对象,用于加载资源、创建视图,不可为空 - * @param c 数据库查询返回的游标,封装了所有文件夹的ID和名称数据,游标已完成查询定位 + * 构造函数 + * @param context 上下文 + * @param c 包含文件夹数据的游标 */ public FoldersListAdapter(Context context, Cursor c) { super(context, c); } - /** - * 重写父类方法:创建列表子项的空白视图 - * 生命周期:列表滚动时,为屏幕外新进入的位置创建全新的视图对象,复用性低 - * 核心逻辑:仅创建视图容器,不做任何数据绑定,数据绑定由bindView方法完成 - * @param context 上下文对象,用于创建视图 - * @param cursor 当前位置对应的游标数据,本方法中暂未使用,仅遵循父类接口规范 - * @param parent 列表子项的父容器,即承载所有文件夹项的ListView - * @return View 新建的、未绑定数据的文件夹列表子项视图 - */ @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { - // 创建自定义的文件夹列表项视图,内部已完成布局加载和控件绑定 return new FolderListItem(context); } - /** - * 重写父类核心方法:为列表子项视图绑定对应的数据【核心业务方法】 - * 生命周期:列表首次加载/滚动复用视图时调用,所有数据渲染逻辑均在此实现 - * 核心业务规则: - * 1. 根文件夹(ID=Notes.ID_ROOT_FOLDER):不展示原始名称,替换为业务文本「移动到上级文件夹」; - * 2. 普通文件夹:直接展示数据库中存储的文件夹名称; - * 3. 类型安全校验:只处理自定义的FolderListItem视图,防止视图类型异常导致崩溃。 - * @param view 待绑定数据的列表子项视图,可为newView创建的新视图,也可为复用的旧视图 - * @param context 上下文对象,用于加载字符串资源 - * @param cursor 当前列表位置对应的游标,已定位到对应行,可直接读取字段值 - */ @Override public void bindView(View view, Context context, Cursor cursor) { - // 视图类型校验,保证类型安全,避免视图强转异常 if (view instanceof FolderListItem) { - // 读取当前游标中的文件夹ID,判断是否为根文件夹 - long folderId = cursor.getLong(ID_COLUMN); - String folderName; - - // 根文件夹特殊处理:替换展示文本;普通文件夹使用原始名称 - if (folderId == Notes.ID_ROOT_FOLDER) { - folderName = context.getString(R.string.menu_move_parent_folder); - } else { - folderName = cursor.getString(NAME_COLUMN); - } - - // 调用自定义视图的绑定方法,将处理后的文件夹名称设置到文本控件 + String folderName = (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context + .getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN); ((FolderListItem) view).bind(folderName); } } - /** - * 对外提供的工具方法:根据列表位置获取对应的文件夹名称 - * 封装价值:外部调用方无需操作游标,直接传入位置即可获取名称,隐藏游标操作细节,解耦调用逻辑 - * 业务规则:与bindView保持完全一致,根文件夹返回「移动到上级文件夹」,保证数据一致性 - * @param context 上下文对象,用于加载根文件夹的业务文本 - * @param position 列表中的目标位置索引,从0开始 - * @return String 处理后的文件夹展示名称 - */ public String getFolderName(Context context, int position) { - // 根据位置获取对应的游标对象,游标已自动定位到对应行 Cursor cursor = (Cursor) getItem(position); - long folderId = cursor.getLong(ID_COLUMN); - - // 根文件夹判断与名称适配,逻辑与bindView完全一致 - return folderId == Notes.ID_ROOT_FOLDER ? context.getString(R.string.menu_move_parent_folder) - : cursor.getString(NAME_COLUMN); + return (cursor.getLong(ID_COLUMN) == Notes.ID_ROOT_FOLDER) ? context + .getString(R.string.menu_move_parent_folder) : cursor.getString(NAME_COLUMN); } - /** - * 私有内部类:文件夹列表的自定义子项视图【视图封装核心类】 - * 继承:LinearLayout 线性布局,作为子项的根布局 - * 设计思想:视图控件封装化,将列表项的布局加载、控件绑定、数据绑定全部封装在内部 - * 核心优势:外部无需关心视图内部结构,只需调用bind方法即可完成数据渲染,符合封装原则 - * 访问权限:private 私有,仅当前适配器可创建使用,不对外暴露,保证视图安全性 - */ private class FolderListItem extends LinearLayout { - // 列表项核心控件:展示文件夹名称的文本控件,全局缓存避免重复查找 private TextView mName; - /** - * 构造方法:初始化自定义列表项视图 - * 核心逻辑:加载布局文件 + 绑定内部控件,只执行一次,视图创建时完成初始化 - * @param context 上下文对象,用于加载布局资源和查找控件 - */ public FolderListItem(Context context) { super(context); - // 将文件夹列表项的布局文件,填充到当前的LinearLayout根布局中 inflate(context, R.layout.folder_list_item, this); - // 查找并缓存文件夹名称文本控件,后续直接复用,提升性能 mName = (TextView) findViewById(R.id.tv_folder_name); } - /** - * 视图数据绑定方法:为文本控件设置文件夹名称 - * 核心作用:作为适配器与自定义视图的通信桥梁,只做单一的文本赋值操作 - * @param name 处理后的文件夹展示名称(根文件夹/普通文件夹) - */ public void bind(String name) { mName.setText(name); } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/FriendManagementActivity.java b/src/Notes-master/src/net/micode/notes/ui/FriendManagementActivity.java new file mode 100644 index 0000000..f299a36 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/ui/FriendManagementActivity.java @@ -0,0 +1,212 @@ +package net.micode.notes.ui; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import net.micode.notes.R; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.tool.UserManager; +import net.micode.notes.data.Users; + +import java.util.ArrayList; +import java.util.List; + +/** + * 好友管理活动 + *

+ * 该类负责实现好友列表的显示和管理功能,用户可以通过该界面查看好友列表并启动与好友的聊天。 + * 它通过NotesDatabaseHelper操作用户数据表,获取除当前用户以外的所有用户作为好友列表。 + */ +public class FriendManagementActivity extends Activity { + private ListView mFriendListView; + private FriendAdapter mFriendAdapter; + private List mFriendList; + private NotesDatabaseHelper mDbHelper; + private SQLiteDatabase mDb; + private UserManager mUserManager; + private long mCurrentUserId; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_friend_management); + + // 设置ActionBar + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle("好友管理"); + } + + // 初始化数据库 + mDbHelper = NotesDatabaseHelper.getInstance(this); + mDb = mDbHelper.getWritableDatabase(); + + // 初始化UserManager + mUserManager = UserManager.getInstance(this); + mCurrentUserId = mUserManager.getCurrentUserId(); + + // 初始化ListView + mFriendListView = findViewById(R.id.friend_list); + mFriendList = new ArrayList<>(); + mFriendAdapter = new FriendAdapter(this, mFriendList); + mFriendListView.setAdapter(mFriendAdapter); + + // 设置ListView点击事件 + mFriendListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Friend friend = mFriendList.get(position); + // 启动与好友聊天的活动 + Intent intent = new Intent(FriendManagementActivity.this, ChatActivity.class); + intent.putExtra("friend_id", friend.id); + intent.putExtra("friend_username", friend.username); + startActivity(intent); + } + }); + + // 加载好友列表 + loadFriendList(); + } + + @Override + protected void onResume() { + super.onResume(); + // 重新加载好友列表 + loadFriendList(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 关闭数据库连接 + if (mDb != null && mDb.isOpen()) { + mDb.close(); + } + } + + /** + * 加载好友列表,即除当前用户以外的所有用户 + */ + private void loadFriendList() { + mFriendList.clear(); + + // 查询除当前用户以外的所有用户 + Cursor cursor = mDb.query( + NotesDatabaseHelper.TABLE.USER, + new String[]{Users.UserColumns.ID, Users.UserColumns.USERNAME}, + Users.UserColumns.ID + " != ?", + new String[]{String.valueOf(mCurrentUserId)}, + null, + null, + null + ); + + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(Users.UserColumns.ID)); + String username = cursor.getString(cursor.getColumnIndexOrThrow(Users.UserColumns.USERNAME)); + mFriendList.add(new Friend(id, username)); + } + cursor.close(); + } + + // 通知适配器数据变化 + mFriendAdapter.notifyDataSetChanged(); + + // 如果没有好友,显示提示 + if (mFriendList.isEmpty()) { + Toast.makeText(this, "暂无其他用户", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 好友实体类 + */ + private static class Friend { + long id; + String username; + + Friend(long id, String username) { + this.id = id; + this.username = username; + } + } + + /** + * 好友列表适配器 + */ + private static class FriendAdapter extends BaseAdapter { + private Context mContext; + private List mFriendList; + private LayoutInflater mInflater; + + FriendAdapter(Context context, List friendList) { + mContext = context; + mFriendList = friendList; + mInflater = LayoutInflater.from(context); + } + + @Override + public int getCount() { + return mFriendList.size(); + } + + @Override + public Object getItem(int position) { + return mFriendList.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + if (convertView == null) { + convertView = mInflater.inflate(R.layout.item_friend, parent, false); + holder = new ViewHolder(); + holder.usernameTextView = convertView.findViewById(R.id.friend_username); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + Friend friend = mFriendList.get(position); + holder.usernameTextView.setText(friend.username); + + return convertView; + } + + private static class ViewHolder { + TextView usernameTextView; + } + } + + @Override + public boolean onOptionsItemSelected(android.view.MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // 返回上一级活动 + finish(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/ui/FriendNoteEditActivity.java b/src/Notes-master/src/net/micode/notes/ui/FriendNoteEditActivity.java new file mode 100644 index 0000000..48916ad --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/ui/FriendNoteEditActivity.java @@ -0,0 +1,264 @@ +package net.micode.notes.ui; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.View; +import android.widget.TextView; +import android.widget.Toast; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; + + +/** + * 好友便签查看活动类 + * 用于查看好友的公开便签详情,以只读模式显示便签内容 + * 作为FriendNoteListActivity的配套组件,提供便签内容的详细展示 + * + * 架构设计: + * - 继承自Activity,使用简单的TextView布局 + * - 以只读模式显示便签标题和内容 + * - 显示便签的修改时间 + * - 支持从Intent中获取便签ID和好友ID + * - 包含状态保存和恢复机制 + * + * 核心功能: + * - 查看好友公开便签的详细内容 + * - 显示便签标题和修改时间 + * - 支持返回上一级活动 + * - 处理便签数据加载失败的情况 + * - 适配不同的便签状态(有标题/无标题,有内容/无内容) + */ +public class FriendNoteEditActivity extends Activity { + /** + * 便签内容编辑器 + */ + private TextView mNoteEditor; + /** + * 便签标题视图 + */ + private TextView mNoteTitleView; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_friend_note_edit); + + // 设置ActionBar + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle("查看便签"); + } + + // 初始化控件 + mNoteEditor = findViewById(R.id.note_edit_view); + mNoteTitleView = findViewById(R.id.note_title_view); + + // 加载便签数据 + if (savedInstanceState == null && !initActivityState(getIntent())) { + finish(); + return; + } + initNoteScreen(); + } + + @Override + protected void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState != null && savedInstanceState.containsKey(Intent.EXTRA_UID)) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, savedInstanceState.getLong(Intent.EXTRA_UID)); + intent.putExtra("friend_id", savedInstanceState.getLong("friend_id")); + if (!initActivityState(intent)) { + finish(); + return; + } + // 恢复状态后重新初始化界面 + initNoteScreen(); + } + } + + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (getIntent() != null) { + outState.putLong(Intent.EXTRA_UID, getIntent().getLongExtra(Intent.EXTRA_UID, 0)); + outState.putLong("friend_id", getIntent().getLongExtra("friend_id", -1)); + } + } + + private String mNoteContent; + private String mNoteTitle; + private long mModifiedDate; + + private boolean initActivityState(Intent intent) { + /** + * 只支持查看模式 + */ + long noteId = intent.getLongExtra(Intent.EXTRA_UID, 0); + long friendId = intent.getLongExtra("friend_id", -1); + + if (noteId <= 0 || friendId <= 0) { + Toast.makeText(this, "便签信息错误", Toast.LENGTH_SHORT).show(); + finish(); + return false; + } + + try { + // 查询便签基本信息,包括标题 + String[] noteProjection = { + net.micode.notes.data.Notes.NoteColumns.MODIFIED_DATE, + net.micode.notes.data.Notes.NoteColumns.TITLE + }; + + android.database.Cursor noteCursor = null; + try { + // 使用ContentResolver查询便签基本信息 + noteCursor = getContentResolver().query( + android.content.ContentUris.withAppendedId(net.micode.notes.data.Notes.CONTENT_NOTE_URI, noteId), + noteProjection, + null, + null, + null + ); + + if (noteCursor != null) { + if (noteCursor.moveToFirst()) { + mModifiedDate = noteCursor.getLong(0); + mNoteTitle = noteCursor.getString(1); + } + noteCursor.close(); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "查询便签信息失败", Toast.LENGTH_SHORT).show(); + } + + // 查询便签具体内容,这是必须的,因为完整内容存储在Data表中 + String[] dataProjection = {net.micode.notes.data.Notes.DataColumns.CONTENT}; + String dataSelection = net.micode.notes.data.Notes.DataColumns.NOTE_ID + " = ? AND " + + net.micode.notes.data.Notes.DataColumns.MIME_TYPE + " = ?"; + String[] dataSelectionArgs = {String.valueOf(noteId), net.micode.notes.data.Notes.DataConstants.NOTE}; + + android.database.Cursor dataCursor = null; + try { + // 使用ContentResolver查询数据 + dataCursor = getContentResolver().query( + net.micode.notes.data.Notes.CONTENT_DATA_URI, + dataProjection, + dataSelection, + dataSelectionArgs, + null + ); + + if (dataCursor != null) { + if (dataCursor.moveToFirst()) { + mNoteContent = dataCursor.getString(0); + } + dataCursor.close(); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "查询便签内容失败", Toast.LENGTH_SHORT).show(); + } finally { + if (dataCursor != null) { + dataCursor.close(); + } + } + + // 如果查询失败,尝试使用数据库直接查询 + if (mNoteContent == null || mNoteContent.isEmpty()) { + try { + // 直接使用数据库查询,绕过ContentProvider的用户过滤 + net.micode.notes.data.NotesDatabaseHelper helper = net.micode.notes.data.NotesDatabaseHelper.getInstance(FriendNoteEditActivity.this); + if (helper != null) { + dataCursor = helper.getReadableDatabase().query( + net.micode.notes.data.NotesDatabaseHelper.TABLE.DATA, + dataProjection, + dataSelection, + dataSelectionArgs, + null, + null, + null + ); + + if (dataCursor != null) { + if (dataCursor.moveToFirst()) { + mNoteContent = dataCursor.getString(0); + } + dataCursor.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "直接查询便签内容失败", Toast.LENGTH_SHORT).show(); + } finally { + if (dataCursor != null) { + dataCursor.close(); + } + } + } + + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "初始化便签失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + finish(); + return false; + } + return true; + } + + private void initNoteScreen() { + // 显示修改日期 + TextView modifiedDateTextView = findViewById(R.id.tv_modified_date); + modifiedDateTextView.setText(android.text.format.DateUtils.formatDateTime( + this, mModifiedDate, + android.text.format.DateUtils.FORMAT_SHOW_DATE | + android.text.format.DateUtils.FORMAT_NUMERIC_DATE | + android.text.format.DateUtils.FORMAT_SHOW_TIME)); + + // 显示便签标题 + if (mNoteTitle != null && !mNoteTitle.isEmpty()) { + mNoteTitleView.setVisibility(View.VISIBLE); + mNoteTitleView.setText(mNoteTitle); + } else { + // 如果没有标题,隐藏标题文本视图 + mNoteTitleView.setVisibility(View.GONE); + } + + // 显示便签内容,添加调试日志 + if (mNoteContent != null && !mNoteContent.isEmpty()) { + mNoteEditor.setText(mNoteContent); + // 设置文本颜色为深色,确保可见 + mNoteEditor.setTextColor(getResources().getColor(android.R.color.primary_text_dark)); + // 设置背景色为白色,确保可见 + mNoteEditor.setBackgroundColor(getResources().getColor(android.R.color.white)); + } else { + // 如果内容为空,显示提示文本 + mNoteEditor.setText("(空便签)"); + mNoteEditor.setTextColor(getResources().getColor(android.R.color.secondary_text_dark)); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // 不显示菜单,因为是只读模式 + return false; + } + + @Override + public boolean onOptionsItemSelected(android.view.MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // 返回上一级活动 + finish(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/ui/FriendNoteListActivity.java b/src/Notes-master/src/net/micode/notes/ui/FriendNoteListActivity.java new file mode 100644 index 0000000..623eda6 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/ui/FriendNoteListActivity.java @@ -0,0 +1,305 @@ +package net.micode.notes.ui; + +import android.app.ActionBar; +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import net.micode.notes.R; +import net.micode.notes.data.Notes; +import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.model.WorkingNote; + +import java.util.ArrayList; +import java.util.List; + +/** + * 好友便签列表活动类 + * 显示指定好友的所有公开便签,允许用户查看好友的便签内容 + * 作为好友功能的重要组成部分,实现了便签的社交分享功能 + * + * 架构设计: + * - 继承自Activity,使用ListView显示便签列表 + * - 自定义NoteAdapter适配器处理数据绑定 + * - 通过ContentResolver查询好友的公开便签 + * - 支持点击查看便签详情 + * - 包含错误处理和空状态提示 + * + * 核心功能: + * - 显示好友的公开便签列表 + * - 按置顶状态和修改时间排序 + * - 点击查看便签详情 + * - 支持返回上一级活动 + * - 显示便签的置顶、锁定和公开状态 + */ +public class FriendNoteListActivity extends Activity { + /** + * 便签列表视图 + */ + private ListView mNoteListView; + /** + * 便签适配器 + */ + private NoteAdapter mNoteAdapter; + /** + * 便签数据列表 + */ + private List mNoteList; + /** + * 好友ID + */ + private long mFriendId; + /** + * 好友用户名 + */ + private String mFriendUsername; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_friend_note_list); + + // 获取好友信息 + mFriendId = getIntent().getLongExtra("friend_id", -1); + mFriendUsername = getIntent().getStringExtra("friend_username"); + + if (mFriendId == -1 || mFriendUsername == null) { + Toast.makeText(this, "好友信息错误", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + // 设置ActionBar + ActionBar actionBar = getActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(mFriendUsername + "的公开便签"); + } + + // 初始化ListView + mNoteListView = findViewById(R.id.friend_note_list); + mNoteList = new ArrayList<>(); + mNoteAdapter = new NoteAdapter(this, mNoteList); + mNoteListView.setAdapter(mNoteAdapter); + + // 设置ListView点击事件 + mNoteListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + Note note = mNoteList.get(position); + // 启动查看便签详情的活动 + Intent intent = new Intent(FriendNoteListActivity.this, FriendNoteEditActivity.class); + intent.putExtra(Intent.EXTRA_UID, note.id); + intent.putExtra("friend_id", mFriendId); + startActivity(intent); + } + }); + + // 加载好友的公开便签 + loadFriendNotes(); + } + + @Override + protected void onResume() { + super.onResume(); + // 重新加载好友便签 + loadFriendNotes(); + } + + /** + * 加载好友的公开便签 + */ + private void loadFriendNotes() { + try { + mNoteList.clear(); + + // 查询好友的所有公开便签 + String[] projection = { + NoteColumns.ID, + NoteColumns.TITLE, + NoteColumns.SNIPPET, + NoteColumns.MODIFIED_DATE, + NoteColumns.TYPE, + NoteColumns.PINNED, + NoteColumns.LOCKED, + NoteColumns.PUBLIC + }; + + String selection = NoteColumns.USER_ID + " = ? AND " + NoteColumns.PUBLIC + " = 1 AND " + NoteColumns.PARENT_ID + " <> " + Notes.ID_TRASH_FOLER; + String[] selectionArgs = {String.valueOf(mFriendId)}; + + Cursor cursor = null; + try { + cursor = getContentResolver().query( + Notes.CONTENT_NOTE_URI, + projection, + selection, + selectionArgs, + NoteColumns.PINNED + " DESC, " + NoteColumns.MODIFIED_DATE + " DESC" + ); + + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.ID)); + String title = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.TITLE)); + String content = cursor.getString(cursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + long modifiedDate = cursor.getLong(cursor.getColumnIndexOrThrow(NoteColumns.MODIFIED_DATE)); + int type = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.TYPE)); + int pinned = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.PINNED)); + int locked = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.LOCKED)); + int isPublic = cursor.getInt(cursor.getColumnIndexOrThrow(NoteColumns.PUBLIC)); + + mNoteList.add(new Note(id, title, content, modifiedDate, type, pinned, locked, isPublic)); + } + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "查询好友便签失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + + // 通知适配器数据变化 + mNoteAdapter.notifyDataSetChanged(); + + // 如果没有公开便签,显示提示 + if (mNoteList.isEmpty()) { + Toast.makeText(this, mFriendUsername + "没有公开的便签", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "加载好友便签失败", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 便签实体类 + */ + private static class Note { + long id; + String title; + String content; + long modifiedDate; + int type; + int pinned; + int locked; + int isPublic; + + Note(long id, String title, String content, long modifiedDate, int type, int pinned, int locked, int isPublic) { + this.id = id; + this.title = title; + this.content = content; + this.modifiedDate = modifiedDate; + this.type = type; + this.pinned = pinned; + this.locked = locked; + this.isPublic = isPublic; + } + } + + /** + * 便签列表适配器 + */ + private static class NoteAdapter extends BaseAdapter { + private Activity mActivity; + private List mNoteList; + private LayoutInflater mInflater; + + NoteAdapter(Activity activity, List noteList) { + mActivity = activity; + mNoteList = noteList; + mInflater = LayoutInflater.from(activity); + } + + @Override + public int getCount() { + return mNoteList.size(); + } + + @Override + public Object getItem(int position) { + return mNoteList.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + ViewHolder holder; + if (convertView == null) { + convertView = mInflater.inflate(R.layout.note_item, parent, false); + holder = new ViewHolder(); + holder.titleTextView = convertView.findViewById(R.id.tv_title); + holder.modifiedDateTextView = convertView.findViewById(R.id.tv_time); + holder.pinnedImageView = convertView.findViewById(R.id.iv_alert_icon); + holder.lockedImageView = convertView.findViewById(R.id.iv_lock_icon); + holder.publicImageView = convertView.findViewById(R.id.iv_public_icon); + convertView.setTag(holder); + } else { + holder = (ViewHolder) convertView.getTag(); + } + + Note note = mNoteList.get(position); + // 显示标题,如果标题为空则显示内容摘要 + String displayText; + if (note.title != null && !note.title.isEmpty()) { + displayText = note.title; + } else { + displayText = note.content; + } + holder.titleTextView.setText(displayText); + holder.modifiedDateTextView.setText(android.text.format.DateUtils.formatDateTime( + mActivity, note.modifiedDate, + android.text.format.DateUtils.FORMAT_SHOW_DATE | + android.text.format.DateUtils.FORMAT_NUMERIC_DATE | + android.text.format.DateUtils.FORMAT_SHOW_TIME)); + + // 设置置顶图标 + holder.pinnedImageView.setVisibility(note.pinned == 1 ? View.VISIBLE : View.GONE); + + // 设置锁定图标 + holder.lockedImageView.setVisibility(note.locked == 1 ? View.VISIBLE : View.GONE); + + // 设置公开图标 + holder.publicImageView.setVisibility(note.isPublic == 1 ? View.VISIBLE : View.GONE); + + return convertView; + } + + private static class ViewHolder { + TextView titleTextView; + TextView modifiedDateTextView; + ImageView pinnedImageView; + ImageView lockedImageView; + ImageView publicImageView; + } + } + + @Override + public boolean onOptionsItemSelected(android.view.MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // 返回上一级活动 + finish(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/ui/LoginActivity.java b/src/Notes-master/src/net/micode/notes/ui/LoginActivity.java new file mode 100644 index 0000000..dae80f6 --- /dev/null +++ b/src/Notes-master/src/net/micode/notes/ui/LoginActivity.java @@ -0,0 +1,327 @@ +/* + * Copyright (c) 2010-2011, The MiCode Open Source Community (www.micode.net) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.micode.notes.ui; + +import android.content.ContentValues; +import android.content.Intent; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import net.micode.notes.R; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.data.Users; +import net.micode.notes.tool.UserManager; + +/** + * 登录活动 + *

+ * 该类负责用户登录、注册和密码修改功能,是用户认证的入口界面。 + * 它通过NotesDatabaseHelper操作用户数据表,实现用户的身份验证和管理。 + */ +public class LoginActivity extends AppCompatActivity { + + private EditText etUsername; + private EditText etPassword; + + private NotesDatabaseHelper mDbHelper; + private SQLiteDatabase mDb; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_login); + + // 初始化数据库 + mDbHelper = NotesDatabaseHelper.getInstance(this); + mDb = mDbHelper.getWritableDatabase(); + + // 初始化控件 + etUsername = findViewById(R.id.et_username); + etPassword = findViewById(R.id.et_password); + } + + /** + * 登录按钮点击事件 + */ + public void onLoginClick(View view) { + String username = etUsername.getText().toString().trim(); + String password = etPassword.getText().toString().trim(); + + // 验证输入 + if (username.isEmpty() || password.isEmpty()) { + Toast.makeText(this, "用户名和密码不能为空", Toast.LENGTH_SHORT).show(); + return; + } + + // 验证用户名和密码 + long userId = validateUser(username, password); + if (userId != -1) { + // 登录成功,保存用户信息 + UserManager userManager = UserManager.getInstance(this); + userManager.saveCurrentUser(userId, username); + + // 跳转到便签列表 + Intent intent = new Intent(this, NotesListActivity.class); + startActivity(intent); + finish(); + } else { + // 登录失败,提示错误 + Toast.makeText(this, "用户名或密码错误", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 注册按钮点击事件 + */ + public void onRegisterClick(View view) { + showRegisterDialog(); + } + + /** + * 修改密码按钮点击事件 + */ + public void onChangePasswordClick(View view) { + showChangePasswordDialog(); + } + + /** + * 显示注册对话框 + */ + private void showRegisterDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_register, null); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setView(dialogView); + + final AlertDialog dialog = builder.create(); + + // 初始化注册对话框控件 + final EditText etRegisterUsername = dialogView.findViewById(R.id.et_register_username); + final EditText etRegisterPassword = dialogView.findViewById(R.id.et_register_password); + final EditText etRegisterConfirmPassword = dialogView.findViewById(R.id.et_register_confirm_password); + Button btnRegisterCancel = dialogView.findViewById(R.id.btn_register_cancel); + Button btnRegisterConfirm = dialogView.findViewById(R.id.btn_register_confirm); + + // 取消按钮点击事件 + btnRegisterCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + } + }); + + // 注册按钮点击事件 + btnRegisterConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String username = etRegisterUsername.getText().toString().trim(); + String password = etRegisterPassword.getText().toString().trim(); + String confirmPassword = etRegisterConfirmPassword.getText().toString().trim(); + + // 验证输入 + if (username.isEmpty() || password.isEmpty() || confirmPassword.isEmpty()) { + Toast.makeText(LoginActivity.this, "请填写完整信息", Toast.LENGTH_SHORT).show(); + return; + } + + if (!password.equals(confirmPassword)) { + Toast.makeText(LoginActivity.this, "两次输入的密码不一致", Toast.LENGTH_SHORT).show(); + return; + } + + // 检查用户名是否已存在 + if (isUsernameExists(username)) { + Toast.makeText(LoginActivity.this, "用户名已存在", Toast.LENGTH_SHORT).show(); + return; + } + + // 注册用户 + if (registerUser(username, password)) { + Toast.makeText(LoginActivity.this, "注册成功", Toast.LENGTH_SHORT).show(); + dialog.dismiss(); + } else { + Toast.makeText(LoginActivity.this, "注册失败,请重试", Toast.LENGTH_SHORT).show(); + } + } + }); + + dialog.show(); + } + + /** + * 显示修改密码对话框 + */ + private void showChangePasswordDialog() { + View dialogView = getLayoutInflater().inflate(R.layout.dialog_change_password, null); + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setView(dialogView); + + final AlertDialog dialog = builder.create(); + + // 初始化修改密码对话框控件 + final EditText etChangeUsername = dialogView.findViewById(R.id.et_change_username); + final EditText etCurrentPassword = dialogView.findViewById(R.id.et_current_password); + final EditText etNewPassword = dialogView.findViewById(R.id.et_new_password); + final EditText etConfirmNewPassword = dialogView.findViewById(R.id.et_confirm_new_password); + Button btnChangeCancel = dialogView.findViewById(R.id.btn_change_cancel); + Button btnChangeConfirm = dialogView.findViewById(R.id.btn_change_confirm); + + // 取消按钮点击事件 + btnChangeCancel.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + dialog.dismiss(); + } + }); + + // 确认修改按钮点击事件 + btnChangeConfirm.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + String username = etChangeUsername.getText().toString().trim(); + String currentPassword = etCurrentPassword.getText().toString().trim(); + String newPassword = etNewPassword.getText().toString().trim(); + String confirmNewPassword = etConfirmNewPassword.getText().toString().trim(); + + // 验证输入 + if (username.isEmpty() || currentPassword.isEmpty() || newPassword.isEmpty() || confirmNewPassword.isEmpty()) { + Toast.makeText(LoginActivity.this, "请填写完整信息", Toast.LENGTH_SHORT).show(); + return; + } + + if (!newPassword.equals(confirmNewPassword)) { + Toast.makeText(LoginActivity.this, "两次输入的新密码不一致", Toast.LENGTH_SHORT).show(); + return; + } + + // 验证当前密码 + if (validateUser(username, currentPassword) == -1) { + Toast.makeText(LoginActivity.this, "用户名或当前密码错误", Toast.LENGTH_SHORT).show(); + return; + } + + // 修改密码 + if (changePassword(username, newPassword)) { + Toast.makeText(LoginActivity.this, "密码修改成功", Toast.LENGTH_SHORT).show(); + dialog.dismiss(); + } else { + Toast.makeText(LoginActivity.this, "密码修改失败,请重试", Toast.LENGTH_SHORT).show(); + } + } + }); + + dialog.show(); + } + + /** + * 验证用户名和密码,并返回用户ID + */ + private long validateUser(String username, String password) { + String[] projection = {Users.UserColumns.ID}; + String selection = Users.UserColumns.USERNAME + " = ? AND " + Users.UserColumns.PASSWORD + " = ?"; + String[] selectionArgs = {username, password}; + + Cursor cursor = mDb.query( + NotesDatabaseHelper.TABLE.USER, + projection, + selection, + selectionArgs, + null, + null, + null + ); + + long userId = -1; + if (cursor.moveToFirst()) { + userId = cursor.getLong(cursor.getColumnIndexOrThrow(Users.UserColumns.ID)); + } + cursor.close(); + return userId; + } + + /** + * 检查用户名是否已存在 + */ + private boolean isUsernameExists(String username) { + String[] projection = {Users.UserColumns.ID}; + String selection = Users.UserColumns.USERNAME + " = ?"; + String[] selectionArgs = {username}; + + Cursor cursor = mDb.query( + NotesDatabaseHelper.TABLE.USER, + projection, + selection, + selectionArgs, + null, + null, + null + ); + + boolean exists = cursor.getCount() > 0; + cursor.close(); + return exists; + } + + /** + * 注册用户 + */ + private boolean registerUser(String username, String password) { + ContentValues values = new ContentValues(); + values.put(Users.UserColumns.USERNAME, username); + values.put(Users.UserColumns.PASSWORD, password); + + long result = mDb.insert(NotesDatabaseHelper.TABLE.USER, null, values); + return result != -1; + } + + /** + * 修改密码 + */ + private boolean changePassword(String username, String newPassword) { + ContentValues values = new ContentValues(); + values.put(Users.UserColumns.PASSWORD, newPassword); + values.put(Users.UserColumns.MODIFIED_DATE, System.currentTimeMillis()); + + String selection = Users.UserColumns.USERNAME + " = ?"; + String[] selectionArgs = {username}; + + int result = mDb.update( + NotesDatabaseHelper.TABLE.USER, + values, + selection, + selectionArgs + ); + + return result > 0; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 关闭数据库连接 + if (mDb != null && mDb.isOpen()) { + mDb.close(); + } + } +} \ No newline at end of file diff --git a/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java b/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java index 355f794..5fbda6e 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/NoteEditActivity.java @@ -14,148 +14,90 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,核心编辑页面,承载所有便签的新建/编辑/查看核心逻辑 package net.micode.notes.ui; -// Android系统服务-闹钟:实现便签提醒功能的核心系统服务 import android.app.Activity; import android.app.AlarmManager; -// Android弹窗:实现删除确认、时间选择等核心弹窗交互 import android.app.AlertDialog; -// Android延迟意图:绑定闹钟事件,触发提醒广播 import android.app.PendingIntent; -// Android搜索服务:接收搜索跳转参数,实现搜索关键词高亮 import android.app.SearchManager; -// Android小组件管理:实现便签与桌面小组件的绑定和更新 import android.appwidget.AppWidgetManager; -// Android内容URI工具:拼接便签ID生成唯一URI,用于闹钟广播标识 import android.content.ContentUris; -// Android上下文:页面运行环境核心类 import android.content.Context; -// Android对话框监听:处理弹窗的确认/取消点击事件 import android.content.DialogInterface; import android.content.Intent; -// Android轻量级存储:持久化保存字体大小等用户偏好设置 import android.content.SharedPreferences; -// Android图形画笔:实现清单模式勾选后的删除线文本样式 import android.graphics.Paint; -// Android页面状态存储:屏幕旋转/内存不足重建时保存便签ID import android.os.Bundle; -// Android偏好设置工具:获取全局的SharedPreferences实例 import android.preference.PreferenceManager; -// Android富文本核心类:实现搜索关键词的背景高亮效果 +import android.graphics.Bitmap; +import android.net.Uri; import android.text.Spannable; import android.text.SpannableString; -// Android文本工具类:判空、文本截取等安全高效操作 +import android.text.SpannableStringBuilder; import android.text.TextUtils; -// Android时间格式化工具:格式化便签修改时间、提醒相对时间 import android.text.format.DateUtils; -// Android富文本样式:为搜索关键词添加背景色高亮 import android.text.style.BackgroundColorSpan; -// Android日志工具:输出调试日志,便于问题排查 +import android.text.style.ImageSpan; import android.util.Log; -// Android布局加载:加载菜单布局、清单模式子项布局 import android.view.LayoutInflater; -// Android菜单核心类:创建页面右上角的功能菜单 import android.view.Menu; import android.view.MenuItem; -// Android触摸事件:处理点击外部关闭弹窗的核心事件分发 import android.view.MotionEvent; import android.view.View; import android.view.View.OnClickListener; -// Android窗口管理:控制软键盘的显示/隐藏模式 import android.view.WindowManager; -// Android复选框:清单模式的核心选择控件 import android.widget.CheckBox; +import android.text.style.URLSpan; +import android.widget.Button; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; -// Android编辑框:普通文本模式的核心输入控件 import android.widget.EditText; -// Android图片控件:背景色选择、选中态展示、功能图标 import android.widget.ImageView; -// Android线性布局:清单模式的列表容器,承载多个编辑项 import android.widget.LinearLayout; -// Android文本控件:展示修改时间、提醒信息等静态文本 import android.widget.TextView; -// Android轻提示:操作成功/失败的吐司提示 import android.widget.Toast; -// 小米便签资源类:引用字符串、颜色、布局、图片等项目资源 import net.micode.notes.R; -// 小米便签数据常量:定义便签类型、文件夹ID、Intent参数等核心常量 import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.TextNote; -// 小米便签核心数据模型:封装便签的所有数据、状态和业务操作,解耦UI与数据层 import net.micode.notes.model.WorkingNote; import net.micode.notes.model.WorkingNote.NoteSettingChangedListener; -// 小米便签数据工具类:数据库操作、数据校验、通话记录匹配等通用工具 import net.micode.notes.tool.DataUtils; -// 小米便签样式解析工具:解析背景色、字体大小等样式资源,统一管理样式规范 import net.micode.notes.tool.ResourceParser; import net.micode.notes.tool.ResourceParser.TextAppearanceResources; -// 小米便签自定义时间选择器:年月日时分一体化选择弹窗 import net.micode.notes.ui.DateTimePickerDialog.OnDateTimeSetListener; -// 小米便签自定义编辑框回调:处理清单模式的增删、回车事件 import net.micode.notes.ui.NoteEditText.OnTextViewChangeListener; import net.micode.notes.widget.NoteWidgetProvider_2x; import net.micode.notes.widget.NoteWidgetProvider_4x; - -// Java集合框架:HashMap实现键值对映射,解耦控件ID与业务标识;HashSet存储批量操作ID +import net.micode.notes.ui.FoldersListAdapter; +import android.view.ViewGroup; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Map; -// Java正则表达式:实现搜索关键词的文本匹配,支撑高亮逻辑 import java.util.regex.Matcher; import java.util.regex.Pattern; -// Android异步查询:实现文件夹列表的异步加载 -import android.content.AsyncQueryHandler; -// Android数据库游标:存储文件夹列表查询结果 -import android.database.Cursor; -// Android列表适配器:展示文件夹列表 -import android.widget.ListAdapter; /** - * 小米便签 核心编辑页面【整个应用的核心页面】 - * 页面定位:Activity基类,承载便签的「新建、编辑、查看、删除」全生命周期操作 - * 核心业务能力(十大核心职责): - * 1. 多场景页面初始化:区分「查看已有便签、新建普通便签、新建通话记录便签、搜索跳转」4种启动场景; - * 2. 双编辑模式无缝切换:支持「普通文本模式」与「清单勾选模式」互相切换,数据无损转换; - * 3. 样式个性化定制:支持5种背景色切换、4种字体大小选择,样式偏好持久化存储; - * 4. 提醒功能完整实现:设置/取消便签提醒、注册系统闹钟、过期提醒展示、相对时间显示; - * 5. 丰富的功能菜单:新建、删除、分享、创建桌面快捷方式、字体设置、模式切换、提醒管理; - * 6. 搜索关键词高亮:从搜索列表跳转时,自动高亮匹配的搜索关键词,提升浏览体验; - * 7. 通话记录深度适配:自动匹配通话记录生成便签,避免重复创建,一键记录通话信息; - * 8. 桌面小组件联动:编辑便签后自动同步更新绑定的桌面小组件内容; - * 9. 防数据丢失机制:页面暂停/销毁/返回时自动保存便签,屏幕旋转时恢复编辑状态; - * 10. 完善的异常处理:所有关键操作均做数据校验,异常场景友好提示,杜绝崩溃闪退。 - * 核心设计模式: - * - 数据与UI解耦:通过WorkingNote数据模型封装所有数据操作,页面仅做UI展示和事件分发; - * - 回调解耦业务逻辑:通过多个回调接口处理子控件事件、数据状态变化,符合单一职责原则; - * - 常量映射解耦:通过HashMap映射控件ID与业务标识,避免硬编码,提升代码可维护性。 + * 便签编辑活动 + *

+ * 该类是便签编辑的核心界面,负责处理便签的创建、编辑、保存等操作。 + * 它实现了多个接口,用于处理点击事件、便签设置变更和文本视图变更。 */ public class NoteEditActivity extends Activity implements OnClickListener, NoteSettingChangedListener, OnTextViewChangeListener { - /** - * 标题栏控件持有者 内部类【控件缓存设计】 - * 设计目的:将标题栏的所有核心控件统一缓存到该类,避免多次调用findViewById造成性能损耗 - * 核心价值:控件只查找一次,复用全局,提升页面初始化和刷新的效率 - */ private class HeadViewHolder { - public TextView tvModified; // 展示便签的「最后修改时间」文本控件 - public ImageView ivAlertIcon; // 提醒功能的标识图标,有提醒时显示,无则隐藏 - public TextView tvAlertDate; // 展示提醒「剩余时间/已过期」文本,联动提醒图标 - public ImageView ibSetBgColor; // 背景色设置的功能按钮,点击弹出背景色选择面板 - } + public TextView tvModified; - // ===================== 静态常量映射表区 - 核心解耦设计【重中之重】===================== - // 映射原则:所有映射表均为静态不可变,静态代码块初始化,全局唯一,避免内存浪费 - // 核心价值:彻底解耦「控件ID」与「业务常量标识」,修改控件ID或业务标识时互不影响,提升可维护性 + public ImageView ivAlertIcon; + + public TextView tvAlertDate; + + public ImageView ibSetBgColor; + } - /** - * 背景色选择按钮ID → ResourceParser背景色常量 映射表 - * 作用:点击不同背景色按钮时,快速匹配对应的背景色业务标识,无需多个if判断 - */ private static final Map sBgSelectorBtnsMap = new HashMap(); static { sBgSelectorBtnsMap.put(R.id.iv_bg_yellow, ResourceParser.YELLOW); @@ -165,10 +107,6 @@ public class NoteEditActivity extends Activity implements OnClickListener, sBgSelectorBtnsMap.put(R.id.iv_bg_white, ResourceParser.WHITE); } - /** - * ResourceParser背景色常量 → 背景色选中态控件ID 映射表 - * 作用:选中某背景色后,快速显示对应的选中标识,未选中则隐藏,控制选择器的视觉反馈 - */ private static final Map sBgSelectorSelectionMap = new HashMap(); static { sBgSelectorSelectionMap.put(ResourceParser.YELLOW, R.id.iv_bg_yellow_select); @@ -178,10 +116,6 @@ public class NoteEditActivity extends Activity implements OnClickListener, sBgSelectorSelectionMap.put(ResourceParser.WHITE, R.id.iv_bg_white_select); } - /** - * 字体大小选择按钮ID → ResourceParser字体常量 映射表 - * 作用:点击不同字体按钮时,快速匹配对应的字体大小业务标识,统一管理字体样式 - */ private static final Map sFontSizeBtnsMap = new HashMap(); static { sFontSizeBtnsMap.put(R.id.ll_font_large, ResourceParser.TEXT_LARGE); @@ -190,10 +124,6 @@ public class NoteEditActivity extends Activity implements OnClickListener, sFontSizeBtnsMap.put(R.id.ll_font_super, ResourceParser.TEXT_SUPER); } - /** - * ResourceParser字体常量 → 字体选中态控件ID 映射表 - * 作用:选中某字体后,快速显示对应的选中标识,控制字体选择器的视觉反馈 - */ private static final Map sFontSelectorSelectionMap = new HashMap(); static { sFontSelectorSelectionMap.put(ResourceParser.TEXT_LARGE, R.id.iv_large_select); @@ -202,57 +132,69 @@ public class NoteEditActivity extends Activity implements OnClickListener, sFontSelectorSelectionMap.put(ResourceParser.TEXT_SUPER, R.id.iv_super_select); } - // ===================== 全局常量区 - 日志/存储/业务规则 ===================== - private static final String TAG = "NoteEditActivity"; // 日志过滤TAG,固定值 - private static final String PREFERENCE_FONT_SIZE = "pref_font_size"; // 字体大小偏好存储Key,持久化保存 - private static final int SHORTCUT_ICON_TITLE_MAX_LEN = 10; // 桌面快捷方式标题最大长度,避免标题过长 - public static final String TAG_CHECKED = String.valueOf('\u221A'); // 清单模式-已勾选标记符 ✔ - public static final String TAG_UNCHECKED = String.valueOf('\u25A1'); // 清单模式-未勾选标记符 □ - - // ===================== 核心成员变量区 - 页面所有核心数据/控件/状态 ===================== - private HeadViewHolder mNoteHeaderHolder; // 标题栏控件持有者实例,缓存标题栏所有控件 - private View mHeadViewPanel; // 标题栏根布局,用于整体设置背景色 - private View mNoteBgColorSelector; // 背景色选择器面板,弹窗式展示,点击外部关闭 - private View mFontSizeSelector; // 字体大小选择器面板,弹窗式展示,点击外部关闭 - private EditText mNoteEditor; // 普通文本模式的核心编辑框,单行/多行输入均可 - private View mNoteEditorPanel; // 编辑区域根布局,承载普通编辑框/清单列表,用于设置整体背景色 - private WorkingNote mWorkingNote; // 核心数据模型【页面的核心】,所有便签数据/操作均通过该类完成 - private SharedPreferences mSharedPrefs; // 应用全局偏好设置,持久化存储用户的字体大小选择 - private int mFontSizeId; // 当前选中的字体大小业务标识,关联ResourceParser常量 - private LinearLayout mEditTextList; // 清单模式的核心列表容器,承载所有带复选框的编辑项 - private String mUserQuery; // 搜索关键词,从搜索列表跳转时传入,用于文本高亮 - private Pattern mPattern; // 搜索关键词的正则匹配器,编译一次复用多次,提升匹配效率 - private BackgroundQueryHandler mBackgroundQueryHandler; // 异步查询处理器,用于加载文件夹列表 - private static final int FOLDER_LIST_QUERY_TOKEN = 1; // 文件夹列表查询的token标识 - - // ===================== Activity 生命周期核心方法 ===================== - /** - * 页面创建入口:初始化布局、读取启动参数、初始化页面核心状态 - * 生命周期:页面第一次创建时调用,只执行一次 - * @param savedInstanceState 页面重建时的状态缓存,屏幕旋转/内存不足时保存数据 - */ + private static final String TAG = "NoteEditActivity"; + + private HeadViewHolder mNoteHeaderHolder; + + private View mHeadViewPanel; + + private View mNoteBgColorSelector; + + private View mFontSizeSelector; + + private NoteEditText mNoteEditor; + private NoteEditText mNoteTitleEditor; // 标题编辑器(支持富文本) + + private View mNoteEditorPanel; + private View mCharacterCountLayout; // 字数统计布局 + + private WorkingNote mWorkingNote; + + private SharedPreferences mSharedPrefs; + private int mFontSizeId; + + private TextView mCharacterCountView; // 字数统计显示 + + private static final String PREFERENCE_FONT_SIZE = "pref_font_size"; + + private static final int SHORTCUT_ICON_TITLE_MAX_LEN = 10; + + public static final String TAG_CHECKED = String.valueOf('\u221A'); + public static final String TAG_UNCHECKED = String.valueOf('\u25A1'); + + private LinearLayout mEditTextList; + + private String mUserQuery; + private Pattern mPattern; + + // 撤回功能相关 + private java.util.Stack mUndoStack; // 用于保存撤回历史记录(支持富文本) + private boolean mIsUndoing; // 标记是否正在执行撤回操作,避免递归调用 + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.note_edit); // 加载核心编辑页面布局 + this.setContentView(R.layout.note_edit); - // 核心初始化逻辑:无重建状态时,通过启动Intent初始化页面;初始化失败则直接关闭页面 if (savedInstanceState == null && !initActivityState(getIntent())) { finish(); return; } - initResources(); // 初始化控件、监听器、偏好设置等所有页面资源 + initResources(); + + // Check if we need to show change background dialog directly + if (getIntent().getBooleanExtra("CHANGE_BACKGROUND", false)) { + changeBackground(); + } } /** - * 页面状态恢复:Activity因内存不足被销毁后重建时,恢复之前的编辑状态 - * 生命周期:页面重建时调用,仅在savedInstanceState不为空时执行 - * @param savedInstanceState 保存的页面状态,核心存储便签ID + * Current activity may be killed when the memory is low. Once it is killed, for another time + * user load this activity, we should restore the former state */ @Override protected void onRestoreInstanceState(Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); - // 从缓存中读取便签ID,重新构建Intent并初始化页面状态 if (savedInstanceState != null && savedInstanceState.containsKey(Intent.EXTRA_UID)) { Intent intent = new Intent(Intent.ACTION_VIEW); intent.putExtra(Intent.EXTRA_UID, savedInstanceState.getLong(Intent.EXTRA_UID)); @@ -264,26 +206,24 @@ public class NoteEditActivity extends Activity implements OnClickListener, } } - /** - * 核心初始化方法:根据启动Intent的Action,区分不同的业务场景,初始化页面核心状态 - * 四大核心场景全覆盖,是页面的入口逻辑,所有数据初始化均从此处开始 - * @param intent 启动当前页面的Intent对象,携带所有启动参数 - * @return true-初始化成功,false-初始化失败(如便签不存在、参数异常) - */ private boolean initActivityState(Intent intent) { - mWorkingNote = null; // 初始化数据模型为空,避免脏数据 - // 场景一:ACTION_VIEW → 查看/编辑【已有便签】(从便签列表点击进入) + /** + * If the user specified the {@link Intent#ACTION_VIEW} but not provided with id, + * then jump to the NotesListActivity + */ + mWorkingNote = null; if (TextUtils.equals(Intent.ACTION_VIEW, intent.getAction())) { - long noteId = intent.getLongExtra(Intent.EXTRA_UID, 0); // 获取要查看的便签ID - mUserQuery = ""; // 默认无搜索关键词 + long noteId = intent.getLongExtra(Intent.EXTRA_UID, 0); + mUserQuery = ""; - // 子场景:从【搜索结果列表】跳转 → 携带搜索关键词,需要文本高亮 + /** + * Starting from the searched result + */ if (intent.hasExtra(SearchManager.EXTRA_DATA_KEY)) { noteId = Long.parseLong(intent.getStringExtra(SearchManager.EXTRA_DATA_KEY)); mUserQuery = intent.getStringExtra(SearchManager.USER_QUERY); } - // 数据校验:校验便签是否存在于数据库且为有效便签类型,不存在则跳转列表并提示 if (!DataUtils.visibleInNoteDatabase(getContentResolver(), noteId, Notes.TYPE_NOTE)) { Intent jump = new Intent(this, NotesListActivity.class); startActivity(jump); @@ -291,28 +231,27 @@ public class NoteEditActivity extends Activity implements OnClickListener, finish(); return false; } else { - // 加载便签数据:通过WorkingNote从数据库加载便签的所有信息 mWorkingNote = WorkingNote.load(this, noteId); - if (mWorkingNote == null) { // 加载失败则关闭页面,避免空指针 + if (mWorkingNote == null) { Log.e(TAG, "load note failed with note id" + noteId); finish(); return false; } } - // 查看模式软键盘策略:隐藏软键盘,布局自适应,提升浏览体验 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); - } - // 场景二:ACTION_INSERT_OR_EDIT → 【新建便签】(包含普通新建/通话记录新建) - else if(TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction())) { - // 解析新建便签的核心参数:文件夹ID、小组件ID、小组件类型、默认背景色 + } else if(TextUtils.equals(Intent.ACTION_INSERT_OR_EDIT, intent.getAction())) { + // New note long folderId = intent.getLongExtra(Notes.INTENT_EXTRA_FOLDER_ID, 0); - int widgetId = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); - int widgetType = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, Notes.TYPE_WIDGET_INVALIDE); - int bgResId = intent.getIntExtra(Notes.INTENT_EXTRA_BACKGROUND_ID, ResourceParser.getDefaultBgId(this)); - - // 子场景一:从【通话记录】跳转 → 新建/编辑通话记录便签,避免重复创建 + int widgetId = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID); + int widgetType = intent.getIntExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, + Notes.TYPE_WIDGET_INVALIDE); + int bgResId = intent.getIntExtra(Notes.INTENT_EXTRA_BACKGROUND_ID, + ResourceParser.getDefaultBgId(this)); + + // Parse call-record note String phoneNumber = intent.getStringExtra(Intent.EXTRA_PHONE_NUMBER); long callDate = intent.getLongExtra(Notes.INTENT_EXTRA_CALL_DATE, 0); if (callDate != 0 && phoneNumber != null) { @@ -320,8 +259,8 @@ public class NoteEditActivity extends Activity implements OnClickListener, Log.w(TAG, "The call record number is null"); } long noteId = 0; - // 先查询是否已有该通话记录的便签,有则加载,无则新建 - if ((noteId = DataUtils.getNoteIdByPhoneNumberAndCallDate(getContentResolver(), phoneNumber, callDate)) > 0) { + if ((noteId = DataUtils.getNoteIdByPhoneNumberAndCallDate(getContentResolver(), + phoneNumber, callDate)) > 0) { mWorkingNote = WorkingNote.load(this, noteId); if (mWorkingNote == null) { Log.e(TAG, "load call note failed with note id" + noteId); @@ -329,239 +268,292 @@ public class NoteEditActivity extends Activity implements OnClickListener, return false; } } else { - // 创建空便签并转换为通话记录专属便签,自动填充手机号和通话时间 - mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); + mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, + widgetType, bgResId); mWorkingNote.convertToCallNote(phoneNumber, callDate); } } else { - // 子场景二:普通新建便签 → 创建空白便签,使用默认参数 - mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, bgResId); + mWorkingNote = WorkingNote.createEmptyNote(this, folderId, widgetId, widgetType, + bgResId); } - // 新建模式软键盘策略:自动弹出软键盘,布局自适应,提升输入体验 getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE); } else { - // 异常场景:Intent无合法Action,直接关闭页面,避免未知错误 Log.e(TAG, "Intent not specified action, should not support"); finish(); return false; } - // 注册数据模型监听器:监听便签的背景色、提醒、模式、小组件等状态变化,实时更新UI mWorkingNote.setOnSettingStatusChangedListener(this); return true; } - /** - * 页面恢复可见:页面从后台切回前台、锁屏解锁后调用 - * 生命周期:每次页面恢复可见时调用,可执行多次 - */ @Override protected void onResume() { super.onResume(); - initNoteScreen(); // 恢复/初始化便签的展示样式、模式、高亮等UI状态 + initNoteScreen(); } - /** - * 初始化便签展示界面:页面核心UI渲染方法,适配所有样式和模式 - * 核心逻辑:根据WorkingNote的状态,应用字体样式、切换编辑模式、设置背景色、展示时间和提醒信息 - */ private void initNoteScreen() { - // 第一步:为普通编辑框应用当前选中的字体大小样式 - mNoteEditor.setTextAppearance(this, TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); + mNoteEditor.setTextAppearance(this, TextAppearanceResources + .getTexAppearanceResource(mFontSizeId)); + mNoteTitleEditor.setTextAppearance(this, TextAppearanceResources + .getTexAppearanceResource(mFontSizeId)); + // 设置 MovementMethod,确保 ImageSpan 能正确显示 + mNoteEditor.setMovementMethod(android.text.method.LinkMovementMethod.getInstance()); + + // 设置标题 + mNoteTitleEditor.setText(mWorkingNote.getTitle()); - // 第二步:根据便签模式,切换对应的编辑布局 if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { - switchToListMode(mWorkingNote.getContent()); // 清单模式:加载清单列表 + switchToListMode(mWorkingNote.getContent()); } else { - // 普通模式:设置文本并高亮搜索关键词,光标定位到文本末尾,提升编辑体验 mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); mNoteEditor.setSelection(mNoteEditor.getText().length()); } - - // 第三步:初始化背景色选择器的选中态,默认全部隐藏,只显示当前选中的背景色标识 + for (Integer id : sBgSelectorSelectionMap.keySet()) { findViewById(sBgSelectorSelectionMap.get(id)).setVisibility(View.GONE); } - - // 第四步:为标题栏和编辑区域应用当前选中的背景色样式 + + // 初始化字数统计显示 + updateCharacterCount(); + mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId()); - mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + + // 检查是否有保存的背景图片 + SharedPreferences sharedPreferences = getSharedPreferences("NoteSettings", Context.MODE_PRIVATE); + String backgroundImagePath = sharedPreferences.getString("background_image", null); + if (backgroundImagePath != null) { + // 加载保存的背景图片 + try { + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeFile(backgroundImagePath); + if (bitmap != null) { + android.graphics.drawable.BitmapDrawable drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap); + mNoteEditorPanel.setBackground(drawable); + } else { + // 如果图片加载失败,使用默认背景 + mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + } + } catch (Exception e) { + Log.e(TAG, "Error loading saved background image: " + e.getMessage()); + mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + } + } else { + // 使用默认背景 + mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); + } + + // 设置字数统计布局的背景颜色与便签一致 + mCharacterCountLayout.setBackgroundResource(mWorkingNote.getBgColorResId()); - // 第五步:格式化展示便签的最后修改时间,包含年月日时分,样式统一 mNoteHeaderHolder.tvModified.setText(DateUtils.formatDateTime(this, mWorkingNote.getModifiedDate(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_NUMERIC_DATE | DateUtils.FORMAT_SHOW_TIME | DateUtils.FORMAT_SHOW_YEAR)); - // 第六步:展示提醒信息,区分过期/未过期状态,更新对应的文本和图标 + /** + * TODO: Add the menu for setting alert. Currently disable it because the DateTimePicker + * is not ready + */ showAlertHeader(); } - /** - * 更新提醒信息展示:根据便签的提醒状态,动态显示/隐藏提醒相关控件 - * 核心逻辑:有提醒则显示图标+时间/过期提示,无提醒则隐藏所有相关控件,界面整洁 - */ private void showAlertHeader() { if (mWorkingNote.hasClockAlert()) { long time = System.currentTimeMillis(); - // 提醒已过期:显示「已过期」文本,提示用户及时处理 if (time > mWorkingNote.getAlertDate()) { mNoteHeaderHolder.tvAlertDate.setText(R.string.note_alert_expired); } else { - // 提醒未过期:显示相对时间(如「10分钟后」「1小时后」),更符合用户阅读习惯 mNoteHeaderHolder.tvAlertDate.setText(DateUtils.getRelativeTimeSpanString( mWorkingNote.getAlertDate(), time, DateUtils.MINUTE_IN_MILLIS)); } mNoteHeaderHolder.tvAlertDate.setVisibility(View.VISIBLE); mNoteHeaderHolder.ivAlertIcon.setVisibility(View.VISIBLE); } else { - // 无提醒:隐藏所有提醒相关控件,节省界面空间 mNoteHeaderHolder.tvAlertDate.setVisibility(View.GONE); mNoteHeaderHolder.ivAlertIcon.setVisibility(View.GONE); }; } - /** - * 处理新的启动意图:页面已存在时,接收到新的Intent调用该方法(如桌面快捷方式再次打开) - * 核心逻辑:复用当前页面,重新初始化状态,避免创建多个页面实例 - * @param intent 新的启动意图 - */ @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); - initActivityState(intent); // 重新初始化页面状态,适配新的Intent参数 + initActivityState(intent); } - /** - * 页面状态保存:页面即将被销毁时调用,保存核心状态数据,用于重建时恢复 - * 生命周期:屏幕旋转、内存不足、页面退到后台时调用 - * @param outState 状态缓存容器,存储需要恢复的数据 - */ @Override protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); - // 关键逻辑:未保存到数据库的新便签,先执行保存生成ID,避免数据丢失 + /** + * For new note without note id, we should firstly save it to + * generate a id. If the editing note is not worth saving, there + * is no id which is equivalent to create new note + */ if (!mWorkingNote.existInDatabase()) { saveNote(); } - // 将核心的便签ID保存到缓存,页面重建时通过该ID恢复数据 outState.putLong(Intent.EXTRA_UID, mWorkingNote.getNoteId()); Log.d(TAG, "Save working note id: " + mWorkingNote.getNoteId() + " onSaveInstanceState"); } - /** - * 触摸事件分发:页面的核心触摸事件处理入口,优先级高于所有子控件的触摸事件 - * 核心业务:实现「点击外部关闭弹窗」的交互逻辑,处理背景色/字体选择器的关闭 - * @param ev 触摸事件对象,包含触摸坐标、动作类型等信息 - * @return true-事件已消费,false-事件继续分发 - */ @Override public boolean dispatchTouchEvent(MotionEvent ev) { - // 背景色选择器显示时,点击外部区域则关闭面板,消费事件避免穿透 if (mNoteBgColorSelector.getVisibility() == View.VISIBLE && !inRangeOfView(mNoteBgColorSelector, ev)) { mNoteBgColorSelector.setVisibility(View.GONE); return true; } - // 字体大小选择器显示时,点击外部区域则关闭面板,消费事件避免穿透 if (mFontSizeSelector.getVisibility() == View.VISIBLE && !inRangeOfView(mFontSizeSelector, ev)) { mFontSizeSelector.setVisibility(View.GONE); return true; } - return super.dispatchTouchEvent(ev); // 未消费则交给父类处理,不影响其他触摸逻辑 + return super.dispatchTouchEvent(ev); } - /** - * 坐标范围校验工具:判断触摸坐标是否在指定View的矩形范围内 - * 核心作用:支撑「点击外部关闭弹窗」的逻辑,精准判断触摸位置 - * @param view 目标校验的View - * @param ev 触摸事件对象 - * @return true-触摸在View范围内,false-触摸在View范围外 - */ private boolean inRangeOfView(View view, MotionEvent ev) { int []location = new int[2]; - view.getLocationOnScreen(location); // 获取View在屏幕上的绝对坐标 + view.getLocationOnScreen(location); int x = location[0]; int y = location[1]; - // 校验触摸坐标是否超出View的上下左右边界 - if (ev.getX() < x || ev.getX() > (x + view.getWidth()) || ev.getY() < y || ev.getY() > (y + view.getHeight())) { + if (ev.getX() < x + || ev.getX() > (x + view.getWidth()) + || ev.getY() < y + || ev.getY() > (y + view.getHeight())) { return false; } return true; } - /** - * 页面资源初始化:绑定所有UI控件、设置点击监听器、读取用户偏好设置 - * 核心逻辑:所有控件只查找一次,监听器一次性绑定,偏好设置一次性读取,提升页面性能 - */ private void initResources() { - // 第一步:绑定标题栏根布局和控件持有者,缓存标题栏所有控件 mHeadViewPanel = findViewById(R.id.note_title); mNoteHeaderHolder = new HeadViewHolder(); mNoteHeaderHolder.tvModified = (TextView) findViewById(R.id.tv_modified_date); mNoteHeaderHolder.ivAlertIcon = (ImageView) findViewById(R.id.iv_alert_icon); mNoteHeaderHolder.tvAlertDate = (TextView) findViewById(R.id.tv_alert_date); mNoteHeaderHolder.ibSetBgColor = (ImageView) findViewById(R.id.btn_set_bg_color); - mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); // 绑定背景色按钮点击监听 - - // 初始化异步查询处理器,用于加载文件夹列表 - mBackgroundQueryHandler = new BackgroundQueryHandler(this.getContentResolver()); - - // 第二步:绑定编辑区域核心控件 - mNoteEditor = (EditText) findViewById(R.id.note_edit_view); + mNoteHeaderHolder.ibSetBgColor.setOnClickListener(this); + mNoteEditor = (NoteEditText) findViewById(R.id.note_edit_view); + mNoteTitleEditor = (NoteEditText) findViewById(R.id.note_title_edit); // 初始化标题编辑器(支持富文本) + // 显式设置输入法类型,确保支持中文输入 + mNoteEditor.setInputType(android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE | + android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | + android.text.InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | + android.text.InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); + // 为标题编辑器设置输入类型 + mNoteTitleEditor.setInputType(android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | + android.text.InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | + android.text.InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE); mNoteEditorPanel = findViewById(R.id.sv_note_edit); mNoteBgColorSelector = findViewById(R.id.note_bg_color_selector); - - // 第三步:为所有背景色选择按钮绑定点击监听器,统一处理背景色切换逻辑 for (int id : sBgSelectorBtnsMap.keySet()) { ImageView iv = (ImageView) findViewById(id); iv.setOnClickListener(this); } - // 第四步:绑定字体大小选择器,并为所有字体按钮绑定点击监听器 mFontSizeSelector = findViewById(R.id.font_size_selector); for (int id : sFontSizeBtnsMap.keySet()) { View view = findViewById(id); view.setOnClickListener(this); }; - - // 第五步:读取用户的字体大小偏好设置,持久化存储,兼容异常值(重置为默认) mSharedPrefs = PreferenceManager.getDefaultSharedPreferences(this); mFontSizeId = mSharedPrefs.getInt(PREFERENCE_FONT_SIZE, ResourceParser.BG_DEFAULT_FONT_SIZE); + /** + * HACKME: Fix bug of store the resource id in shared preference. + * The id may larger than the length of resources, in this case, + * return the {@link ResourceParser#BG_DEFAULT_FONT_SIZE} + */ if(mFontSizeId >= TextAppearanceResources.getResourcesSize()) { mFontSizeId = ResourceParser.BG_DEFAULT_FONT_SIZE; } - - // 第六步:绑定清单模式的核心列表容器 mEditTextList = (LinearLayout) findViewById(R.id.note_edit_list); + + // 初始化字数统计显示 + mCharacterCountLayout = findViewById(R.id.ll_character_count); + mCharacterCountView = (TextView) findViewById(R.id.tv_character_count); + + // 初始化撤回栈,保存完整的SpannableStringBuilder,包括富文本格式 + mUndoStack = new java.util.Stack(); + mIsUndoing = false; + + // 创建一个通用的文本变化监听器,用于处理标题和内容编辑器 + final android.text.TextWatcher textWatcher = new android.text.TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + if (!mIsUndoing) { + // 获取当前焦点的编辑器 + View focusedView = getCurrentFocus(); + if (focusedView == null) return; + + // 保存完整的便签内容,包括标题和正文(支持富文本) + android.text.SpannableStringBuilder fullContent = new android.text.SpannableStringBuilder(); + + // 先保存标题 + android.text.SpannableStringBuilder titleContent = new android.text.SpannableStringBuilder(mNoteTitleEditor.getText()); + fullContent.append(titleContent); + // 添加分隔符,用于区分标题和正文 + fullContent.append("\n---\n"); + // 再保存正文 + android.text.SpannableStringBuilder noteContent = new android.text.SpannableStringBuilder(mNoteEditor.getText()); + fullContent.append(noteContent); + + // 检查是否与栈顶内容相同,避免重复保存 + if (!mUndoStack.isEmpty()) { + String topContent = mUndoStack.peek().toString(); + if (topContent.equals(fullContent.toString())) { + return; + } + } + + // 保存到撤回栈 + mUndoStack.push(fullContent); + + // 限制撤回栈的大小 + if (mUndoStack.size() > 20) { + mUndoStack.remove(0); + } + } + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(android.text.Editable s) { + // 更新字数统计 + updateCharacterCount(); + + // 实时保存标题 + mWorkingNote.setWorkingTitle(mNoteTitleEditor.getText().toString()); + } + }; + + // 为内容编辑器添加监听器 + mNoteEditor.addTextChangedListener(textWatcher); + + // 为标题编辑器添加监听器 + mNoteTitleEditor.addTextChangedListener(textWatcher); + + // 为NoteEditText添加MovementMethod,确保链接可以被点击 + mNoteEditor.setMovementMethod(android.text.method.LinkMovementMethod.getInstance()); + + } - /** - * 页面暂停:页面退到后台、锁屏、启动新页面时调用 - * 生命周期:每次页面失去焦点时调用,可执行多次 - * 核心逻辑:自动保存便签数据,避免数据丢失;清理弹窗状态,保证界面整洁 - */ @Override protected void onPause() { super.onPause(); - // 自动保存便签:页面暂停时必保存,所有编辑操作不会丢失,核心防丢机制 if(saveNote()) { Log.d(TAG, "Note data was saved with length:" + mWorkingNote.getContent().length()); } - clearSettingState(); // 关闭所有弹窗面板,避免重建时残留显示 + clearSettingState(); } - /** - * 更新桌面小组件:当便签关联了桌面小组件时,编辑后同步更新小组件内容 - * 核心逻辑:发送广播通知对应的小组件刷新,保证便签与小组件数据一致 - */ private void updateWidget() { Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); - // 根据便签绑定的小组件类型,选择对应的小组件广播接收者 if (mWorkingNote.getWidgetType() == Notes.TYPE_WIDGET_2X) { intent.setClass(this, NoteWidgetProvider_2x.class); } else if (mWorkingNote.getWidgetType() == Notes.TYPE_WIDGET_4X) { @@ -571,68 +563,51 @@ public class NoteEditActivity extends Activity implements OnClickListener, return; } - // 传入小组件ID,发送精准的更新广播 - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { mWorkingNote.getWidgetId() }); + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { + mWorkingNote.getWidgetId() + }); + sendBroadcast(intent); - setResult(RESULT_OK, intent); // 设置返回结果,告知调用方更新成功 + setResult(RESULT_OK, intent); } - /** - * 点击事件统一处理:所有控件的点击事件均在此处分发,统一管理,避免分散处理 - * 核心处理:背景色选择、字体大小选择、背景色面板打开,所有点击逻辑集中管理 - * @param v 被点击的View控件 - */ public void onClick(View v) { int id = v.getId(); - // 点击背景色按钮:打开背景色选择面板,显示当前选中的背景色标识 if (id == R.id.btn_set_bg_color) { mNoteBgColorSelector.setVisibility(View.VISIBLE); - findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility(View.VISIBLE); - } - // 点击背景色选择按钮:切换便签背景色,隐藏选择面板,触发背景色变化回调 - else if (sBgSelectorBtnsMap.containsKey(id)) { - findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility(View.GONE); + findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( + - View.VISIBLE); + } else if (sBgSelectorBtnsMap.containsKey(id)) { + findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( + View.GONE); mWorkingNote.setBgColorId(sBgSelectorBtnsMap.get(id)); mNoteBgColorSelector.setVisibility(View.GONE); - } - // 点击字体大小选择按钮:持久化保存字体选择、更新UI样式、隐藏选择面板 - else if (sFontSizeBtnsMap.containsKey(id)) { + } else if (sFontSizeBtnsMap.containsKey(id)) { findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.GONE); mFontSizeId = sFontSizeBtnsMap.get(id); - // 持久化存储用户的字体选择,下次打开页面自动应用 mSharedPrefs.edit().putInt(PREFERENCE_FONT_SIZE, mFontSizeId).commit(); findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE); - - // 根据当前编辑模式,分别更新字体样式,保证样式统一 if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { getWorkingText(); switchToListMode(mWorkingNote.getContent()); } else { - mNoteEditor.setTextAppearance(this, TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); + mNoteEditor.setTextAppearance(this, + TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); } mFontSizeSelector.setVisibility(View.GONE); } } - /** - * 返回键事件处理:重写系统返回键,增加弹窗关闭逻辑,提升用户体验 - * 设计意图:用户点击返回键时,优先关闭打开的弹窗面板,而非直接退出页面,避免误操作 - */ @Override public void onBackPressed() { - // 若有弹窗面板打开,则先关闭面板,不执行返回逻辑 if(clearSettingState()) { return; } - saveNote(); // 保存当前编辑内容,避免数据丢失 - super.onBackPressed(); // 执行系统默认的返回逻辑,退出当前页面 + saveNote(); + super.onBackPressed(); } - /** - * 清理弹窗面板状态:关闭背景色/字体大小选择器,统一的弹窗关闭方法 - * @return true-关闭了某一个面板,false-无面板需要关闭 - */ private boolean clearSettingState() { if (mNoteBgColorSelector.getVisibility() == View.VISIBLE) { mNoteBgColorSelector.setVisibility(View.GONE); @@ -644,112 +619,45 @@ public class NoteEditActivity extends Activity implements OnClickListener, return false; } - // ===================== WorkingNote 状态变化回调接口实现 ===================== - // 该接口由WorkingNote定义,当便签的背景色、提醒、模式、小组件等状态变化时,自动回调以下方法 - // 核心价值:解耦数据模型与UI层,数据变化自动更新UI,无需手动调用,符合观察者模式 - - /** - * 背景色变化回调:便签背景色修改后,自动更新页面的背景色样式和选择器选中态 - */ public void onBackgroundColorChanged() { - findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility(View.VISIBLE); + findViewById(sBgSelectorSelectionMap.get(mWorkingNote.getBgColorId())).setVisibility( + View.VISIBLE); mNoteEditorPanel.setBackgroundResource(mWorkingNote.getBgColorResId()); mHeadViewPanel.setBackgroundResource(mWorkingNote.getTitleBgResId()); + // 设置字数统计布局的背景颜色与便签一致 + mCharacterCountLayout.setBackgroundResource(mWorkingNote.getBgColorResId()); } - /** - * 提醒时间变化回调:便签的提醒时间设置/取消后,注册/取消系统闹钟,更新提醒UI展示 - * @param date 新的提醒时间戳,0表示取消提醒 - * @param set true-设置提醒,false-取消提醒 - */ - public void onClockAlertChanged(long date, boolean set) { - // 关键逻辑:未保存的新便签,设置提醒前必须先保存,否则无唯一ID绑定闹钟 - if (!mWorkingNote.existInDatabase()) { - saveNote(); - } - - // 仅处理有效便签ID的提醒设置,避免无效操作 - if (mWorkingNote.getNoteId() > 0) { - // 构建提醒广播Intent,通过URI绑定唯一的便签ID,确保闹钟触发时能找到对应的便签 - Intent intent = new Intent(this, AlarmReceiver.class); - intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId())); - PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, 0); - AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE)); - - showAlertHeader(); // 更新提醒信息的UI展示 - - // 取消提醒:移除已注册的闹钟,清理提醒状态 - if(!set) { - alarmManager.cancel(pendingIntent); - } else { - // 设置提醒:注册系统闹钟,RTC_WAKEUP模式保证即使设备休眠也能触发提醒 - alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent); - } - } else { - // 异常提示:便签无内容未保存,无法设置提醒,引导用户输入内容 - Log.e(TAG, "Clock alert setting error"); - showToast(R.string.error_note_empty_for_clock); - } - } - - /** - * 小组件关联变化回调:便签绑定的小组件信息变更后,自动更新桌面小组件内容 - */ - public void onWidgetChanged() { - updateWidget(); - } - - /** - * 编辑模式变化回调:便签在普通/清单模式间切换时,更新对应的编辑布局和数据 - * @param oldMode 切换前的模式,0=普通模式,MODE_CHECK_LIST=清单模式 - * @param newMode 切换后的模式 - */ - public void onCheckListModeChanged(int oldMode, int newMode) { - if (newMode == TextNote.MODE_CHECK_LIST) { - switchToListMode(mNoteEditor.getText().toString()); // 切换到清单模式 - } else { - // 切换到普通模式:先获取清单模式的所有内容,合并为文本后展示 - if (!getWorkingText()) { - mWorkingNote.setWorkingText(mWorkingNote.getContent().replace(TAG_UNCHECKED + " ", "")); - } - mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); - mEditTextList.setVisibility(View.GONE); - mNoteEditor.setVisibility(View.VISIBLE); - } - } - - // ===================== 选项菜单核心逻辑 ===================== - /** - * 准备选项菜单:页面右上角的功能菜单,动态加载菜单项,适配不同的便签状态 - * 核心逻辑:根据便签类型(普通/通话记录)、模式(清单/普通)、提醒状态,动态显示/隐藏菜单项 - * @param menu 菜单容器,用于加载和展示菜单项 - * @return true-菜单初始化成功,false-初始化失败 - */ @Override public boolean onPrepareOptionsMenu(Menu menu) { - if (isFinishing()) { // 页面正在关闭时,无需初始化菜单 + Log.d(TAG, "onPrepareOptionsMenu called"); + if (isFinishing()) { return true; } - clearSettingState(); // 关闭所有弹窗面板,避免菜单与面板叠加显示 - menu.clear(); // 清空原有菜单,避免重复加载 + clearSettingState(); + menu.clear(); - // 根据便签所属文件夹,加载对应的菜单布局:通话记录便签有专属菜单 if (mWorkingNote.getFolderId() == Notes.ID_CALL_RECORD_FOLDER) { + Log.d(TAG, "Inflating call_note_edit menu"); getMenuInflater().inflate(R.menu.call_note_edit, menu); } else { + Log.d(TAG, "Inflating note_edit menu"); getMenuInflater().inflate(R.menu.note_edit, menu); - // 动态添加"移动到文件夹"菜单项 - menu.add(Menu.NONE, R.id.menu_move_to_folder, Menu.CATEGORY_CONTAINER, R.string.menu_move); } - // 动态更新模式切换菜单的标题:清单模式显示「普通模式」,普通模式显示「清单模式」 + // 检查menu_change_background菜单项是否存在 + MenuItem changeBackgroundItem = menu.findItem(R.id.menu_change_background); + if (changeBackgroundItem != null) { + Log.d(TAG, "menu_change_background item found in menu"); + } else { + Log.d(TAG, "menu_change_background item NOT found in menu"); + } + if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { menu.findItem(R.id.menu_list_mode).setTitle(R.string.menu_normal_mode); } else { menu.findItem(R.id.menu_list_mode).setTitle(R.string.menu_list_mode); } - - // 动态控制提醒菜单的显隐:有提醒则隐藏「设置提醒」,无提醒则隐藏「取消提醒」 if (mWorkingNote.hasClockAlert()) { menu.findItem(R.id.menu_alert).setVisible(false); } else { @@ -758,58 +666,186 @@ public class NoteEditActivity extends Activity implements OnClickListener, return true; } - /** - * 选项菜单点击事件处理:所有菜单项的核心业务逻辑入口,页面的核心功能集合 - * 包含:新建便签、删除便签、字体设置、模式切换、分享、桌面快捷方式、提醒设置/取消 - * @param item 被点击的菜单项 - * @return true-事件已处理,false-事件未处理 - */ @Override public boolean onOptionsItemSelected(MenuItem item) { + Log.d(TAG, "onOptionsItemSelected called, item id: " + item.getItemId()); switch (item.getItemId()) { case R.id.menu_new_note: - createNewNote(); // 新建便签:保存当前便签后,启动新的编辑页面 + createNewNote(); + break; + case R.id.menu_undo: + // 实现撤回功能,支持标题和正文的富文本格式 + if (!mUndoStack.isEmpty()) { + try { + mIsUndoing = true; + android.text.SpannableStringBuilder previousFullContent = mUndoStack.pop(); + + // 解析保存的完整内容,分离标题和正文 + String fullContentStr = previousFullContent.toString(); + String titleContentStr = fullContentStr; + String noteContentStr = ""; + + // 查找分隔符 + int separatorIndex = fullContentStr.indexOf("\n---\n"); + if (separatorIndex != -1) { + titleContentStr = fullContentStr.substring(0, separatorIndex); + noteContentStr = fullContentStr.substring(separatorIndex + 5); // 跳过分隔符 + } + + // 恢复标题(带富文本格式) + android.text.SpannableStringBuilder titleContent = new android.text.SpannableStringBuilder(previousFullContent); + titleContent = new android.text.SpannableStringBuilder(titleContent.subSequence(0, titleContentStr.length())); + mNoteTitleEditor.setText(titleContent, android.widget.TextView.BufferType.SPANNABLE); + + // 恢复正文(带富文本格式) + android.text.SpannableStringBuilder noteContent = new android.text.SpannableStringBuilder(previousFullContent); + if (separatorIndex != -1) { + noteContent = new android.text.SpannableStringBuilder(noteContent.subSequence(separatorIndex + 5, previousFullContent.length())); + } + mNoteEditor.setText(noteContent, android.widget.TextView.BufferType.SPANNABLE); + + // 更新工作便签和字数统计 + mWorkingNote.setWorkingTitle(titleContentStr); + mWorkingNote.setWorkingText(noteContentStr); + updateCharacterCount(); + } finally { + mIsUndoing = false; + } + } break; case R.id.menu_delete: - // 删除便签:弹出确认弹窗,用户确认后执行删除逻辑,避免误删 AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle(getString(R.string.alert_title_delete)); builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(getString(R.string.alert_message_delete_note)); - builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - deleteCurrentNote(); - finish(); - } - }); + builder.setMessage("请选择删除方式"); + builder.setPositiveButton("移动到回收站", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + deleteCurrentNote(); + finish(); + } + }); + builder.setNeutralButton("直接删除", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + // 直接彻底删除 + if (mWorkingNote.existInDatabase()) { + HashSet ids = new HashSet(); + long id = mWorkingNote.getNoteId(); + if (id != Notes.ID_ROOT_FOLDER) { + ids.add(id); + } else { + Log.d(TAG, "Wrong note id, should not happen"); + } + DataUtils.batchDeleteNotes(getContentResolver(), ids); + } + mWorkingNote.markDeleted(true); + finish(); + } + }); builder.setNegativeButton(android.R.string.cancel, null); builder.show(); break; case R.id.menu_font_size: - // 打开字体大小选择面板,显示当前选中的字体标识 mFontSizeSelector.setVisibility(View.VISIBLE); findViewById(sFontSelectorSelectionMap.get(mFontSizeId)).setVisibility(View.VISIBLE); break; case R.id.menu_list_mode: - // 切换便签编辑模式:普通 ↔ 清单,数据自动转换,无损切换 - mWorkingNote.setCheckListMode(mWorkingNote.getCheckListMode() == 0 ? TextNote.MODE_CHECK_LIST : 0); + mWorkingNote.setCheckListMode(mWorkingNote.getCheckListMode() == 0 ? + TextNote.MODE_CHECK_LIST : 0); break; case R.id.menu_share: - // 分享便签:获取最新编辑内容,调用系统分享接口,支持所有分享渠道 getWorkingText(); sendTo(this, mWorkingNote.getContent()); break; case R.id.menu_send_to_desktop: - sendToDesktop(); // 创建桌面快捷方式:一键生成便签的桌面图标,快速访问 + sendToDesktop(); break; case R.id.menu_alert: - setReminder(); // 设置提醒:弹出时间选择弹窗,选择后绑定闹钟 + setReminder(); break; case R.id.menu_delete_remind: - mWorkingNote.setAlertDate(0, false); // 取消提醒:清空便签的提醒时间 + mWorkingNote.setAlertDate(0, false); + break; + case R.id.menu_add_picture: + addPicture(); + break; + case R.id.menu_change_background: + changeBackground(); + break; + case R.id.menu_format_bold: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置加粗 + mNoteEditor.setBold(); + break; + case R.id.menu_format_italic: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置斜体 + mNoteEditor.setItalic(); + break; + case R.id.menu_format_underline: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 切换下划线 + mNoteEditor.toggleUnderline(); + break; + case R.id.menu_format_strikethrough: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 切换删除线 + mNoteEditor.toggleStrikethrough(); + break; + case R.id.menu_format_text_color: + // 设置文字颜色(内部已保存状态) + showTextColorPicker(); + break; + case R.id.menu_format_bg_color: + // 设置文字背景颜色(内部已保存状态) + showTextBackgroundColorPicker(); + break; + case R.id.menu_format_align_left: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置左对齐 + mNoteEditor.setAlignLeft(); + break; + case R.id.menu_format_align_center: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置居中对齐 + mNoteEditor.setAlignCenter(); + break; + case R.id.menu_format_align_right: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置右对齐 + mNoteEditor.setAlignRight(); + break; + case R.id.menu_format_align_justify: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置两端对齐 + mNoteEditor.setAlignJustify(); break; - case R.id.menu_move_to_folder: - startQueryDestinationFolders(); // 查询文件夹列表,让用户选择要移动到的文件夹 + case R.id.menu_format_font_large: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置大字号 + mNoteEditor.setTextSize(1.2f); + break; + case R.id.menu_format_font_normal: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置正常字号 + mNoteEditor.setTextSize(1.0f); + break; + case R.id.menu_format_font_small: + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 设置小字号 + mNoteEditor.setTextSize(0.8f); break; default: break; @@ -817,23 +853,106 @@ public class NoteEditActivity extends Activity implements OnClickListener, return true; } - /** - * 设置便签提醒:弹出自定义的时间选择弹窗,支持年月日时分的精准选择 - */ private void setReminder() { DateTimePickerDialog d = new DateTimePickerDialog(this, System.currentTimeMillis()); d.setOnDateTimeSetListener(new OnDateTimeSetListener() { public void OnDateTimeSet(AlertDialog dialog, long date) { - mWorkingNote.setAlertDate(date, true); + mWorkingNote.setAlertDate(date , true); } }); d.show(); } + + /** + * 保存当前便签状态到撤回栈(支持富文本样式修改) + */ + private void saveCurrentStateToUndoStack() { + if (mIsUndoing) return; + + // 保存完整的便签内容,包括标题和正文(支持富文本) + android.text.SpannableStringBuilder fullContent = new android.text.SpannableStringBuilder(); + + // 先保存标题 + android.text.SpannableStringBuilder titleContent = new android.text.SpannableStringBuilder(mNoteTitleEditor.getText()); + fullContent.append(titleContent); + // 添加分隔符,用于区分标题和正文 + fullContent.append("\n---\n"); + // 再保存正文 + android.text.SpannableStringBuilder noteContent = new android.text.SpannableStringBuilder(mNoteEditor.getText()); + fullContent.append(noteContent); + + // 检查是否与栈顶内容相同,避免重复保存 + if (!mUndoStack.isEmpty()) { + String topContent = mUndoStack.peek().toString(); + if (topContent.equals(fullContent.toString())) { + return; + } + } + + // 保存到撤回栈 + mUndoStack.push(fullContent); + + // 限制撤回栈的大小 + if (mUndoStack.size() > 20) { + mUndoStack.remove(0); + } + } + + /** + * 显示文字颜色选择器 + */ + private void showTextColorPicker() { + // 创建颜色选择器对话框 + final int[] colors = {android.graphics.Color.RED, android.graphics.Color.BLUE, + android.graphics.Color.GREEN, android.graphics.Color.BLACK, + android.graphics.Color.GRAY, android.graphics.Color.YELLOW, + android.graphics.Color.MAGENTA, android.graphics.Color.CYAN}; + final String[] colorNames = {"红色", "蓝色", "绿色", "黑色", + "灰色", "黄色", "紫色", "青色"}; + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("选择文字颜色"); + builder.setItems(colorNames, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 应用颜色 + mNoteEditor.setTextColor(colors[which]); + } + }); + builder.show(); + } + + /** + * 显示文字背景颜色选择器 + */ + private void showTextBackgroundColorPicker() { + // 创建背景颜色选择器对话框 + final int[] colors = {android.graphics.Color.RED, android.graphics.Color.BLUE, + android.graphics.Color.GREEN, android.graphics.Color.YELLOW, + android.graphics.Color.MAGENTA, android.graphics.Color.CYAN, + android.graphics.Color.WHITE, android.graphics.Color.GRAY}; + final String[] colorNames = {"红色", "蓝色", "绿色", "黄色", + "紫色", "青色", "白色", "灰色"}; + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("选择文字背景颜色"); + builder.setItems(colorNames, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // 保存当前状态到撤回栈 + saveCurrentStateToUndoStack(); + // 应用背景颜色 + mNoteEditor.setTextBackgroundColor(colors[which]); + } + }); + builder.show(); + } /** - * 系统分享功能:调用Android原生的分享接口,分享便签的纯文本内容 - * @param context 上下文对象 - * @param info 要分享的便签文本内容 + * Share note to apps that support {@link Intent#ACTION_SEND} action + * and {@text/plain} type */ private void sendTo(Context context, String info) { Intent intent = new Intent(Intent.ACTION_SEND); @@ -842,239 +961,250 @@ public class NoteEditActivity extends Activity implements OnClickListener, context.startActivity(intent); } - /** - * 新建便签:保存当前便签后,启动新的编辑页面,继承当前便签的文件夹属性 - * 设计意图:用户新建便签时,保留当前的编辑上下文,提升操作效率 - */ private void createNewNote() { - saveNote(); // 先保存当前编辑的便签,避免数据丢失 - finish(); // 关闭当前页面,避免页面栈过深 + // Firstly, save current editing notes + saveNote(); + + // For safety, start a new NoteEditActivity + finish(); Intent intent = new Intent(this, NoteEditActivity.class); intent.setAction(Intent.ACTION_INSERT_OR_EDIT); intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, mWorkingNote.getFolderId()); startActivity(intent); } - /** - * 删除当前便签:核心删除逻辑,区分同步模式和非同步模式,执行不同的删除策略 - * 同步模式:有绑定同步账号,便签移至回收站,可恢复;非同步模式:直接从数据库删除,不可恢复 - */ - private void deleteCurrentNote() { - if (mWorkingNote.existInDatabase()) { // 仅处理已保存到数据库的便签 + private void deleteCurrentNote() { + if (mWorkingNote.existInDatabase()) { HashSet ids = new HashSet(); long id = mWorkingNote.getNoteId(); - if (id != Notes.ID_ROOT_FOLDER) { // 校验便签ID有效性,避免误删根文件夹 + if (id != Notes.ID_ROOT_FOLDER) { ids.add(id); } else { Log.d(TAG, "Wrong note id, should not happen"); } - - // 非同步模式:直接批量删除便签 - if (!isSyncMode()) { - if (!DataUtils.batchDeleteNotes(getContentResolver(), ids)) { - Log.e(TAG, "Delete Note error"); - } - } else { - // 同步模式:将便签移至回收站文件夹,而非直接删除,支持恢复 - if (!DataUtils.batchMoveToFolder(getContentResolver(), ids, Notes.ID_TRASH_FOLER)) { - Log.e(TAG, "Move notes to trash folder error, should not happens"); - } + // 无论是否同步,都将便签移动到回收站 + if (!DataUtils.batchMoveToFolder(getContentResolver(), ids, Notes.ID_TRASH_FOLER)) { + Log.e(TAG, "Move notes to trash folder error, should not happens"); } } - mWorkingNote.markDeleted(true); // 标记便签为已删除,更新数据模型状态 - } + mWorkingNote.markDeleted(true); + } - /** - * 判断是否为同步模式:检测用户是否配置了同步账号,决定删除策略 - * @return true-已配置同步账号(同步模式),false-未配置(非同步模式) - */ private boolean isSyncMode() { return NotesPreferenceActivity.getSyncAccountName(this).trim().length() > 0; } - - /** - * 异步查询处理器:用于加载文件夹列表 - */ - private final class BackgroundQueryHandler extends AsyncQueryHandler { - public BackgroundQueryHandler(ContentResolver cr) { - super(cr); + + public void onClockAlertChanged(long date, boolean set) { + /** + * User could set clock to an unsaved note, so before setting the + * alert clock, we should save the note first + */ + if (!mWorkingNote.existInDatabase()) { + saveNote(); } - - @Override - protected void onQueryComplete(int token, Object cookie, Cursor cursor) { - if (token == FOLDER_LIST_QUERY_TOKEN) { - showFolderListMenu(cursor); + if (mWorkingNote.getNoteId() > 0) { + Intent intent = new Intent(this, AlarmReceiver.class); + intent.setData(ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, mWorkingNote.getNoteId())); + // 使用适当的PendingIntent flag,确保在Android 12+中正常工作 + int flags = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S ? PendingIntent.FLAG_IMMUTABLE : 0; + PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, flags); + AlarmManager alarmManager = ((AlarmManager) getSystemService(ALARM_SERVICE)); + showAlertHeader(); + if(!set) { + alarmManager.cancel(pendingIntent); + // 提示用户提醒已取消 + showToast(R.string.note_alert_canceled); + } else { + // 在Android 6.0+中,使用setExact方法以确保准确的提醒时间 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, date, pendingIntent); + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, date, pendingIntent); + } + // 提示用户提醒设置成功 + showToast(R.string.note_alert_set_success); } + } else { + /** + * There is the condition that user has input nothing (the note is + * not worthy saving), we have no note id, remind the user that he + * should input something + */ + Log.e(TAG, "Clock alert setting error"); + showToast(R.string.error_note_empty_for_clock); } } - - /** - * 查询文件夹列表:异步加载所有可用的文件夹 - */ - private void startQueryDestinationFolders() { - mBackgroundQueryHandler.startQuery(FOLDER_LIST_QUERY_TOKEN, null, - Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, - Notes.NoteColumns.TYPE + "=" + Notes.TYPE_FOLDER, null, - Notes.NoteColumns.SNIPPET + " COLLATE LOCALIZED ASC"); - } - - /** - * 显示文件夹选择菜单:让用户选择要移动到的文件夹 - * @param cursor 文件夹列表的查询结果 - */ - private void showFolderListMenu(Cursor cursor) { - AlertDialog.Builder builder = new AlertDialog.Builder(NoteEditActivity.this); - builder.setTitle(R.string.menu_title_select_folder); - final FoldersListAdapter adapter = new FoldersListAdapter(NoteEditActivity.this, cursor); - builder.setAdapter(adapter, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - // 保存当前编辑的内容 - getWorkingText(); - // 更新便签的文件夹ID - mWorkingNote.setFolderId(adapter.getItemId(which)); - // 保存便签 - saveNote(); - // 提示用户操作成功 - Toast.makeText(NoteEditActivity.this, - getString(R.string.format_move_notes_to_folder, 1, - adapter.getFolderName(NoteEditActivity.this, which)), - Toast.LENGTH_SHORT).show(); - } - }); - builder.show(); - } - // ===================== NoteEditText 回调接口实现(清单模式核心) ===================== - // 该接口由自定义NoteEditText定义,处理清单模式下的「删除项、回车新增项」核心交互 - // 是清单模式能够正常增删、换行的核心支撑,所有清单的动态操作均通过该接口回调实现 + public void onWidgetChanged() { + updateWidget(); + } - /** - * 清单模式-删除项回调:删除指定索引的清单项,合并文本到前一项,调整后续项的索引 - * @param index 要删除的清单项索引 - * @param text 被删除项的文本内容,需要合并到前一项 - */ public void onEditTextDelete(int index, String text) { int childCount = mEditTextList.getChildCount(); - if (childCount == 1) { // 仅保留最后一项时,禁止删除,避免清单为空 + if (childCount == 1) { return; } - // 调整删除项之后的所有项索引,保证索引连续 for (int i = index + 1; i < childCount; i++) { - ((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text)).setIndex(i - 1); + ((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text)) + .setIndex(i - 1); } - mEditTextList.removeViewAt(index); // 移除指定索引的清单项 - - // 获取合并目标项,将被删除的文本合并到目标项中 + mEditTextList.removeViewAt(index); NoteEditText edit = null; if(index == 0) { - edit = (NoteEditText) mEditTextList.getChildAt(0).findViewById(R.id.et_edit_text); + edit = (NoteEditText) mEditTextList.getChildAt(0).findViewById( + R.id.et_edit_text); } else { - edit = (NoteEditText) mEditTextList.getChildAt(index - 1).findViewById(R.id.et_edit_text); + edit = (NoteEditText) mEditTextList.getChildAt(index - 1).findViewById( + R.id.et_edit_text); } - int length = edit.length(); edit.append(text); edit.requestFocus(); - edit.setSelection(length); // 光标定位到合并后的文本末尾,提升编辑体验 + edit.setSelection(length); } - /** - * 清单模式-回车回调:在指定索引位置新增清单项,拆分文本,调整后续项的索引 - * @param index 触发回车的清单项索引 - * @param text 原项中回车后的文本内容,拆分到新项中 - */ public void onEditTextEnter(int index, String text) { - if(index > mEditTextList.getChildCount()) { // 异常校验,避免索引越界 + /** + * Should not happen, check for debug + */ + if(index > mEditTextList.getChildCount()) { Log.e(TAG, "Index out of mEditTextList boundrary, should not happen"); } - View view = getListItem(text, index); // 创建新的清单项View - mEditTextList.addView(view, index); // 插入到指定索引位置 - - // 新项获取焦点,光标定位到开头,便于用户继续输入 + View view = getListItem(text, index); + mEditTextList.addView(view, index); NoteEditText edit = (NoteEditText) view.findViewById(R.id.et_edit_text); edit.requestFocus(); edit.setSelection(0); - - // 调整新项之后的所有项索引,保证索引连续 for (int i = index + 1; i < mEditTextList.getChildCount(); i++) { - ((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text)).setIndex(i); + ((NoteEditText) mEditTextList.getChildAt(i).findViewById(R.id.et_edit_text)) + .setIndex(i); } } - /** - * 切换到清单模式:将普通文本按换行拆分,生成对应的带复选框的清单项列表 - * @param text 普通模式的文本内容,按换行符拆分为多个清单项 - */ private void switchToListMode(String text) { - mEditTextList.removeAllViews(); // 清空原有列表,避免重复加载 - String[] items = text.split("\n"); // 按换行拆分文本 + mEditTextList.removeAllViews(); + String[] items = text.split("\n"); int index = 0; - // 遍历拆分后的文本,创建并添加清单项,仅处理非空文本 for (String item : items) { if(!TextUtils.isEmpty(item)) { mEditTextList.addView(getListItem(item, index)); index++; } } - mEditTextList.addView(getListItem("", index)); // 添加空项,便于用户继续输入 - mEditTextList.getChildAt(index).findViewById(R.id.et_edit_text).requestFocus(); // 空项获取焦点 + mEditTextList.addView(getListItem("", index)); + mEditTextList.getChildAt(index).findViewById(R.id.et_edit_text).requestFocus(); - // 切换UI显示:隐藏普通编辑框,显示清单列表 mNoteEditor.setVisibility(View.GONE); mEditTextList.setVisibility(View.VISIBLE); } - /** - * 搜索关键词高亮:为匹配的搜索关键词添加背景色高亮效果,提升搜索体验 - * 核心实现:使用Spannable富文本,为匹配的文本段添加BackgroundColorSpan样式 - * @param fullText 便签的完整文本内容 - * @param userQuery 搜索关键词,为空则直接返回原文本 - * @return 带高亮效果的富文本,无匹配则返回原文本 - */ private Spannable getHighlightQueryResult(String fullText, String userQuery) { - SpannableString spannable = new SpannableString(fullText == null ? "" : fullText); + if (TextUtils.isEmpty(fullText)) { + return new SpannableString(""); + } + + // 创建一个 SpannableStringBuilder 用于构建最终的 Spannable + SpannableStringBuilder builder = new SpannableStringBuilder(fullText); + + // 正则表达式匹配 [IMAGE]imageUri[/IMAGE] 格式的图片标记 + Pattern imagePattern = Pattern.compile("\\[IMAGE\\](.*?)\\[/IMAGE\\]"); + Matcher imageMatcher = imagePattern.matcher(fullText); + + // 倒序处理,避免替换后影响后续匹配位置 + ArrayList imageInfos = new ArrayList<>(); + while (imageMatcher.find()) { + String imageUriStr = imageMatcher.group(1); + imageInfos.add(new ImageInfo(imageMatcher.start(), imageMatcher.end(), imageUriStr)); + } + + // 倒序处理图片标记 + for (int i = imageInfos.size() - 1; i >= 0; i--) { + ImageInfo info = imageInfos.get(i); + try { + // 获取图片URI + Uri imageUri = Uri.parse(info.uri); + + // 获取压缩后的 bitmap + Bitmap bitmap = getCompressedBitmap(imageUri); + if (bitmap != null) { + // 计算合适的图片大小(相对于文本大小) + float scale = getResources().getDisplayMetrics().density; + + // 使用屏幕宽度,更可靠 + int screenWidth = getResources().getDisplayMetrics().widthPixels; + int maxImageWidth = (int) (screenWidth * 0.8); // 80% of screen width + int maxImageHeight = (int) (500 * scale); // 500dp max height + + // 调整图片大小 + Bitmap scaledBitmap = getScaledBitmap(bitmap, maxImageWidth, maxImageHeight); + + // 创建 ImageSpan(使用兼容旧版本的构造方式) + ImageSpan imageSpan = new ImageSpan(this, scaledBitmap); + + // 不替换图片标记,直接在图片标记上应用 ImageSpan + // 这样可以保持文本内容不变,避免 getWorkingText 方法中的问题 + builder.setSpan(imageSpan, info.start, info.end, Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + + // 回收临时 bitmap + if (scaledBitmap != bitmap) { + bitmap.recycle(); + } + } + // 如果图片加载失败,保持原始标记不变 + } catch (Exception e) { + // 发生异常时,保持原始标记不变 + e.printStackTrace(); + } + } + + // 处理搜索高亮 if (!TextUtils.isEmpty(userQuery)) { - mPattern = Pattern.compile(userQuery); // 编译正则表达式,提升匹配效率 - Matcher m = mPattern.matcher(fullText); + mPattern = Pattern.compile(userQuery); + Matcher m = mPattern.matcher(builder); int start = 0; - // 遍历所有匹配项,为每个匹配段添加高亮样式 while (m.find(start)) { - spannable.setSpan(new BackgroundColorSpan(this.getResources().getColor(R.color.user_query_highlight)), - m.start(), m.end(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); - start = m.end(); // 更新起始位置,避免重复匹配同一文本 + builder.setSpan( + new BackgroundColorSpan(this.getResources().getColor( + R.color.user_query_highlight)), m.start(), m.end(), + Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + start = m.end(); } } - return spannable; + + return builder; + } + + // 用于存储图片信息的内部类 + private static class ImageInfo { + int start; + int end; + String uri; + + ImageInfo(int start, int end, String uri) { + this.start = start; + this.end = end; + this.uri = uri; + } } - /** - * 创建清单模式的子项View:加载布局、绑定控件、设置样式、处理勾选状态和文本高亮 - * 清单模式的核心子项构建方法,每个清单项均通过该方法创建 - * @param item 清单项的文本内容 - * @param index 清单项的索引,用于绑定回调和定位 - * @return 组装完成的清单项View - */ private View getListItem(String item, int index) { - View view = LayoutInflater.from(this).inflate(R.layout.note_edit_list_item, null); // 加载子项布局 + View view = LayoutInflater.from(this).inflate(R.layout.note_edit_list_item, null); final NoteEditText edit = (NoteEditText) view.findViewById(R.id.et_edit_text); - edit.setTextAppearance(this, TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); // 应用字体样式 - - // 绑定复选框,设置勾选状态变化监听,控制删除线样式 + edit.setTextAppearance(this, TextAppearanceResources.getTexAppearanceResource(mFontSizeId)); CheckBox cb = ((CheckBox) view.findViewById(R.id.cb_edit_item)); cb.setOnCheckedChangeListener(new OnCheckedChangeListener() { public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { if (isChecked) { - edit.setPaintFlags(edit.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); // 勾选:添加删除线 + edit.setPaintFlags(edit.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); } else { - edit.setPaintFlags(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG); // 取消勾选:移除删除线 + edit.setPaintFlags(Paint.ANTI_ALIAS_FLAG | Paint.DEV_KERN_TEXT_FLAG); } } }); - // 处理文本中的勾选标记符,初始化复选框状态和文本内容 if (item.startsWith(TAG_CHECKED)) { cb.setChecked(true); edit.setPaintFlags(edit.getPaintFlags() | Paint.STRIKE_THRU_TEXT_FLAG); @@ -1085,20 +1215,29 @@ public class NoteEditActivity extends Activity implements OnClickListener, item = item.substring(TAG_UNCHECKED.length(), item.length()).trim(); } - // 绑定回调和索引,设置文本并高亮搜索关键词 edit.setOnTextViewChangeListener(this); edit.setIndex(index); edit.setText(getHighlightQueryResult(item, mUserQuery)); + + // 为清单模式的编辑框添加文本变化监听器,用于更新字数统计 + edit.addTextChangedListener(new android.text.TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) {} + + @Override + public void afterTextChanged(android.text.Editable s) { + updateCharacterCount(); + } + }); + return view; } - /** - * 清单项文本变化回调:根据文本是否为空,控制复选框的显隐,保证界面整洁 - * @param index 清单项的索引 - * @param hasText 文本是否非空,true-显示复选框,false-隐藏复选框 - */ public void onTextChange(int index, boolean hasText) { - if (index >= mEditTextList.getChildCount()) { // 异常校验,避免索引越界 + if (index >= mEditTextList.getChildCount()) { Log.e(TAG, "Wrong index, should not happen"); return; } @@ -1109,16 +1248,26 @@ public class NoteEditActivity extends Activity implements OnClickListener, } } - /** - * 获取当前编辑的文本内容:根据当前的编辑模式,读取对应的文本并更新到WorkingNote - * 核心数据同步方法,保存便签、切换模式、分享等操作前均需调用该方法 - * @return true-清单模式下存在已勾选的项,false-无勾选/普通模式 - */ + public void onCheckListModeChanged(int oldMode, int newMode) { + if (newMode == TextNote.MODE_CHECK_LIST) { + switchToListMode(mNoteEditor.getText().toString()); + } else { + if (!getWorkingText()) { + mWorkingNote.setWorkingText(mWorkingNote.getContent().replace(TAG_UNCHECKED + " ", + "")); + } + mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); + mEditTextList.setVisibility(View.GONE); + mNoteEditor.setVisibility(View.VISIBLE); + } + // 模式切换后更新字数统计 + updateCharacterCount(); + } + private boolean getWorkingText() { boolean hasChecked = false; if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { StringBuilder sb = new StringBuilder(); - // 遍历所有清单项,拼接带标记符的文本,已勾选加✔,未勾选加□ for (int i = 0; i < mEditTextList.getChildCount(); i++) { View view = mEditTextList.getChildAt(i); NoteEditText edit = (NoteEditText) view.findViewById(R.id.et_edit_text); @@ -1131,87 +1280,477 @@ public class NoteEditActivity extends Activity implements OnClickListener, } } } - mWorkingNote.setWorkingText(sb.toString()); // 更新数据模型的内容 + mWorkingNote.setWorkingText(sb.toString()); + // 确保便签模式被正确设置 + mWorkingNote.setCheckListMode(TextNote.MODE_CHECK_LIST); } else { - mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); // 普通模式直接读取编辑框文本 + // 对于普通文本模式,获取编辑器中的文本内容 + mWorkingNote.setWorkingText(mNoteEditor.getText().toString()); + // 确保便签模式被正确设置 + mWorkingNote.setCheckListMode(0); // 0 表示普通文本模式 } return hasChecked; } - /** - * 保存便签核心方法:页面的核心数据持久化入口,所有保存操作均通过该方法完成 - * 核心逻辑:先同步编辑内容到WorkingNote,再调用WorkingNote的saveNote方法持久化到数据库 - * @return true-保存成功,false-保存失败 - */ private boolean saveNote() { - getWorkingText(); // 同步当前编辑的内容到数据模型 - boolean saved = mWorkingNote.saveNote(); // 持久化到数据库 - + getWorkingText(); + boolean saved = mWorkingNote.saveNote(); if (saved) { - setResult(RESULT_OK); // 设置返回结果,告知调用方数据已更新,需要刷新列表 + /** + * There are two modes from List view to edit view, open one note, + * create/edit a node. Opening node requires to the original + * position in the list when back from edit view, while creating a + * new node requires to the top of the list. This code + * {@link #RESULT_OK} is used to identify the create/edit state + */ + setResult(RESULT_OK); } return saved; } - + /** - * 创建桌面快捷方式:生成指向当前便签的桌面图标,点击可直接打开该便签 - * 核心逻辑:发送系统广播通知桌面启动器创建快捷方式,绑定便签的唯一ID + * 更新字数统计显示 */ + private void updateCharacterCount() { + int count = 0; + if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { + // 清单模式:遍历所有编辑框计算总字数 + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < mEditTextList.getChildCount(); i++) { + View view = mEditTextList.getChildAt(i); + NoteEditText edit = (NoteEditText) view.findViewById(R.id.et_edit_text); + if (!TextUtils.isEmpty(edit.getText())) { + sb.append(edit.getText()).append("\n"); + } + } + count = sb.length(); + } else { + // 普通文本模式:直接获取编辑器内容长度 + count = mNoteEditor.getText().length(); + } + + // 更新显示 + mCharacterCountView.setText(getString(R.string.character_count, count)); + } + private void sendToDesktop() { - // 关键逻辑:未保存的新便签,创建快捷方式前必须先保存,否则无唯一ID + /** + * Before send message to home, we should make sure that current + * editing note is exists in databases. So, for new note, firstly + * save it + */ if (!mWorkingNote.existInDatabase()) { saveNote(); } if (mWorkingNote.getNoteId() > 0) { - // 构建快捷方式的Intent,指向当前便签的查看页面 Intent sender = new Intent(); Intent shortcutIntent = new Intent(this, NoteEditActivity.class); shortcutIntent.setAction(Intent.ACTION_VIEW); shortcutIntent.putExtra(Intent.EXTRA_UID, mWorkingNote.getNoteId()); - - // 设置快捷方式的核心参数:意图、标题、图标、允许重复创建 sender.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent); - sender.putExtra(Intent.EXTRA_SHORTCUT_NAME, makeShortcutIconTitle(mWorkingNote.getContent())); - sender.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, Intent.ShortcutIconResource.fromContext(this, R.drawable.icon_app)); + sender.putExtra(Intent.EXTRA_SHORTCUT_NAME, + makeShortcutIconTitle(mWorkingNote.getContent())); + sender.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE, + Intent.ShortcutIconResource.fromContext(this, R.drawable.icon_app)); sender.putExtra("duplicate", true); sender.setAction("com.android.launcher.action.INSTALL_SHORTCUT"); - - showToast(R.string.info_note_enter_desktop); // 提示创建成功 - sendBroadcast(sender); // 发送广播,通知桌面创建快捷方式 + showToast(R.string.info_note_enter_desktop); + sendBroadcast(sender); } else { - // 异常提示:便签无内容未保存,无法创建快捷方式 + /** + * There is the condition that user has input nothing (the note is + * not worthy saving), we have no note id, remind the user that he + * should input something + */ Log.e(TAG, "Send to desktop error"); showToast(R.string.error_note_empty_for_send_to_desktop); } } - /** - * 生成桌面快捷方式标题:清理清单标记符,截断超长文本,保证标题简洁美观 - * @param content 便签的原始内容 - * @return 处理后的快捷方式标题,最长10个字符 - */ private String makeShortcutIconTitle(String content) { content = content.replace(TAG_CHECKED, ""); content = content.replace(TAG_UNCHECKED, ""); - return content.length() > SHORTCUT_ICON_TITLE_MAX_LEN ? content.substring(0, SHORTCUT_ICON_TITLE_MAX_LEN) : content; + return content.length() > SHORTCUT_ICON_TITLE_MAX_LEN ? content.substring(0, + SHORTCUT_ICON_TITLE_MAX_LEN) : content; } - // ===================== 工具方法封装 - 简化重复调用 ===================== - /** - * 显示短时长Toast提示:封装系统Toast,简化调用,统一提示样式 - * @param resId 提示文本的资源ID - */ private void showToast(int resId) { showToast(resId, Toast.LENGTH_SHORT); } - /** - * 显示指定时长的Toast提示:重载方法,支持长短两种时长 - * @param resId 提示文本的资源ID - * @param duration 提示时长,Toast.LENGTH_SHORT/Toast.LENGTH_LONG - */ private void showToast(int resId, int duration) { Toast.makeText(this, resId, duration).show(); } -} \ No newline at end of file + + private static final int REQUEST_CODE_PICK_IMAGE = 100; + private static final int REQUEST_CODE_CAMERA = 101; + private static final int REQUEST_CODE_PICK_BACKGROUND = 102; + private static final int REQUEST_CODE_TAKE_BACKGROUND = 103; + private static final int MAX_IMAGE_SIZE = 1024; // 限制图片大小为1024x1024 + + private void addPicture() { + // 显示选择对话框:从相册导入或相机拍摄 + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.menu_add_picture); + String[] options = {"从相册导入", "相机拍摄"}; + builder.setItems(options, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (which == 0) { + // 从相册导入 + Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_CODE_PICK_IMAGE); + } else { + // 相机拍摄 + Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + // 使用简单的方式启动相机,让系统处理图片存储 + startActivityForResult(intent, REQUEST_CODE_CAMERA); + } + } + }); + builder.show(); + } + + /** + * 更换背景方法 + */ + private void changeBackground() { + Log.d(TAG, "changeBackground method called"); + + // 先显示一个Toast,确认方法被调用 + Toast.makeText(this, "正在打开更换背景选项", Toast.LENGTH_SHORT).show(); + + // 直接使用runOnUiThread确保在主线程执行 + runOnUiThread(new Runnable() { + @Override + public void run() { + try { + Log.d(TAG, "Creating AlertDialog.Builder"); + + // 使用默认主题,避免样式问题 + AlertDialog.Builder builder = new AlertDialog.Builder(NoteEditActivity.this); + builder.setTitle("更换背景"); + builder.setIcon(android.R.drawable.ic_dialog_info); // 添加图标,让对话框更明显 + builder.setCancelable(true); + + String[] options = {"从相册选择", "拍照"}; + builder.setItems(options, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Log.d(TAG, "Dialog item clicked: " + which); + dialog.dismiss(); + + if (which == 0) { + // 从相册选择 + Intent intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + intent.setType("image/*"); + startActivityForResult(intent, REQUEST_CODE_PICK_BACKGROUND); + } else { + // 拍照 + Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + // 使用简单的方式启动相机,让系统处理图片存储 + startActivityForResult(intent, REQUEST_CODE_TAKE_BACKGROUND); + } + } + }); + + // 添加取消按钮 + builder.setNegativeButton("取消", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + + Log.d(TAG, "Creating dialog"); + AlertDialog dialog = builder.create(); + + // 设置对话框属性,确保显示在最上层 + android.view.Window window = dialog.getWindow(); + if (window != null) { + // 移除TYPE_APPLICATION_OVERLAY设置,因为在Android 8.0+中需要特殊权限 + // 设置窗口属性,确保不会被遮挡 + window.setFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL, + android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL); + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND); + // 设置对话框背景为白色,确保可见 + window.setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.WHITE)); + } + + // 设置对话框可取消 + dialog.setCanceledOnTouchOutside(true); + dialog.setCancelable(true); + + Log.d(TAG, "Showing dialog"); + // 确保对话框能够显示 + dialog.show(); + Log.d(TAG, "Dialog shown successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error in changeBackground: " + e.getMessage(), e); + // 显示错误信息 + Toast.makeText(NoteEditActivity.this, "更换背景失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); + } + } + }); + } + + + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == RESULT_OK) { + android.net.Uri imageUri = null; + + // 处理从相册导入图片 + if (requestCode == REQUEST_CODE_PICK_IMAGE) { + if (data != null && data.getData() != null) { + imageUri = data.getData(); + } + // 处理图片URI(添加图片到便签) + if (imageUri != null) { + handleAddPicture(imageUri); + } + } + // 处理相机拍摄图片 + else if (requestCode == REQUEST_CODE_CAMERA) { + // 相机拍摄的图片URI处理 + if (data != null && data.getData() != null) { + // 有些相机应用会直接返回URI + imageUri = data.getData(); + } else { + // 从媒体库获取最新拍摄的图片 + try { + android.database.Cursor cursor = getContentResolver().query( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{android.provider.MediaStore.Images.Media._ID}, + null, null, "DATE_ADDED DESC"); + if (cursor != null && cursor.moveToFirst()) { + long id = cursor.getLong(cursor.getColumnIndex(android.provider.MediaStore.Images.Media._ID)); + imageUri = android.content.ContentUris.withAppendedId( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); + cursor.close(); + } + } catch (Exception e) { + Log.e(TAG, "Error getting camera image: " + e.getMessage()); + } + } + // 处理图片URI(添加图片到便签) + if (imageUri != null) { + handleAddPicture(imageUri); + } + } + // 处理从相册选择背景图片 + else if (requestCode == REQUEST_CODE_PICK_BACKGROUND) { + if (data != null && data.getData() != null) { + imageUri = data.getData(); + // 设置背景图片 + setBackgroundImage(imageUri); + } + } + // 处理拍照更换背景 + else if (requestCode == REQUEST_CODE_TAKE_BACKGROUND) { + // 相机拍摄的图片URI处理 + if (data != null && data.getData() != null) { + // 有些相机应用会直接返回URI + imageUri = data.getData(); + } else { + // 从媒体库获取最新拍摄的图片 + try { + android.database.Cursor cursor = getContentResolver().query( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{android.provider.MediaStore.Images.Media._ID}, + null, null, "DATE_ADDED DESC"); + if (cursor != null && cursor.moveToFirst()) { + long id = cursor.getLong(cursor.getColumnIndex(android.provider.MediaStore.Images.Media._ID)); + imageUri = android.content.ContentUris.withAppendedId( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); + cursor.close(); + } + } catch (Exception e) { + Log.e(TAG, "Error getting camera image: " + e.getMessage()); + } + } + // 设置背景图片 + if (imageUri != null) { + setBackgroundImage(imageUri); + } + } + } + } + + /** + * 处理添加图片到便签的逻辑 + */ + private void handleAddPicture(android.net.Uri imageUri) { + try { + // 构建图片标记 + String imageTag = "\n[IMAGE]" + imageUri.toString() + "[/IMAGE]\n"; + + // 直接使用 mWorkingNote.getContent() 获取当前内容,它包含完整的图片标记 + String currentContent = mWorkingNote.getContent(); + + // 构建新内容 + String newContent; + if (currentContent.isEmpty()) { + // 空便签情况:直接添加图片标记,不需要前面的换行符 + newContent = "[IMAGE]" + imageUri.toString() + "[/IMAGE]\n"; + } else { + // 非空便签情况:在末尾添加图片标记 + newContent = currentContent + imageTag; + } + + // 更新便签内容 + mWorkingNote.setWorkingText(newContent); + + // 刷新编辑器显示 + if (mWorkingNote.getCheckListMode() == TextNote.MODE_CHECK_LIST) { + switchToListMode(mWorkingNote.getContent()); + } else { + mNoteEditor.setText(getHighlightQueryResult(mWorkingNote.getContent(), mUserQuery)); + mNoteEditor.setSelection(mNoteEditor.getText().length()); + } + + // 保存到数据库 + saveNote(); + + // 使用正确的Toast提示 + Toast.makeText(NoteEditActivity.this, "图片已添加到便签", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "Error handling image: " + e.getMessage()); + showToast(R.string.error_sdcard_export, Toast.LENGTH_SHORT); + } + } + + /** + * 设置背景图片 + */ + private void setBackgroundImage(android.net.Uri imageUri) { + try { + // 获取图片的真实路径 + String imagePath = getRealPathFromUri(imageUri); + if (imagePath != null) { + // 将图片路径保存到SharedPreferences + SharedPreferences sharedPreferences = getSharedPreferences("NoteSettings", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString("background_image", imagePath); + editor.apply(); + + // 加载图片并设置背景 + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeFile(imagePath); + if (bitmap != null) { + // 创建BitmapDrawable对象 + android.graphics.drawable.BitmapDrawable drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap); + // 设置背景图片 + mNoteEditorPanel.setBackground(drawable); + // 提示用户背景已更换 + Toast.makeText(NoteEditActivity.this, "背景已更换", Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(NoteEditActivity.this, "图片加载失败", Toast.LENGTH_SHORT).show(); + } + } + } catch (Exception e) { + Log.e(TAG, "Error setting background image: " + e.getMessage()); + Toast.makeText(NoteEditActivity.this, "更换背景失败", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 从URI获取真实的文件路径 + */ + private String getRealPathFromUri(android.net.Uri uri) { + String path = null; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + // Android 4.4及以上版本 + if ("content".equalsIgnoreCase(uri.getScheme())) { + String[] projection = {android.provider.MediaStore.Images.Media.DATA}; + android.database.Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, projection, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int column_index = cursor.getColumnIndexOrThrow(android.provider.MediaStore.Images.Media.DATA); + path = cursor.getString(column_index); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + path = uri.getPath(); + } + } else { + // Android 4.4以下版本 + String[] projection = {android.provider.MediaStore.Images.Media.DATA}; + android.database.Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, projection, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int column_index = cursor.getColumnIndexOrThrow(android.provider.MediaStore.Images.Media.DATA); + path = cursor.getString(column_index); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + return path; + } + + private android.graphics.Bitmap getCompressedBitmap(android.net.Uri uri) throws Exception { + // 获取图片的宽高 + android.content.ContentResolver resolver = getContentResolver(); + android.graphics.BitmapFactory.Options options = new android.graphics.BitmapFactory.Options(); + options.inJustDecodeBounds = true; + android.graphics.BitmapFactory.decodeStream(resolver.openInputStream(uri), null, options); + int width = options.outWidth; + int height = options.outHeight; + + // 计算缩放比例 + int scale = 1; + while (width / scale > MAX_IMAGE_SIZE || height / scale > MAX_IMAGE_SIZE) { + scale *= 2; + } + + // 加载压缩后的图片 + options.inJustDecodeBounds = false; + options.inSampleSize = scale; + return android.graphics.BitmapFactory.decodeStream(resolver.openInputStream(uri), null, options); + } + + /** + * 根据最大宽度和高度缩放图片,保持原始宽高比 + */ + private android.graphics.Bitmap getScaledBitmap(android.graphics.Bitmap bitmap, int maxWidth, int maxHeight) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + + // 计算缩放比例 + float scale = 1.0f; + if (width > maxWidth || height > maxHeight) { + float widthScale = (float) maxWidth / width; + float heightScale = (float) maxHeight / height; + scale = Math.min(widthScale, heightScale); + } + + // 如果不需要缩放,直接返回原始 bitmap + if (scale == 1.0f) { + return bitmap; + } + + // 计算新的宽度和高度 + int newWidth = (int) (width * scale); + int newHeight = (int) (height * scale); + + // 创建缩放后的 bitmap + return android.graphics.Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true); + } +} diff --git a/src/Notes-master/src/net/micode/notes/ui/NoteEditText.java b/src/Notes-master/src/net/micode/notes/ui/NoteEditText.java index 65401cb..1487a34 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NoteEditText.java +++ b/src/Notes-master/src/net/micode/notes/ui/NoteEditText.java @@ -14,264 +14,177 @@ * limitations under the License. */ -// 包声明:归属小米便签的UI模块,该类是便签编辑页的核心自定义输入控件 package net.micode.notes.ui; -// 安卓系统上下文:提供应用运行环境与系统服务访问能力 import android.content.Context; -// 安卓图形矩形类:用于焦点切换时的焦点区域坐标计算与传递 +import android.graphics.Color; +import android.graphics.Typeface; import android.graphics.Rect; -// 安卓文本布局核心类:管理文本的排版、行高、行列定位,核心支撑触摸光标精准定位 import android.text.Layout; -// 安卓文本选择工具类:用于手动设置文本光标位置、选中文本区域 import android.text.Selection; -// 安卓富文本标记接口:标识带格式的文本(如包含链接、颜色的文本),本类核心处理该类型文本 +import android.text.Spannable; +import android.text.SpannableStringBuilder; import android.text.Spanned; -// 安卓文本工具类:提供字符串判空、文本处理等安全高效的工具方法 +import android.text.TextPaint; import android.text.TextUtils; -// 安卓文本链接样式类:封装文本中的超链接数据,包含链接地址与点击事件 +import android.text.style.BackgroundColorSpan; +import android.text.style.ClickableSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.LeadingMarginSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; import android.text.style.URLSpan; -// 安卓属性集类:承载布局xml中配置的控件属性,自定义View必备构造参数 +import android.text.style.UnderlineSpan; import android.util.AttributeSet; -// 安卓日志工具类:输出编辑框相关的调试日志,便于问题排查 import android.util.Log; -// 安卓上下文菜单类:构建长按文本弹出的菜单容器 import android.view.ContextMenu; -// 安卓按键事件类:封装物理按键/软键盘按键的事件信息,用于监听回车、删除键 import android.view.KeyEvent; -// 安卓菜单项类:上下文菜单中的单个选项对象 import android.view.MenuItem; import android.view.MenuItem.OnMenuItemClickListener; -// 安卓触摸事件类:封装屏幕触摸的坐标、动作类型,用于重写触摸定位光标逻辑 import android.view.MotionEvent; -// 安卓原生编辑框:本自定义控件的父类,继承所有原生EditText基础能力 +import android.view.inputmethod.InputMethodManager; import android.widget.EditText; -// 小米便签资源类:引用项目中的字符串、图片等资源文件,此处核心用链接菜单的文本资源 import net.micode.notes.R; -// Java集合类:HashMap存储链接协议与菜单文本的映射关系,实现协议与文案解耦 import java.util.HashMap; import java.util.Map; /** - * 小米便签 编辑页核心自定义EditText控件 - * 核心定位:继承原生Android EditText,基于便签编辑业务做深度定制扩展,无侵入式修改原生逻辑 - * 核心设计原则:重写系统回调方法实现定制化需求,所有扩展逻辑均通过回调接口解耦给上层Activity处理 - * 核心扩展能力(六大核心职责): - * 1. 定制化按键交互:重写回车/删除键逻辑,适配多编辑框联动的核心业务; - * 2. 精准触摸光标定位:修复原生触摸光标偏移问题,优化富文本编辑的触摸体验; - * 3. 富文本链接专属处理:长按识别文本中的链接,弹出自定义上下文菜单并支持一键跳转; - * 4. 焦点状态联动回调:编辑框获焦/失焦时,根据文本是否为空回调上层控制功能按钮显隐; - * 5. 多编辑框索引管理:维护自身在编辑框列表中的索引,支撑增删联动逻辑; - * 6. 完整兼容原生能力:所有重写方法最终都会调用父类实现,保留原生EditText全部功能。 + * 富文本编辑框 + *

+ * 该类继承自EditText,实现了富文本编辑的功能,包括加粗、斜体、下划线、删除线、 + * 文本颜色、背景颜色、字号大小等功能。它还实现了链接检测和处理功能。 */ public class NoteEditText extends EditText { - // 日志打印的TAG标识:固定值,便于过滤该控件的所有日志信息 private static final String TAG = "NoteEditText"; - - // 核心成员变量:当前编辑框在【多编辑框列表】中的索引值 - // 作用:用于向Activity回调增删操作时,标识当前操作的编辑框位置,核心支撑多框联动 private int mIndex; - - // 核心成员变量:删除键按下瞬间的光标起始位置 - // 作用:在onKeyDown中记录、onKeyUp中校验,判断是否触发「删除当前编辑框」的业务逻辑 private int mSelectionStartBeforeDelete; - // ========== 常量区:文本链接的协议头(Scheme)常量 ========== - // 定义安卓原生支持的三大核心链接协议前缀,用于匹配文本中的URLSpan链接类型 - private static final String SCHEME_TEL = "tel:"; // 电话链接协议头 → 匹配电话号码链接 - private static final String SCHEME_HTTP = "http:"; // 网页链接协议头 → 匹配http/https网页链接 - private static final String SCHEME_EMAIL = "mailto:";// 邮箱链接协议头 → 匹配邮件发送链接 + private static final String SCHEME_TEL = "tel:" ; + private static final String SCHEME_HTTP = "http:" ; + private static final String SCHEME_EMAIL = "mailto:" ; - /** - * 静态不可变映射表:链接协议头(Scheme) → 上下文菜单文本的资源ID - * 设计模式:静态代码块初始化的常量映射,全局唯一,避免多次创建对象造成内存浪费 - * 核心作用:根据识别到的链接协议,自动匹配对应的菜单文本,实现协议与文案的解耦管理 - */ private static final Map sSchemaActionResMap = new HashMap(); static { - // 初始化映射关系:协议头 → 对应的菜单文本资源ID - sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel); // 电话链接 → 展示「拨打电话」 - sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web); // 网页链接 → 展示「打开网页」 - sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email); // 邮箱链接 → 展示「发送邮件」 + sSchemaActionResMap.put(SCHEME_TEL, R.string.note_link_tel); + sSchemaActionResMap.put(SCHEME_HTTP, R.string.note_link_web); + sSchemaActionResMap.put(SCHEME_EMAIL, R.string.note_link_email); } /** - * 编辑框状态变化 回调接口【核心解耦设计】 - * 设计思想:将所有业务逻辑(增删编辑框、控制显隐)完全抽离到接口中,由上层NoteEditActivity实现 - * 核心价值:该控件只负责「触发事件」,不负责「处理事件」,彻底解耦UI控件与业务逻辑,符合单一职责 + * Call by the {@link NoteEditActivity} to delete or add edit text */ public interface OnTextViewChangeListener { /** - * 删除当前编辑框的回调方法 - * 触发时机:按下删除键 + 光标在文本起始位 + 当前编辑框不是第一个编辑框 - * @param index 当前触发删除的编辑框索引 - * @param text 当前编辑框中的文本内容,供上层做数据保存 + * Delete current edit text when {@link KeyEvent#KEYCODE_DEL} happens + * and the text is null */ void onEditTextDelete(int index, String text); /** - * 新增编辑框的回调方法 - * 触发时机:按下回车键时,自动分割文本并回调上层新增编辑框 - * @param index 新增编辑框应该插入的目标索引(当前索引+1) - * @param text 回车光标后的分割文本,作为新增编辑框的初始化内容 + * Add edit text after current edit text when {@link KeyEvent#KEYCODE_ENTER} + * happen */ void onEditTextEnter(int index, String text); /** - * 文本/焦点状态变化的回调方法 - * 触发时机:编辑框获焦/失焦、文本内容变化时 - * @param index 当前编辑框的索引 - * @param hasText 当前编辑框是否有文本内容 → true显示功能按钮,false隐藏功能按钮 + * Hide or show item option when text change */ void onTextChange(int index, boolean hasText); } - // 成员变量:回调接口的实例对象,由上层Activity通过set方法注入 private OnTextViewChangeListener mOnTextViewChangeListener; - /** - * 构造方法一:代码中手动创建控件时调用(无布局属性) - * @param context 应用上下文对象,必传参数 - */ public NoteEditText(Context context) { super(context, null); - mIndex = 0; // 默认索引为0,代表首个编辑框,后续可通过setIndex重新赋值 + mIndex = 0; } - /** - * 对外提供的设置方法:为当前编辑框绑定在列表中的索引值 - * @param index 多编辑框列表中的位置索引 - */ public void setIndex(int index) { mIndex = index; } - /** - * 对外提供的设置方法:注入状态变化的回调监听器 - * @param listener 实现了OnTextViewChangeListener接口的实例(上层Activity) - */ public void setOnTextViewChangeListener(OnTextViewChangeListener listener) { mOnTextViewChangeListener = listener; } - /** - * 构造方法二:布局xml中引用该控件时自动调用(带布局属性) - * 安卓自定义View的标准构造方法,适配布局文件中的属性配置 - * @param context 应用上下文 - * @param attrs 布局xml中配置的控件属性集 - */ public NoteEditText(Context context, AttributeSet attrs) { super(context, attrs, android.R.attr.editTextStyle); } - /** - * 构造方法三:带默认样式的构造方法,适配主题样式定制场景 - * 安卓自定义View的完整构造方法,满足所有创建场景,无业务逻辑,仅做父类调用 - * @param context 应用上下文 - * @param attrs 布局属性集 - * @param defStyle 默认样式资源ID - */ public NoteEditText(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); + // TODO Auto-generated constructor stub } - /** - * 重写触摸事件处理方法:核心优化【触摸光标精准定位】 - * 原生EditText痛点:触摸文本时光标位置容易偏移,尤其富文本编辑时体验差 - * 本方法核心逻辑:将触摸的屏幕坐标,精准转换为文本的行列偏移量,手动设置光标位置 - * @param event 触摸事件对象,包含触摸坐标、动作类型等信息 - * @return boolean 事件消费标记:返回父类处理结果,不拦截原生触摸逻辑 - */ @Override public boolean onTouchEvent(MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: // 只处理「按下」动作,是触摸的起始事件 - // 步骤1:计算触摸点的【文本相对坐标】- 修正内边距与滚动偏移的影响 - int x = (int) event.getX(); - int y = (int) event.getY(); - x -= getTotalPaddingLeft(); // 减去左侧总内边距,得到文本区域内的X坐标 - y -= getTotalPaddingTop(); // 减去顶部总内边距,得到文本区域内的Y坐标 - x += getScrollX(); // 加上横向滚动偏移,适配文本横向滚动的场景 - y += getScrollY(); // 加上纵向滚动偏移,适配文本纵向滚动的场景 - - // 步骤2:通过文本布局对象,将坐标转换为文本的行列信息 - Layout layout = getLayout(); // 获取当前编辑框的文本布局管理器 - int line = layout.getLineForVertical(y); // 根据Y坐标获取触摸到的文本行号 - int off = layout.getOffsetForHorizontal(line, x); // 根据行号+X坐标获取该行的字符偏移量 - - // 步骤3:手动设置光标到精准的触摸位置,核心修复原生偏移问题 - Selection.setSelection(getText(), off); - break; + // 确保获得焦点,无论是否有内容 + if (event.getAction() == MotionEvent.ACTION_DOWN) { + if (!hasFocus()) { + requestFocus(); + } + } + + // 调用父类方法处理事件 + boolean handled = super.onTouchEvent(event); + + // 处理点击事件 + if (event.getAction() == MotionEvent.ACTION_DOWN) { + // 无论是否有layout,都确保光标在正确位置 + setSelection(getText().length()); + + // 显示软键盘 + Context context = getContext(); + if (context != null) { + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT); + } + } } - // 必须调用父类方法:保留原生的滑动、长按、双击等所有触摸逻辑,只做增量优化 - return super.onTouchEvent(event); + + return handled; } - /** - * 重写按键按下事件:预处理核心按键(回车/删除),做事件标记,不做业务逻辑处理 - * 核心设计:按键按下时「只记录状态」,按键抬起时「执行业务逻辑」,符合安卓按键事件的处理规范 - * 原因:按下事件可能有长按重复触发,抬起事件只会触发一次,保证业务逻辑只执行一次 - * @param keyCode 按键码:标识按下的是哪个按键(回车/删除/字母等) - * @param event 按键事件对象,包含按键动作、重复次数等信息 - * @return boolean 事件消费标记:false=不消费,交给onKeyUp处理;true=消费,拦截后续处理 - */ @Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_ENTER: - // 回车键按下:有监听器则返回false,交给onKeyUp处理分割+新增逻辑,无则走原生逻辑 if (mOnTextViewChangeListener != null) { return false; } break; case KeyEvent.KEYCODE_DEL: - // 删除键按下:记录此时的光标起始位置,供onKeyUp校验是否触发删除编辑框逻辑 mSelectionStartBeforeDelete = getSelectionStart(); break; default: break; } - // 调用父类方法:处理所有其他按键的原生逻辑,无侵入式修改 return super.onKeyDown(keyCode, event); } - /** - * 重写按键抬起事件:核心业务逻辑处理入口【重中之重】 - * 该方法是本控件的核心,集中处理「回车新增编辑框」「删除当前编辑框」两大核心业务逻辑 - * 所有业务逻辑执行完毕后,均调用父类方法,保证原生按键功能不受影响 - * @param keyCode 按键码 - * @param event 按键事件对象 - * @return boolean 事件消费标记:true=已处理业务逻辑,拦截原生行为;false=未处理,走原生逻辑 - */ @Override public boolean onKeyUp(int keyCode, KeyEvent event) { switch(keyCode) { case KeyEvent.KEYCODE_DEL: - // ========== 删除键抬起:处理「删除编辑框」核心逻辑 ========== if (mOnTextViewChangeListener != null) { - // 触发条件【三重校验,缺一不可】: - // 1. 光标在文本的起始位置(0);2. 当前编辑框不是第一个编辑框(索引≠0);3. 有回调监听器 if (0 == mSelectionStartBeforeDelete && mIndex != 0) { - // 回调上层Activity:执行删除当前编辑框的逻辑,并传递当前文本内容 mOnTextViewChangeListener.onEditTextDelete(mIndex, getText().toString()); - return true; // 消费事件,避免原生删除逻辑执行,防止文本内容错乱 + return true; } } else { - // 无监听器时打印日志,便于调试排查问题 Log.d(TAG, "OnTextViewChangeListener was not seted"); } break; case KeyEvent.KEYCODE_ENTER: - // ========== 回车键抬起:处理「分割文本+新增编辑框」核心逻辑 ========== if (mOnTextViewChangeListener != null) { - int selectionStart = getSelectionStart(); // 获取当前光标位置 - // 步骤1:分割文本 → 光标后的内容作为新增编辑框的初始化文本 + int selectionStart = getSelectionStart(); String text = getText().subSequence(selectionStart, length()).toString(); - // 步骤2:更新当前编辑框文本 → 只保留光标前的内容,完成文本分割 setText(getText().subSequence(0, selectionStart)); - // 步骤3:回调上层Activity → 在当前索引+1的位置新增编辑框,并传递分割后的文本 mOnTextViewChangeListener.onEditTextEnter(mIndex + 1, text); } else { Log.d(TAG, "OnTextViewChangeListener was not seted"); @@ -280,58 +193,33 @@ public class NoteEditText extends EditText { default: break; } - // 调用父类方法:处理其他按键的原生抬起逻辑,兼容所有原生功能 return super.onKeyUp(keyCode, event); } - /** - * 重写焦点变化事件:处理「焦点联动功能按钮显隐」的业务逻辑 - * 触发时机:编辑框获取焦点/失去焦点时自动调用 - * 核心逻辑:失焦时如果文本为空,回调隐藏功能按钮;其他情况回调显示功能按钮 - * 设计亮点:只关心「失焦+空文本」的特殊场景,其他场景统一回调显示,逻辑简洁高效 - * @param focused 当前是否获取到焦点:true=获焦,false=失焦 - * @param direction 焦点变化的方向,系统传递的参数,无实际业务用途 - * @param previouslyFocusedRect 上一个焦点控件的矩形区域,系统传递的参数 - */ @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { if (mOnTextViewChangeListener != null) { - // 核心判断:失焦 并且 文本为空 → 回调隐藏按钮;否则回调显示按钮 if (!focused && TextUtils.isEmpty(getText())) { mOnTextViewChangeListener.onTextChange(mIndex, false); } else { mOnTextViewChangeListener.onTextChange(mIndex, true); } } - // 调用父类方法:执行原生的焦点变化逻辑,如光标显示/隐藏、背景色变化等 super.onFocusChanged(focused, direction, previouslyFocusedRect); } - /** - * 重写上下文菜单创建方法:核心实现「富文本链接的自定义菜单」功能 - * 触发时机:长按编辑框中的文本时,系统自动调用该方法创建上下文菜单 - * 核心业务逻辑:识别长按区域是否包含链接 → 是则添加自定义链接菜单 → 点击菜单触发链接跳转 - * 原生兼容:识别不到链接时,自动调用父类方法创建默认菜单(复制/粘贴/全选等) - * @param menu 系统传入的上下文菜单容器,用于添加自定义菜单项 - */ @Override protected void onCreateContextMenu(ContextMenu menu) { - // 前置判断:只有文本是【富文本(Spanned)】类型时,才处理链接识别逻辑 if (getText() instanceof Spanned) { - // 步骤1:获取当前光标选中的文本区域,兼容正选/反选两种情况 int selStart = getSelectionStart(); int selEnd = getSelectionEnd(); - int min = Math.min(selStart, selEnd); // 选中区域的起始位置 - int max = Math.max(selStart, selEnd); // 选中区域的结束位置 - // 步骤2:从选中区域中提取所有的URLSpan链接对象 + int min = Math.min(selStart, selEnd); + int max = Math.max(selStart, selEnd); + final URLSpan[] urls = ((Spanned) getText()).getSpans(min, max, URLSpan.class); - - // 步骤3:仅处理「单个链接」的场景,避免多链接冲突,保证菜单的唯一性 if (urls.length == 1) { - int defaultResId = 0; // 初始化菜单文本资源ID - - // 步骤4:遍历协议映射表,匹配当前链接的协议类型,获取对应的菜单文本 + int defaultResId = 0; for(String schema: sSchemaActionResMap.keySet()) { if(urls[0].getURL().indexOf(schema) >= 0) { defaultResId = sSchemaActionResMap.get(schema); @@ -339,23 +227,217 @@ public class NoteEditText extends EditText { } } - // 兜底处理:未匹配到已知协议时,显示「其他链接」的默认文本 if (defaultResId == 0) { defaultResId = R.string.note_link_other; } - // 步骤5:向菜单中添加自定义选项,并绑定点击事件 menu.add(0, 0, 0, defaultResId).setOnMenuItemClickListener( new OnMenuItemClickListener() { public boolean onMenuItemClick(MenuItem item) { - // 核心逻辑:触发URLSpan原生的点击事件 → 自动跳转对应链接(拨打电话/打开网页/发送邮件) + // goto a new intent urls[0].onClick(NoteEditText.this); - return true; // 消费菜单点击事件,避免后续处理 + return true; } }); } } - // 必须调用父类方法:无链接时创建原生默认菜单(复制、粘贴、剪切、选择全部等),完整兼容原生功能 super.onCreateContextMenu(menu); } -} \ No newline at end of file + + /** + * 设置文字加粗 + */ + public void setBold() { + toggleStyle(Typeface.BOLD); + } + + /** + * 设置文字斜体 + */ + public void setItalic() { + toggleStyle(Typeface.ITALIC); + } + + /** + * 设置文字粗斜体 + */ + public void setBoldItalic() { + applyStyle(Typeface.BOLD_ITALIC); + } + + /** + * 设置文字正常 + */ + public void setNormal() { + applyStyle(Typeface.NORMAL); + } + + /** + * 切换下划线 + */ + public void toggleUnderline() { + toggleSpan(UnderlineSpan.class); + } + + /** + * 切换删除线 + */ + public void toggleStrikethrough() { + toggleSpan(StrikethroughSpan.class); + } + + /** + * 设置文字颜色 + */ + public void setTextColor(int color) { + applySpan(new ForegroundColorSpan(color)); + } + + /** + * 设置文字背景颜色 + */ + public void setTextBackgroundColor(int color) { + applySpan(new BackgroundColorSpan(color)); + } + + /** + * 设置字号大小 + */ + public void setTextSize(float size) { + // 将字号稍微放大一些 + float adjustedSize = size * 1.1f; + applySpan(new RelativeSizeSpan(adjustedSize)); + } + + /** + * 设置文字左对齐 + */ + public void setAlignLeft() { + setGravity(android.view.Gravity.LEFT); + } + + /** + * 设置文字居中对齐 + */ + public void setAlignCenter() { + setGravity(android.view.Gravity.CENTER); + } + + /** + * 设置文字右对齐 + */ + public void setAlignRight() { + setGravity(android.view.Gravity.RIGHT); + } + + /** + * 设置文字两端对齐 + */ + public void setAlignJustify() { + setGravity(android.view.Gravity.FILL_HORIZONTAL); + } + + /** + * 应用样式 + */ + private void applyStyle(int style) { + Spannable spannable = getText(); + if (spannable == null) return; + + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start == end) return; + + StyleSpan[] spans = spannable.getSpans(start, end, StyleSpan.class); + for (StyleSpan span : spans) { + spannable.removeSpan(span); + } + + spannable.setSpan(new StyleSpan(style), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + setSelection(start, end); + } + + /** + * 切换样式 + */ + private void toggleStyle(int style) { + Spannable spannable = getText(); + if (spannable == null) return; + + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start == end) return; + + // 检查当前样式 + boolean hasStyle = false; + StyleSpan[] spans = spannable.getSpans(start, end, StyleSpan.class); + for (StyleSpan span : spans) { + if (span.getStyle() == style) { + hasStyle = true; + spannable.removeSpan(span); + } + } + + if (!hasStyle) { + // 没有该样式,添加 + spannable.setSpan(new StyleSpan(style), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + setSelection(start, end); + } + + /** + * 应用Span + */ + private void applySpan(Object span) { + Spannable spannable = getText(); + if (spannable == null) return; + + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start == end) return; + + spannable.setSpan(span, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + setSelection(start, end); + } + + /** + * 切换Span + */ + private void toggleSpan(Class spanClass) { + Spannable spannable = getText(); + if (spannable == null) return; + + int start = getSelectionStart(); + int end = getSelectionEnd(); + if (start == end) return; + + // 检查当前是否有该Span + boolean hasSpan = false; + try { + Object[] spans = spannable.getSpans(start, end, spanClass); + for (Object span : spans) { + hasSpan = true; + spannable.removeSpan(span); + } + + if (!hasSpan) { + // 没有该Span,添加 + try { + // 创建该Span的实例 + Object newSpan = spanClass.getDeclaredConstructor().newInstance(); + spannable.setSpan(newSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } catch (Exception e) { + // 捕获所有异常,避免崩溃 + e.printStackTrace(); + } + } + } catch (Exception e) { + // 捕获所有异常,避免崩溃 + e.printStackTrace(); + } + + setSelection(start, end); + } +} + diff --git a/src/Notes-master/src/net/micode/notes/ui/NoteItemData.java b/src/Notes-master/src/net/micode/notes/ui/NoteItemData.java index 653b7fc..8c236df 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NoteItemData.java +++ b/src/Notes-master/src/net/micode/notes/ui/NoteItemData.java @@ -14,372 +14,258 @@ * limitations under the License. */ -// 包声明:归属小米便签UI模块,为列表页提供标准化的便签/文件夹数据模型,封装数据库游标解析逻辑 package net.micode.notes.ui; -// 安卓上下文:提供系统服务访问能力,用于联系人查询、内容解析器获取 import android.content.Context; -// 安卓数据库游标:承载数据库查询结果集,是本类核心的数据解析源 import android.database.Cursor; -// 安卓文本工具类:提供字符串判空、文本处理等安全操作方法 import android.text.TextUtils; -// 联系人数据工具类:根据手机号匹配系统通讯录中的联系人姓名 import net.micode.notes.data.Contact; -// 便签核心常量类:定义便签类型、文件夹ID、字段名等全局常量 import net.micode.notes.data.Notes; -// 便签数据库列名常量:数据库表的字段名称枚举,避免硬编码字符串 import net.micode.notes.data.Notes.NoteColumns; -// 便签数据工具类:提供通话记录手机号查询、数据格式化等通用能力 import net.micode.notes.tool.DataUtils; + /** - * 便签列表项核心数据模型类【小米便签核心类】 - * 核心定位:MVC架构中的Model层,纯数据载体,无任何UI渲染逻辑 - * 核心职责: - * 1. 定义数据库查询的投影字段,精准指定查询列,规避全表查询的性能损耗; - * 2. 从数据库Cursor中解析并封装所有便签/文件夹的业务数据; - * 3. 对通话记录类便签做专属适配,自动关联手机号和联系人名称; - * 4. 智能判断列表项的位置状态,为UI背景样式渲染提供数据支撑; - * 5. 提供只读的getter方法和业务判断方法,对外屏蔽数据细节,保证数据安全; - * 设计原则:单一职责 + 完全封装 + 只读数据 + 业务内聚,解耦性极强 + * 便签列表数据传输对象 + *

+ * 该类是专为列表页(NotesListActivity)设计的轻量级数据传输对象, + * 用于从Cursor中提取数据并驱动列表渲染。它包含了便签的基本信息, + * 如ID、类型、内容摘要、修改时间、背景颜色等。 */ public class NoteItemData { - /** - * 数据库查询投影数组:指定本次查询需要返回的数据库列 - * 投影设计原则:按需查询,只获取业务需要的字段,减少内存占用和IO开销 - * 字段覆盖:便签/文件夹的基础属性、时间属性、关联属性、扩展属性四大类 - */ - static final String[] PROJECTION = new String[]{ - NoteColumns.ID, // 0: 便签/文件夹的唯一主键ID - NoteColumns.ALERTED_DATE, // 1: 提醒时间戳(0代表无提醒) - NoteColumns.BG_COLOR_ID, // 2: 背景色ID,用于列表项背景着色 - NoteColumns.CREATED_DATE, // 3: 创建时间戳,UTC毫秒值 - NoteColumns.HAS_ATTACHMENT, // 4: 是否包含附件 0-无 1-有(图片/音频等) - NoteColumns.MODIFIED_DATE, // 5: 最后修改时间戳,列表页优先展示该时间 - NoteColumns.NOTES_COUNT, // 6: 文件夹内包含的便签数量,仅文件夹类型有效 - NoteColumns.PARENT_ID, // 7: 父文件夹ID,根目录为Notes.ID_ROOT_FOLDER - NoteColumns.SNIPPET, // 8: 便签内容摘要/文件夹名称,短文本展示用 - NoteColumns.TYPE, // 9: 数据类型,区分便签/文件夹/系统项 - NoteColumns.WIDGET_ID, // 10: 关联的桌面小部件ID,无效则为默认值 - NoteColumns.WIDGET_TYPE // 11: 关联小部件尺寸类型 2x/4x + static final String [] PROJECTION = new String [] { + NoteColumns.ID, + NoteColumns.ALERTED_DATE, + NoteColumns.BG_COLOR_ID, + NoteColumns.CREATED_DATE, + NoteColumns.HAS_ATTACHMENT, + NoteColumns.MODIFIED_DATE, + NoteColumns.NOTES_COUNT, + NoteColumns.PARENT_ID, + NoteColumns.SNIPPET, + NoteColumns.TITLE, + NoteColumns.TYPE, + NoteColumns.WIDGET_ID, + NoteColumns.WIDGET_TYPE, + NoteColumns.PINNED, + NoteColumns.SORT_ORDER, + NoteColumns.LOCKED, + NoteColumns.PUBLIC, }; - /** - * 投影数组列索引常量:与PROJECTION数组字段一一对应 - * 设计目的:彻底避免硬编码数字索引,提升代码可读性+可维护性 - * 核心价值:修改投影数组字段顺序时,仅需同步修改此处索引,无需改动业务解析代码 - */ - private static final int ID_COLUMN = 0; - private static final int ALERTED_DATE_COLUMN = 1; - private static final int BG_COLOR_ID_COLUMN = 2; - private static final int CREATED_DATE_COLUMN = 3; - private static final int HAS_ATTACHMENT_COLUMN = 4; - private static final int MODIFIED_DATE_COLUMN = 5; - private static final int NOTES_COUNT_COLUMN = 6; - private static final int PARENT_ID_COLUMN = 7; - private static final int SNIPPET_COLUMN = 8; - private static final int TYPE_COLUMN = 9; - private static final int WIDGET_ID_COLUMN = 10; - private static final int WIDGET_TYPE_COLUMN = 11; - - // ===================== 核心业务数据字段 - 与投影数组一一映射 ===================== - // 访问规则:全部私有,仅通过getter方法访问,无setter,数据只读,保证一致性 - private long mId; // 便签/文件夹唯一ID,数据库主键 - private long mAlertDate; // 提醒时间戳,>0表示该便签设置了提醒 - private int mBgColorId; // 背景色ID,对应预设的颜色值,列表项渲染用 - private long mCreatedDate; // 便签创建时间戳 - private boolean mHasAttachment; // 是否包含附件,数据库int转Java布尔值,贴合业务语义 - private long mModifiedDate; // 最后修改时间戳,列表页展示的核心时间字段 - private int mNotesCount; // 文件夹内便签数量,仅TYPE_FOLDER类型有效 - private long mParentId; // 父文件夹ID,通话记录便签固定为通话记录文件夹ID - private String mSnippet; // 便签纯文本摘要/文件夹名称,已清理勾选标记 - private int mType; // 数据类型:Notes.TYPE_NOTE/文件夹/系统项 - private int mWidgetId; // 关联桌面小部件ID,无效则为INVALID_APPWIDGET_ID - private int mWidgetType; // 关联小部件尺寸类型,2x/4x两种规格 - - // ===================== 通话记录专属扩展字段 ===================== - private String mName; // 通话记录联系人姓名,无则显示手机号 - private String mPhoneNumber; // 通话记录对应的手机号,仅通话便签有值 - - // ===================== 列表位置状态字段 - 纯UI渲染支撑 ===================== - // 作用:标记当前项在列表中的位置,用于适配不同的背景样式(圆角/分割线/边距等) - private boolean mIsLastItem; // 是否为列表最后一条数据 - private boolean mIsFirstItem; // 是否为列表第一条数据 - private boolean mIsOnlyOneItem; // 是否为列表中唯一的一条数据 - private boolean mIsOneNoteFollowingFolder; // 文件夹后的唯一一条便签项 - private boolean mIsMultiNotesFollowingFolder;// 文件夹后的第一条便签(后续还有更多项) - - /** - * 唯一构造方法:私有化核心初始化逻辑,从Cursor解析所有数据并完成对象初始化 - * 设计特点:全参构造,一次性完成所有数据赋值,无空构造,保证对象完整性 - * @param context 应用上下文,用于获取内容解析器、查询联系人信息,不可为空 - * @param cursor 数据库查询结果游标,已移动到目标行,不可为空/已关闭 - */ + public static final int ID_COLUMN = 0; + private static final int ALERTED_DATE_COLUMN = 1; + private static final int BG_COLOR_ID_COLUMN = 2; + private static final int CREATED_DATE_COLUMN = 3; + private static final int HAS_ATTACHMENT_COLUMN = 4; + private static final int MODIFIED_DATE_COLUMN = 5; + private static final int NOTES_COUNT_COLUMN = 6; + private static final int PARENT_ID_COLUMN = 7; + private static final int SNIPPET_COLUMN = 8; + private static final int TITLE_COLUMN = 9; + private static final int TYPE_COLUMN = 10; + private static final int WIDGET_ID_COLUMN = 11; + private static final int WIDGET_TYPE_COLUMN = 12; + private static final int PINNED_COLUMN = 13; + private static final int SORT_ORDER_COLUMN = 14; + private static final int LOCKED_COLUMN = 15; + private static final int PUBLIC_COLUMN = 16; + + private long mId; + private long mAlertDate; + private int mBgColorId; + private long mCreatedDate; + private boolean mHasAttachment; + private long mModifiedDate; + private int mNotesCount; + private long mParentId; + private String mSnippet; + private String mTitle; + private int mType; + private int mWidgetId; + private int mWidgetType; + private boolean mPinned; + private int mSortOrder; + private boolean mLocked; + private boolean mPublic; + private String mName; + private String mPhoneNumber; + + private boolean mIsLastItem; + private boolean mIsFirstItem; + private boolean mIsOnlyOneItem; + private boolean mIsOneNoteFollowingFolder; + private boolean mIsMultiNotesFollowingFolder; + public NoteItemData(Context context, Cursor cursor) { - // 第一步:解析数据库核心字段,与投影数组索引一一对应,基础数据初始化 mId = cursor.getLong(ID_COLUMN); mAlertDate = cursor.getLong(ALERTED_DATE_COLUMN); mBgColorId = cursor.getInt(BG_COLOR_ID_COLUMN); mCreatedDate = cursor.getLong(CREATED_DATE_COLUMN); - // 数据库存储为int(0/1),转换为业务语义更清晰的boolean类型 - mHasAttachment = cursor.getInt(HAS_ATTACHMENT_COLUMN) > 0; + mHasAttachment = (cursor.getInt(HAS_ATTACHMENT_COLUMN) > 0) ? true : false; mModifiedDate = cursor.getLong(MODIFIED_DATE_COLUMN); mNotesCount = cursor.getInt(NOTES_COUNT_COLUMN); mParentId = cursor.getLong(PARENT_ID_COLUMN); - // 解析摘要文本,并清理勾选框标记标签,仅保留纯文本内容,优化展示效果 mSnippet = cursor.getString(SNIPPET_COLUMN); mSnippet = mSnippet.replace(NoteEditActivity.TAG_CHECKED, "").replace( NoteEditActivity.TAG_UNCHECKED, ""); + mTitle = cursor.getString(TITLE_COLUMN); mType = cursor.getInt(TYPE_COLUMN); mWidgetId = cursor.getInt(WIDGET_ID_COLUMN); mWidgetType = cursor.getInt(WIDGET_TYPE_COLUMN); + mPinned = (cursor.getInt(PINNED_COLUMN) > 0) ? true : false; + mSortOrder = cursor.getInt(SORT_ORDER_COLUMN); + mLocked = (cursor.getInt(LOCKED_COLUMN) > 0) ? true : false; + mPublic = (cursor.getInt(PUBLIC_COLUMN) > 0) ? true : false; - // 第二步:通话记录便签专属解析逻辑,仅父文件夹为通话记录文件夹时触发 mPhoneNumber = ""; if (mParentId == Notes.ID_CALL_RECORD_FOLDER) { - // 根据便签ID查询关联的通话手机号 mPhoneNumber = DataUtils.getCallNumberByNoteId(context.getContentResolver(), mId); - // 手机号非空时,查询系统通讯录匹配联系人姓名 if (!TextUtils.isEmpty(mPhoneNumber)) { mName = Contact.getContact(context, mPhoneNumber); - // 无匹配联系人时,手机号作为联系人名称兜底展示 if (mName == null) { mName = mPhoneNumber; } } } - // 第三步:空值兜底处理,防止空指针异常,生产级代码必备容错逻辑 if (mName == null) { mName = ""; } - - // 第四步:自动判断当前项在列表中的位置状态,为UI渲染提供数据支撑 checkPostion(cursor); } - /** - * 私有核心方法:解析当前Cursor所在行的列表位置状态,初始化位置标记字段 - * 核心作用:为列表项的背景样式提供精准的状态标记,不同位置展示不同样式 - * 设计亮点:Cursor位置移动后必回位,防止游标位置错乱导致后续解析异常 - * @param cursor 数据库游标,已定位到当前数据行 - */ private void checkPostion(Cursor cursor) { - // 初始化基础位置状态:首项、尾项、唯一项 - mIsLastItem = cursor.isLast(); - mIsFirstItem = cursor.isFirst(); - mIsOnlyOneItem = cursor.getCount() == 1; - - // 初始化文件夹子项状态为默认值false + mIsLastItem = cursor.isLast() ? true : false; + mIsFirstItem = cursor.isFirst() ? true : false; + mIsOnlyOneItem = (cursor.getCount() == 1); mIsMultiNotesFollowingFolder = false; mIsOneNoteFollowingFolder = false; - // 核心业务判断:仅处理【普通便签】且【非列表首项】的场景 if (mType == Notes.TYPE_NOTE && !mIsFirstItem) { - // 记录当前游标位置,用于后续回位,防止位置丢失 - int currentPosition = cursor.getPosition(); - // 游标上移一行,判断上一项是否为【文件夹】或【系统项】 + int position = cursor.getPosition(); if (cursor.moveToPrevious()) { - int prevItemType = cursor.getInt(TYPE_COLUMN); - if (prevItemType == Notes.TYPE_FOLDER || prevItemType == Notes.TYPE_SYSTEM) { - // 上一项是文件夹/系统项 → 当前项是文件夹的子项第一条 - if (cursor.getCount() > currentPosition + 1) { - // 后续还有更多数据 → 标记为多子项的第一条 + if (cursor.getInt(TYPE_COLUMN) == Notes.TYPE_FOLDER + || cursor.getInt(TYPE_COLUMN) == Notes.TYPE_SYSTEM) { + if (cursor.getCount() > (position + 1)) { mIsMultiNotesFollowingFolder = true; } else { - // 后续无数据 → 标记为文件夹后的唯一子项 mIsOneNoteFollowingFolder = true; } } - // 游标回位到原位置,必须执行,否则会导致后续数据解析错位 if (!cursor.moveToNext()) { - // 回位失败时主动抛异常,快速定位问题,避免隐性bug throw new IllegalStateException("cursor move to previous but can't move back"); } } } } - /** - * 业务判断方法:当前项是否为【文件夹后的唯一便签项】 - * @return true-是 false-否 - */ public boolean isOneFollowingFolder() { return mIsOneNoteFollowingFolder; } - /** - * 业务判断方法:当前项是否为【文件夹后的第一条便签(后续还有项)】 - * @return true-是 false-否 - */ public boolean isMultiFollowingFolder() { return mIsMultiNotesFollowingFolder; } - /** - * 位置判断方法:当前项是否为列表最后一项 - * @return true-是 false-否 - */ public boolean isLast() { return mIsLastItem; } - /** - * 数据访问方法:获取通话记录的联系人名称(无则返回手机号) - * @return 联系人姓名/手机号 空则返回空字符串 - */ public String getCallName() { return mName; } - /** - * 位置判断方法:当前项是否为列表第一项 - * @return true-是 false-否 - */ public boolean isFirst() { return mIsFirstItem; } - /** - * 位置判断方法:当前项是否为列表中唯一的一项 - * @return true-是 false-否 - */ public boolean isSingle() { return mIsOnlyOneItem; } - /** - * 基础数据访问:获取便签/文件夹唯一ID - * @return 数据库主键ID,long类型 - */ public long getId() { return mId; } - /** - * 基础数据访问:获取提醒时间戳 - * @return 提醒时间UTC毫秒值,0表示无提醒 - */ public long getAlertDate() { return mAlertDate; } - /** - * 基础数据访问:获取创建时间戳 - * @return 创建时间UTC毫秒值 - */ public long getCreatedDate() { return mCreatedDate; } - /** - * 业务判断方法:当前便签是否包含附件(图片/音频等) - * @return true-有附件 false-无附件 - */ public boolean hasAttachment() { return mHasAttachment; } - /** - * 基础数据访问:获取最后修改时间戳(列表页优先展示) - * @return 修改时间UTC毫秒值 - */ public long getModifiedDate() { return mModifiedDate; } - /** - * 基础数据访问:获取背景色ID - * @return 预设的颜色ID值,用于列表项背景渲染 - */ public int getBgColorId() { return mBgColorId; } - /** - * 基础数据访问:获取父文件夹ID - * @return 父文件夹主键ID,通话记录为固定常量ID - */ public long getParentId() { return mParentId; } - /** - * 基础数据访问:获取文件夹内便签数量 - * @return 数量值,仅文件夹类型有效,便签类型返回无意义值 - */ public int getNotesCount() { return mNotesCount; } - /** - * 兼容适配方法:等价于getParentId(),适配外部旧调用逻辑 - * 设计目的:向下兼容,无侵入式修改原有业务代码 - * @return 父文件夹主键ID - */ - public long getFolderId() { + public long getFolderId () { return mParentId; } - /** - * 基础数据访问:获取数据类型 - * @return 类型值:Notes.TYPE_NOTE/文件夹/系统项 - */ public int getType() { return mType; } - /** - * 基础数据访问:获取关联小部件类型 - * @return 小部件尺寸类型 2x/4x - */ - public int getWidgetType() { - return mWidgetType; - } - - /** - * 基础数据访问:获取关联小部件ID - * @return 小部件ID,无效则为INVALID_APPWIDGET_ID - */ - public int getWidgetId() { - return mWidgetId; - } - - /** - * 基础数据访问:获取清理后的纯文本摘要/文件夹名称 - * @return 无勾选标记的纯文本短内容 - */ + public int getWidgetType() { + return mWidgetType; + } + + public int getWidgetId() { + return mWidgetId; + } + public String getSnippet() { return mSnippet; } - /** - * 核心业务判断方法:当前便签是否设置了提醒 - * 封装细节:屏蔽「时间戳判0」的技术细节,对外提供业务语义 - * @return true-有提醒 false-无提醒 - */ public boolean hasAlert() { - return mAlertDate > 0; + return (mAlertDate > 0); } - /** - * 核心业务判断方法:当前项是否为【通话记录便签】 - * 判断条件:父文件夹是通话记录文件夹 + 手机号非空,双重校验保证准确性 - * @return true-通话记录便签 false-普通便签/文件夹 - */ public boolean isCallRecord() { - return mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber); + return (mParentId == Notes.ID_CALL_RECORD_FOLDER && !TextUtils.isEmpty(mPhoneNumber)); + } + + public boolean isPinned() { + return mPinned; + } + + public int getSortOrder() { + return mSortOrder; + } + + public boolean isLocked() { + return mLocked; + } + + public boolean isPublic() { + return mPublic; + } + + public String getTitle() { + return mTitle; } - /** - * 静态工具方法:无需创建对象,快速从Cursor获取便签类型 - * 性能优化点:避免为了单个字段创建完整对象实例,适配列表适配器高频调用场景 - * 对外赋能:给外部适配器提供轻量级的类型查询能力 - * @param cursor 数据库游标,已定位到目标行 - * @return 便签类型值 - */ public static int getNoteType(Cursor cursor) { return cursor.getInt(TYPE_COLUMN); } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/NotesListActivity.java b/src/Notes-master/src/net/micode/notes/ui/NotesListActivity.java index 462e30a..4234f16 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NotesListActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/NotesListActivity.java @@ -22,6 +22,7 @@ import android.app.Dialog; import android.appwidget.AppWidgetManager; import android.content.AsyncQueryHandler; import android.content.ContentResolver; +import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; @@ -49,25 +50,37 @@ import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnCreateContextMenuListener; import android.view.View.OnTouchListener; +import android.view.KeyEvent; import android.view.inputmethod.InputMethodManager; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; import android.widget.AdapterView.OnItemLongClickListener; import android.widget.Button; import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.GridView; +import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.ListView; import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.database.sqlite.SQLiteDatabase; import net.micode.notes.R; +import net.micode.notes.data.Messages; import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.NoteColumns; +import net.micode.notes.data.NotesDatabaseHelper; +import net.micode.notes.data.Users; import net.micode.notes.gtask.remote.GTaskSyncService; import net.micode.notes.model.WorkingNote; import net.micode.notes.tool.BackupUtils; import net.micode.notes.tool.DataUtils; import net.micode.notes.tool.ResourceParser; +import net.micode.notes.tool.UserManager; import net.micode.notes.ui.NotesListAdapter.AppWidgetAttribute; import net.micode.notes.widget.NoteWidgetProvider_2x; import net.micode.notes.widget.NoteWidgetProvider_4x; @@ -76,159 +89,177 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.util.ArrayList; import java.util.HashSet; /** - * 小米便签 核心主页面 - 便签列表页 + * 便签列表活动 *

- * 继承安卓原生{Activity},是小米便签应用的**核心门户页面**,隶属于MVC架构的UI层业务页面,承载应用所有核心交互能力; - * 核心设计定位:作为应用的入口与中枢,统一管理便签/文件夹的所有核心业务操作,封装完整的页面状态管理、事件分发、异步数据处理、交互反馈逻辑, - * 是连接便签编辑页、设置页、小组件、数据工具类的核心桥梁; - * 核心业务职责: - * 1. 数据展示:分页展示便签列表、文件夹列表、通话记录专属文件夹及内容,适配不同文件夹层级的视图切换; - * 2. 便签操作:新建普通便签、打开已有便签编辑、批量删除便签、批量移动便签到指定文件夹,适配同步/非同步模式的差异化删除逻辑; - * 3. 文件夹管理:创建文件夹、重命名文件夹、删除文件夹、进入文件夹查看子项,校验文件夹名称唯一性,维护文件夹层级关系; - * 4. 多选模式:完整支撑便签的批量操作,包含全选/取消全选、选中状态维护、选中数量统计、多选菜单适配; - * 5. 初始化引导:首次打开应用自动创建引导便签,展示应用核心功能与使用说明; - * 6. 数据交互:异步执行数据库查询/更新/删除,避免主线程阻塞,保证页面流畅性;导出便签为文本文件,支持本地备份; - * 7. 同步适配:区分同步模式/非同步模式,同步模式下删除便签移至回收站,支持手动触发/取消同步,跳转同步设置页; - * 8. 小组件联动:便签数据变更后,自动发送广播更新关联的2x/4x桌面小组件内容,保证数据一致性; - * 9. 交互优化:处理新建按钮透明区域的事件透传、长按震动反馈、软键盘的显隐控制、上下文菜单的创建与销毁; - * 技术实现特点: - * - 基于{AsyncQueryHandler}实现数据库异步操作,解决主线程阻塞问题,提升页面滑动与操作流畅度; - * - 基于{NotesListAdapter}实现列表数据与视图的解耦,统一管理列表项的渲染与选中状态; - * - 通过枚举{ListEditState}维护页面状态,精准控制不同状态下的视图展示与功能开放; - * - 大量使用{AsyncTask}处理耗时操作(删除/导出),保证UI线程不被阻塞; - * - 完整的异常边界处理,包含空数据校验、无效ID过滤、日志输出,提升应用稳定性; - * - 精细化的交互体验优化,包含触摸事件分发、震动反馈、Toast提示、对话框确认,兼顾功能完整性与用户体验。 - *

+ * 该类是便签列表的核心界面,负责显示便签列表、处理便签的选择、删除、移动等操作。 + * 它使用AsyncQueryHandler异步加载数据,支持便签的批量操作和小部件的管理。 + *

+ * [2025 新特性]: 支持基于AI颜色分类的智能排序功能。 */ public class NotesListActivity extends Activity implements OnClickListener, OnItemLongClickListener { - // ===================== 异步查询任务标识常量 - 核心区分不同类型的异步数据库查询 ===================== - /** 异步查询Token:查询指定文件夹下的所有便签/子文件夹数据,是页面最核心的查询任务 */ private static final int FOLDER_NOTE_LIST_QUERY_TOKEN = 0; - /** 异步查询Token:查询可用于移动便签的目标文件夹列表,仅在批量移动便签时触发 */ + private static final int FOLDER_LIST_QUERY_TOKEN = 1; - // ===================== 文件夹上下文菜单操作ID - 区分文件夹的右键菜单功能 ===================== - /** 上下文菜单ID:删除当前文件夹 */ private static final int MENU_FOLDER_DELETE = 0; - /** 上下文菜单ID:进入当前文件夹查看子项 */ + private static final int MENU_FOLDER_VIEW = 1; - /** 上下文菜单ID:修改当前文件夹的名称 */ + private static final int MENU_FOLDER_CHANGE_NAME = 2; - // ===================== 偏好设置存储键值 - 持久化标记应用初始化状态 ===================== - /** SharedPreference存储Key:标记是否已为新用户创建首次使用引导便签,避免重复创建 */ private static final String PREFERENCE_ADD_INTRODUCTION = "net.micode.notes.introduction"; - // ===================== 页面核心状态枚举 - 控制当前页面展示的内容类型与功能边界 ===================== - /** - * 列表编辑状态枚举:完整定义页面的三种核心状态,不同状态对应不同的视图展示、菜单加载、功能开放 - */ private enum ListEditState { - NOTE_LIST, // 根目录普通便签列表状态【默认】:展示所有文件夹+根目录便签+通话记录文件夹 - SUB_FOLDER, // 子文件夹状态:展示指定文件夹下的便签,显示标题栏,隐藏部分根目录功能 - CALL_RECORD_FOLDER// 通话记录文件夹专属状态:展示通话记录便签,隐藏新建按钮,专属菜单适配 + NOTE_LIST, SUB_FOLDER, CALL_RECORD_FOLDER, TRASH_FOLDER }; - // ===================== 页面核心成员变量 ===================== - /** 当前页面的状态标识:控制视图展示与功能逻辑分支,默认值为NOTE_LIST根目录状态 */ private ListEditState mState; - /** 异步数据库查询处理器:核心工具类,所有数据库的增删改查均通过该类异步执行,避免主线程阻塞 */ + private BackgroundQueryHandler mBackgroundQueryHandler; - /** 列表核心适配器:绑定数据库Cursor数据与ListView,管理列表项渲染、选中状态、数据统计,页面核心数据桥梁 */ + private NotesListAdapter mNotesListAdapter; - /** 核心列表控件:承载所有便签/文件夹的展示,是页面的核心视图容器 */ + private ListView mNotesListView; - /** 新建便签按钮:页面底部悬浮按钮,点击快速创建新便签,包含透明区域事件透传逻辑 */ + private GridView mNotesGridView; + private Button mAddNewNote; - /** 事件分发标记:标记是否需要将新建按钮的触摸事件透传给下方的ListView,处理透明区域点击 */ + private boolean mDispatch; - /** 触摸原始坐标Y:记录新建按钮触摸事件的初始Y坐标,用于事件透传时的坐标校准 */ + private int mOriginY; - /** 触摸分发坐标Y:记录事件透传后的目标Y坐标,保证ListView接收的坐标准确性 */ + private int mDispatchY; - /** 标题栏文本控件:子文件夹/通话记录文件夹状态下显示,展示当前文件夹名称,根目录状态下隐藏 */ + private TextView mTitleBar; - /** 当前选中的文件夹ID:标记用户当前浏览的文件夹层级,默认值为根文件夹ID,用于数据库查询条件拼接 */ + private long mCurrentFolderId; - /** 内容解析器:应用与ContentProvider的核心交互工具,所有数据库操作均通过该类执行 */ + private ContentResolver mContentResolver; - /** 多选模式回调处理器:封装多选模式的创建、菜单适配、选中状态变更、批量操作逻辑,是多选功能的核心 */ + private ModeCallback mModeCallBack; - /** 日志输出标签:页面所有日志的统一标识,便于日志过滤与问题定位 */ + + // 搜索相关组件 + private EditText mSearchEditText; + private ImageView mSearchImageView; + private ImageView mCancelImageView; + private LinearLayout mSearchBar; + private String mSearchQuery; + private boolean mIsSearching; + + // 显示模式常量 + private static final int DISPLAY_MODE_LIST = 0; + private static final int DISPLAY_MODE_GRID = 1; + + // 当前显示模式 + private int mDisplayMode = DISPLAY_MODE_LIST; + + // 排序方式常量 + private static final String SORT_BY_CREATE_DATE = NoteColumns.CREATED_DATE + " DESC"; + private static final String SORT_BY_MODIFIED_DATE = NoteColumns.MODIFIED_DATE + " DESC"; + + // 当前排序方式 + private String mCurrentSortOrder = SORT_BY_MODIFIED_DATE; + private static final String TAG = "NotesListActivity"; - /** 列表滚动速率常量:自定义列表滚动行为的速率参数,优化滚动流畅度 */ + public static final int NOTES_LISTVIEW_SCROLL_RATE = 30; - /** 当前聚焦的数据项:记录长按选中的便签/文件夹数据,用于多选模式初始化、文件夹上下文菜单操作 */ + private NoteItemData mFocusNoteDataItem; - // ===================== 数据库查询条件常量 - 区分根文件夹与普通文件夹的查询逻辑 ===================== - /** 普通文件夹查询条件:精准查询指定父文件夹下的所有子项,适用于子文件夹层级的数据加载 */ + // 拖拽相关变量 + private boolean mIsDragging = false; + private int mDragStartPosition = -1; + private int mDragCurrentPosition = -1; + private View mDraggingView = null; + private int mDraggingViewHeight = 0; + private float mDragStartY = 0; + private float mDragOffsetY = 0; + private NoteItemData[] mDragTempData = null; // 拖拽过程中的临时数据列表 + private static final String NORMAL_SELECTION = NoteColumns.PARENT_ID + "=?"; - /** 根文件夹查询条件:特殊复合条件,排除系统文件夹 + 仅展示有内容的通话记录文件夹,保证根目录视图整洁 */ - private static final String ROOT_FOLDER_SELECTION = "(" + NoteColumns.TYPE + "<>" - + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?)" + " OR (" + + private static final String ROOT_FOLDER_SELECTION = "( " + NoteColumns.TYPE + "<>" + + Notes.TYPE_SYSTEM + " AND " + NoteColumns.PARENT_ID + "=?) OR ( " + NoteColumns.ID + "=" + Notes.ID_CALL_RECORD_FOLDER + " AND " - + NoteColumns.NOTES_COUNT + ">0)"; + + NoteColumns.NOTES_COUNT + ">0)" + " OR ( " + + NoteColumns.ID + "=" + Notes.ID_TRASH_FOLER + ")"; // 始终显示回收站,不管里面有没有便签 - // ===================== Activity跳转请求码 - 区分不同场景的页面跳转与结果回调 ===================== - /** 请求码:打开已有便签编辑的跳转标识,用于onActivityResult区分回调场景 */ private final static int REQUEST_CODE_OPEN_NODE = 102; - /** 请求码:新建便签的跳转标识,用于onActivityResult区分回调场景 */ private final static int REQUEST_CODE_NEW_NODE = 103; - /** - * Activity生命周期 - 页面创建阶段【核心初始化】 - * 执行时机:页面首次启动时调用,仅执行一次 - * 核心逻辑:加载页面布局、初始化所有核心资源与控件、绑定适配器与监听器、执行首次使用引导逻辑,为页面就绪做准备 - * @param savedInstanceState 页面重建时的状态数据,当前页面未使用该参数 - */ @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setContentView(R.layout.note_list); // 加载页面核心布局 - initResources(); // 初始化所有控件、适配器、监听器、状态变量,核心初始化方法 - setAppInfoFromRawRes(); // 执行首次使用引导,创建引导便签(仅首次打开应用触发) + setContentView(R.layout.note_list); + initResources(); + + /** + * Insert an introduction when user firstly use this application + */ + setAppInfoFromRawRes(); + + // 加载保存的背景图片 + loadSavedBackground(); } - /** - * Activity生命周期 - 页面返回结果回调 - * 执行时机:从{NoteEditActivity}编辑/新建便签返回当前页面时触发 - * 核心逻辑:判断返回结果为成功时,清空列表适配器的Cursor数据,触发重新查询,保证列表数据与最新编辑结果一致 - * @param requestCode 页面跳转时传入的请求码,区分新建/打开便签场景 - * @param resultCode 目标页面返回的结果码,RESULT_OK表示操作成功 - * @param data 目标页面返回的Intent数据,当前页面未使用该参数 - */ @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - // 新建/打开便签操作成功返回,刷新列表数据 if (resultCode == RESULT_OK && (requestCode == REQUEST_CODE_OPEN_NODE || requestCode == REQUEST_CODE_NEW_NODE)) { mNotesListAdapter.changeCursor(null); } else { + // 处理背景更换结果 + Log.d(TAG, "onActivityResult called, requestCode: " + requestCode + ", resultCode: " + resultCode); + + if (resultCode == RESULT_OK) { + String imagePath = null; + + switch (requestCode) { + case 1: // 从相册选择 + if (data != null && data.getData() != null) { + // 获取图片路径 + imagePath = getPathFromUri(data.getData()); + Log.d(TAG, "Image path from gallery: " + imagePath); + } + break; + case 2: // 拍照 + if (data != null && data.getExtras() != null) { + // 获取拍照的图片 + android.graphics.Bitmap bitmap = (android.graphics.Bitmap) data.getExtras().get("data"); + // 保存图片到本地 + imagePath = saveBitmap(bitmap); + Log.d(TAG, "Image path from camera: " + imagePath); + } + break; + } + + if (imagePath != null) { + // 保存图片路径到SharedPreferences + SharedPreferences sharedPreferences = getSharedPreferences("background", MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString("background_path", imagePath); + editor.apply(); + + // 设置背景 + setBackground(imagePath); + } + } super.onActivityResult(requestCode, resultCode, data); } } - /** - * 核心初始化引导逻辑 - 首次使用应用创建引导便签 - * 设计意图:降低新用户使用门槛,自动展示应用核心功能与使用说明,提升用户体验 - * 核心规则:通过SharedPreference持久化标记,仅在应用首次打开时执行一次,避免重复创建引导便签 - * 实现逻辑:读取raw目录下的引导文本文件 → 封装为WorkingNote数据模型 → 保存到数据库 → 标记已创建引导 - */ private void setAppInfoFromRawRes() { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(this); - // 已创建过引导便签,直接返回,避免重复执行 if (!sp.getBoolean(PREFERENCE_ADD_INTRODUCTION, false)) { StringBuilder sb = new StringBuilder(); InputStream in = null; try { - // 打开raw资源目录下的引导文本文件,该文件存储应用使用说明 in = getResources().openRawResource(R.raw.introduction); if (in != null) { - // 流式读取文本内容,拼接为完整的引导文案 InputStreamReader isr = new InputStreamReader(in); BufferedReader br = new BufferedReader(isr); char [] buf = new char[1024]; @@ -244,22 +275,20 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt e.printStackTrace(); return; } finally { - // 关闭输入流,释放系统资源,避免内存泄漏 if(in != null) { try { in.close(); } catch (IOException e) { + // TODO Auto-generated catch block e.printStackTrace(); } } } - // 创建空的工作便签模型:归属根文件夹、无小组件关联、红色背景(醒目) WorkingNote note = WorkingNote.createEmptyNote(this, Notes.ID_ROOT_FOLDER, AppWidgetManager.INVALID_APPWIDGET_ID, Notes.TYPE_WIDGET_INVALIDE, ResourceParser.RED); - note.setWorkingText(sb.toString()); // 为便签赋值引导文本内容 - // 保存便签到数据库成功后,标记已创建引导,持久化到偏好设置 + note.setWorkingText(sb.toString()); if (note.saveNote()) { sp.edit().putBoolean(PREFERENCE_ADD_INTRODUCTION, true).commit(); } else { @@ -269,226 +298,512 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * Activity生命周期 - 页面启动阶段 - * 执行时机:页面创建完成后、用户可见前调用,每次页面恢复可见时都会执行 - * 核心逻辑:触发异步数据库查询,加载当前文件夹下的便签/文件夹列表数据,保证页面展示最新数据 - */ @Override protected void onStart() { super.onStart(); - startAsyncNotesListQuery(); // 启动异步查询,加载列表核心数据 + startAsyncNotesListQuery(); } - /** - * 页面核心初始化方法 - 集中管理所有资源与控件的初始化 - * 设计意图:将所有初始化逻辑集中到该方法,避免分散在多个生命周期方法中,便于维护与扩展 - * 核心逻辑:获取内容解析器、初始化异步查询处理器、绑定核心控件、设置列表适配器与监听器、初始化状态变量 - */ private void initResources() { - mContentResolver = this.getContentResolver(); // 获取应用的内容解析器,用于数据库操作 - mBackgroundQueryHandler = new BackgroundQueryHandler(this.getContentResolver()); // 初始化异步查询处理器 - mCurrentFolderId = Notes.ID_ROOT_FOLDER; // 默认选中根文件夹,展示根目录数据 - mNotesListView = (ListView) findViewById(R.id.notes_list); // 绑定核心列表控件 - - // 为列表添加底部空白视图,避免列表数据为空时ListView塌陷,保证页面布局完整性 + mContentResolver = this.getContentResolver(); + mBackgroundQueryHandler = new BackgroundQueryHandler(this.getContentResolver()); + mCurrentFolderId = Notes.ID_ROOT_FOLDER; + mNotesListView = (ListView) findViewById(R.id.notes_list); mNotesListView.addFooterView(LayoutInflater.from(this).inflate(R.layout.note_list_footer, null), null, false); - mNotesListView.setOnItemClickListener(new OnListItemClickListener()); // 设置列表项点击监听器 - mNotesListView.setOnItemLongClickListener(this); // 设置列表项长按监听器 - - mNotesListAdapter = new NotesListAdapter(this); // 初始化列表核心适配器 - mNotesListView.setAdapter(mNotesListAdapter); // 将适配器绑定到列表控件 - - // 绑定新建便签按钮,设置点击与触摸监听器 + mNotesListView.setOnItemClickListener(new OnListItemClickListener()); + mNotesListView.setOnItemLongClickListener(this); + mNotesListView.setOnTouchListener(new OnDragTouchListener()); + + // 初始化宫格视图 + mNotesGridView = (GridView) findViewById(R.id.notes_grid); + mNotesGridView.setOnItemClickListener(new OnListItemClickListener()); + mNotesGridView.setOnItemLongClickListener(this); + mNotesGridView.setOnTouchListener(new OnDragTouchListener()); + + mNotesListAdapter = new NotesListAdapter(this); + mNotesListView.setAdapter(mNotesListAdapter); mAddNewNote = (Button) findViewById(R.id.btn_new_note); - mAddNewNote.setOnClickListener(this); // 点击事件:创建新便签 - mAddNewNote.setOnTouchListener(new NewNoteOnTouchListener()); // 触摸事件:处理透明区域事件透传 - - // 初始化事件分发相关变量,默认关闭事件透传 + mAddNewNote.setOnClickListener(this); + mAddNewNote.setOnTouchListener(new NewNoteOnTouchListener()); mDispatch = false; mDispatchY = 0; mOriginY = 0; - mTitleBar = (TextView) findViewById(R.id.tv_title_bar); // 绑定标题栏控件 - mState = ListEditState.NOTE_LIST; // 初始化页面状态为根目录普通便签列表 - mModeCallBack = new ModeCallback(); // 初始化多选模式回调处理器 + mTitleBar = (TextView) findViewById(R.id.tv_title_bar); + mState = ListEditState.NOTE_LIST; + mModeCallBack = new ModeCallback(); + + // 初始化搜索相关组件 + mSearchBar = (LinearLayout) findViewById(R.id.search_bar); + mSearchEditText = (EditText) findViewById(R.id.et_search); + mSearchImageView = (ImageView) findViewById(R.id.iv_search); + mCancelImageView = (ImageView) findViewById(R.id.iv_cancel); + + // 初始化省略号按钮 + ImageView mMenuMoreImageView = (ImageView) findViewById(R.id.iv_menu_more); + + // 设置搜索按钮点击事件 + mSearchImageView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + startSearch(); + } + }); + + // 设置取消按钮点击事件 + mCancelImageView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + cancelSearch(); + } + }); + + // 设置省略号按钮点击事件 + mMenuMoreImageView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + showMoreOptionsMenu(v); + } + }); + + // 设置搜索框文本变化监听 + mSearchEditText.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + mSearchQuery = s.toString(); + } + + @Override + public void afterTextChanged(Editable s) { + } + }); + + // 设置搜索框回车键监听 + mSearchEditText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEARCH || + (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER && event.getAction() == KeyEvent.ACTION_DOWN)) { + performSearch(); + return true; + } + return false; + } + }); + + mIsSearching = false; } - /** - * 内部核心回调类 - 列表多选模式完整处理器 - * 实现{ListView.MultiChoiceModeListener}:监听多选模式的创建、准备、销毁、选中状态变更; - * 实现{OnMenuItemClickListener}:监听多选菜单的点击事件,处理批量删除/移动操作; - * 核心职责:封装多选模式的所有逻辑,包括菜单加载、选中状态维护、全选/取消全选、批量操作执行、视图状态切换, - * 是页面批量操作功能的核心实现类,与列表适配器联动完成所有多选相关逻辑。 - */ private class ModeCallback implements ListView.MultiChoiceModeListener, OnMenuItemClickListener { - /** 多选模式顶部下拉菜单:封装全选/取消全选功能,展示当前选中数量,提升交互便捷性 */ private DropdownMenu mDropDownMenu; - /** 系统多选模式ActionMode:控制顶部操作栏的创建与销毁,是多选模式的核心载体 */ private ActionMode mActionMode; - /** 批量移动菜单项:根据当前场景控制显隐,通话记录文件夹下/无用户文件夹时隐藏该菜单 */ private MenuItem mMoveMenu; - /** - * 多选模式创建回调 - 多选功能初始化入口 - * 执行时机:用户长按便签触发多选模式时调用,仅执行一次 - * 核心逻辑:加载多选操作菜单、设置菜单点击监听、适配移动菜单显隐、创建自定义顶部视图、初始化下拉菜单、 - * 切换适配器多选状态、隐藏新建按钮,完成多选模式的所有初始化配置 - * @param mode 当前创建的多选模式ActionMode对象 - * @param menu 多选模式的操作菜单对象 - * @return boolean true表示创建成功,展示多选菜单 - */ public boolean onCreateActionMode(ActionMode mode, Menu menu) { - // 加载多选模式的核心菜单资源,包含删除、移动两个核心操作 getMenuInflater().inflate(R.menu.note_list_options, menu); - menu.findItem(R.id.delete).setOnMenuItemClickListener(this); // 设置删除菜单点击监听 + menu.findItem(R.id.delete).setOnMenuItemClickListener(this); + menu.findItem(R.id.pin).setOnMenuItemClickListener(this); + menu.findItem(R.id.unpin).setOnMenuItemClickListener(this); + menu.findItem(R.id.lock).setOnMenuItemClickListener(this); + menu.findItem(R.id.unlock).setOnMenuItemClickListener(this); + menu.findItem(R.id.make_public).setOnMenuItemClickListener(this); + menu.findItem(R.id.make_private).setOnMenuItemClickListener(this); mMoveMenu = menu.findItem(R.id.move); + + // 根据便签状态显示/隐藏锁和解锁菜单项 + MenuItem lockItem = menu.findItem(R.id.lock); + MenuItem unlockItem = menu.findItem(R.id.unlock); + + if (mFocusNoteDataItem != null && mFocusNoteDataItem.isLocked()) { + // 便签已锁定,显示解锁选项,隐藏加锁选项 + lockItem.setVisible(false); + unlockItem.setVisible(true); + } else { + // 便签未锁定,显示加锁选项,隐藏解锁选项 + lockItem.setVisible(true); + unlockItem.setVisible(false); + } - // 移动菜单显隐规则:通话记录文件夹下的便签不可移动 + 无用户文件夹时无需移动,两种场景均隐藏 - if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER - || DataUtils.getUserFolderCount(mContentResolver) == 0) { + // 根据便签状态显示/隐藏开放和取消开放菜单项 + MenuItem makePublicItem = menu.findItem(R.id.make_public); + MenuItem makePrivateItem = menu.findItem(R.id.make_private); + + boolean isPublic = mFocusNoteDataItem != null && mFocusNoteDataItem.isPublic(); + makePublicItem.setVisible(!isPublic); + makePrivateItem.setVisible(isPublic); + + // 在回收站中,显示"恢复"选项而不是"移动"选项 + if (mState == ListEditState.TRASH_FOLDER) { + // 隐藏"移动"选项 mMoveMenu.setVisible(false); + // 添加"恢复"选项 + MenuItem restoreMenu = menu.add(0, 100, 0, "恢复"); + restoreMenu.setOnMenuItemClickListener(this); } else { - mMoveMenu.setVisible(true); - mMoveMenu.setOnMenuItemClickListener(this); // 设置移动菜单点击监听 + // 不在回收站中,使用原来的逻辑 + if (mFocusNoteDataItem.getParentId() == Notes.ID_CALL_RECORD_FOLDER + || DataUtils.getUserFolderCount(mContentResolver) == 0) { + mMoveMenu.setVisible(false); + } else { + mMoveMenu.setVisible(true); + mMoveMenu.setOnMenuItemClickListener(this); + } } + // 根据便签是否已置顶,显示或隐藏置顶/取消置顶菜单项 + boolean isPinned = mFocusNoteDataItem.isPinned(); + menu.findItem(R.id.pin).setVisible(!isPinned); + menu.findItem(R.id.unpin).setVisible(isPinned); + + // 添加"发送给"菜单项 + MenuItem sendToMenu = menu.add(0, 101, 0, "发送给"); + sendToMenu.setOnMenuItemClickListener(this); + mActionMode = mode; - mNotesListAdapter.setChoiceMode(true); // 通知适配器切换到多选模式,展示勾选框 - mNotesListView.setLongClickable(false); // 多选模式下禁用长按,避免重复触发多选逻辑 - mAddNewNote.setVisibility(View.GONE); // 隐藏新建按钮,避免干扰多选操作 + mNotesListAdapter.setChoiceMode(true); + mNotesListView.setLongClickable(false); + mAddNewNote.setVisibility(View.GONE); - // 加载自定义多选模式顶部视图,包含全选/取消全选的下拉菜单,提升交互体验 View customView = LayoutInflater.from(NotesListActivity.this).inflate( R.layout.note_list_dropdown_menu, null); mode.setCustomView(customView); - // 初始化下拉菜单,绑定按钮与菜单资源 mDropDownMenu = new DropdownMenu(NotesListActivity.this, (Button) customView.findViewById(R.id.selection_menu), R.menu.note_list_dropdown); - // 设置下拉菜单点击监听,处理全选/取消全选逻辑 mDropDownMenu.setOnDropdownMenuItemClickListener(new PopupMenu.OnMenuItemClickListener(){ public boolean onMenuItemClick(MenuItem item) { - // 切换全选状态:已全选则取消全选,未全选则执行全选 mNotesListAdapter.selectAll(!mNotesListAdapter.isAllSelected()); - updateMenu(); // 同步更新菜单标题与按钮文本 + updateMenu(); return true; } + }); return true; } - /** - * 多选菜单更新方法 - 同步选中状态与菜单展示内容 - * 核心逻辑:获取当前选中的便签数量,更新下拉菜单标题为「已选择 X 项」;根据全选状态,切换按钮文本为「全选」/「取消全选」, - * 保证菜单展示内容与实际选中状态一致,提升交互准确性。 - */ private void updateMenu() { int selectedCount = mNotesListAdapter.getSelectedCount(); - // 格式化选中数量文本,更新下拉菜单标题 + // Update dropdown menu String format = getResources().getString(R.string.menu_select_title, selectedCount); mDropDownMenu.setTitle(format); - // 根据全选状态切换按钮文本 MenuItem item = mDropDownMenu.findItem(R.id.action_select_all); if (item != null) { if (mNotesListAdapter.isAllSelected()) { item.setChecked(true); - item.setTitle(R.string.menu_deselect_all); // 已全选 → 显示取消全选 + item.setTitle(R.string.menu_deselect_all); } else { item.setChecked(false); - item.setTitle(R.string.menu_select_all); // 未全选 → 显示全选 + item.setTitle(R.string.menu_select_all); } } } - /** - * 多选模式准备回调 - 预留扩展接口 - * 执行时机:多选模式创建后、每次菜单刷新前调用,当前页面无额外逻辑,返回false即可 - */ public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + // TODO Auto-generated method stub return false; } - /** - * 多选菜单项点击回调 - 预留扩展接口 - * 注:实际的菜单点击逻辑在{onMenuItemClickListener}中实现,当前方法仅返回false - */ public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + // TODO Auto-generated method stub return false; } - /** - * 多选模式销毁回调 - 恢复页面默认状态 - * 执行时机:用户退出多选模式(点击返回键/完成按钮)时调用 - * 核心逻辑:通知适配器退出多选模式、恢复列表项长按功能、重新显示新建便签按钮,将页面恢复到普通浏览状态 - */ public void onDestroyActionMode(ActionMode mode) { - mNotesListAdapter.setChoiceMode(false); // 适配器退出多选模式,隐藏勾选框 - mNotesListView.setLongClickable(true); // 恢复列表项长按功能,允许再次触发多选 - mAddNewNote.setVisibility(View.VISIBLE); // 重新显示新建便签按钮 + mNotesListAdapter.setChoiceMode(false); + mNotesListView.setLongClickable(true); + mAddNewNote.setVisibility(View.VISIBLE); } - /** - * 主动结束多选模式方法 - 对外提供的退出接口 - * 核心作用:批量操作完成后(删除/移动),主动关闭多选模式,恢复页面默认状态,提升交互连贯性 - */ public void finishActionMode() { mActionMode.finish(); } - /** - * 列表项选中状态变更回调 - 多选模式核心状态同步 - * 执行时机:用户勾选/取消勾选列表项时调用 - * 核心逻辑:将选中状态同步到列表适配器,更新适配器的选中状态映射,然后刷新菜单展示内容, - * 保证视图展示的选中状态与实际数据一致。 - * @param mode 当前的多选模式ActionMode对象 - * @param position 发生状态变更的列表项位置 - * @param id 列表项对应的便签ID - * @param checked 变更后的选中状态:true=选中,false=取消选中 - */ public void onItemCheckedStateChanged(ActionMode mode, int position, long id, - boolean checked) { - mNotesListAdapter.setCheckedItem(position, checked); // 同步选中状态到适配器 - updateMenu(); // 刷新菜单标题与按钮文本 + boolean checked) { + mNotesListAdapter.setCheckedItem(position, checked); + updateMenu(); } - /** - * 多选菜单点击事件处理 - 批量操作核心执行逻辑 - * 核心职责:处理「删除」「移动」两个核心批量操作,包含前置校验、用户确认、业务逻辑执行, - * 是多选模式的核心功能出口。 - * @param item 被点击的菜单项对象 - * @return boolean true表示事件已处理完成 - */ public boolean onMenuItemClick(MenuItem item) { - // 前置校验:无选中项时,弹出Toast提示用户选择便签,避免无效操作 if (mNotesListAdapter.getSelectedCount() == 0) { Toast.makeText(NotesListActivity.this, getString(R.string.menu_select_none), Toast.LENGTH_SHORT).show(); return true; } + // 获取选中的便签ID列表 + HashSet selectedIds = mNotesListAdapter.getSelectedItemIds(); + switch (item.getItemId()) { + case R.id.pin: + // 处理置顶逻辑 + try { + // 获取当前最小的SORT_ORDER值,用于新置顶的便签 + long currentTime = System.currentTimeMillis(); + + // 更新每个选中的便签 + for (Long id : selectedIds) { + ContentValues pinValues = new ContentValues(); + pinValues.put(NoteColumns.PINNED, 1); + // 设置为当前时间戳的负数,确保最新置顶的便签有最小的SORT_ORDER值,显示在最前面 + pinValues.put(NoteColumns.SORT_ORDER, -currentTime); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + pinValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for pinning id: " + id); + + // 为下一个便签设置更小的SORT_ORDER值 + currentTime--; + } + } catch (Exception e) { + Log.e(TAG, "Error in pin operation: " + e.getMessage()); + e.printStackTrace(); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + break; + case R.id.unpin: + // 处理取消置顶逻辑 + try { + // 更新每个选中的便签 + for (Long id : selectedIds) { + ContentValues unpinValues = new ContentValues(); + unpinValues.put(NoteColumns.PINNED, 0); + // 重置SORT_ORDER为0,确保取消置顶后显示在下面 + unpinValues.put(NoteColumns.SORT_ORDER, 0); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + unpinValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for unpinning id: " + id); + } + } catch (Exception e) { + Log.e(TAG, "Error in unpin operation: " + e.getMessage()); + e.printStackTrace(); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + break; case R.id.delete: - // 批量删除:弹出确认对话框,提示删除数量,用户确认后执行删除逻辑,防止误删 AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); builder.setTitle(getString(R.string.alert_title_delete)); builder.setIcon(android.R.drawable.ic_dialog_alert); - builder.setMessage(getString(R.string.alert_message_delete_notes, - mNotesListAdapter.getSelectedCount())); - builder.setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int which) { - batchDelete(); // 执行异步批量删除逻辑 - } - }); - builder.setNegativeButton(android.R.string.cancel, null); + + if (mState == ListEditState.TRASH_FOLDER) { + // 在回收站中,直接彻底删除 + builder.setMessage(getString(R.string.alert_message_delete_notes, + mNotesListAdapter.getSelectedCount())); + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int which) { + // 直接彻底删除 + DataUtils.batchDeleteNotes(getContentResolver(), mNotesListAdapter.getSelectedItemIds()); + mModeCallBack.finishActionMode(); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + } else { + // 不在回收站中,让用户选择删除方式 + builder.setMessage("请选择删除方式"); + builder.setPositiveButton("移动到回收站", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int which) { + batchDelete(); + } + }); + builder.setNeutralButton("直接删除", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int which) { + // 直接彻底删除 + DataUtils.batchDeleteNotes(getContentResolver(), mNotesListAdapter.getSelectedItemIds()); + mModeCallBack.finishActionMode(); + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + } builder.show(); break; case R.id.move: - // 批量移动:触发目标文件夹查询,展示文件夹选择对话框,用户选择后执行移动逻辑 startQueryDestinationFolders(); break; + case R.id.lock: + // 处理加锁逻辑 + if (!NotesPreferenceActivity.isPasswordSet(NotesListActivity.this)) { + Toast.makeText(NotesListActivity.this, + R.string.preferences_password_empty, + Toast.LENGTH_SHORT).show(); + // 引导用户设置密码 + Intent intent = new Intent(NotesListActivity.this, NotesPreferenceActivity.class); + startActivity(intent); + break; + } + + // 更新每个选中的便签 + for (Long id : selectedIds) { + ContentValues lockValues = new ContentValues(); + lockValues.put(NoteColumns.LOCKED, 1); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + lockValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for locking id: " + id); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + break; + case R.id.unlock: + // 处理解锁逻辑 + if (!NotesPreferenceActivity.isPasswordSet(NotesListActivity.this)) { + // 没有密码直接解锁 + for (Long id : selectedIds) { + ContentValues unlockValues = new ContentValues(); + unlockValues.put(NoteColumns.LOCKED, 0); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + unlockValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for unlocking id: " + id); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + break; + } + + // 显示密码输入对话框 + View passwordView = LayoutInflater.from(NotesListActivity.this).inflate(R.layout.password_input_dialog, null); + final EditText passwordEdit = (EditText) passwordView.findViewById(R.id.password_input); + + AlertDialog.Builder passwordDialogBuilder = new AlertDialog.Builder(NotesListActivity.this); + passwordDialogBuilder.setTitle(R.string.note_lock_password_prompt); + passwordDialogBuilder.setView(passwordView); + + passwordDialogBuilder.setPositiveButton(getString(R.string.preferences_button_confirm), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + String password = passwordEdit.getText().toString(); + + if (password.equals(NotesPreferenceActivity.getPassword(NotesListActivity.this))) { + // 密码正确,解锁便签 + for (Long id : selectedIds) { + ContentValues unlockValues = new ContentValues(); + unlockValues.put(NoteColumns.LOCKED, 0); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + unlockValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for unlocking id: " + id); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + } else { + // 密码错误,显示提示 + Toast.makeText(NotesListActivity.this, R.string.note_lock_password_incorrect, Toast.LENGTH_SHORT).show(); + } + } + }); + + passwordDialogBuilder.setNegativeButton(android.R.string.cancel, null); + passwordDialogBuilder.show(); + break; + case R.id.make_public: + // 处理开放便签逻辑 + try { + // 更新每个选中的便签 + for (Long id : selectedIds) { + ContentValues publicValues = new ContentValues(); + publicValues.put(NoteColumns.PUBLIC, 1); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + publicValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for making public id: " + id); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + } catch (Exception e) { + Log.e(TAG, "Error in make_public operation: " + e.getMessage()); + e.printStackTrace(); + } + break; + case R.id.make_private: + // 处理取消开放便签逻辑 + try { + // 更新每个选中的便签 + for (Long id : selectedIds) { + ContentValues privateValues = new ContentValues(); + privateValues.put(NoteColumns.PUBLIC, 0); + + int updatedRows = mContentResolver.update( + Notes.CONTENT_NOTE_URI, + privateValues, + "_id=?", + new String[]{String.valueOf(id)}); + Log.d(TAG, "Updated " + updatedRows + " rows for making private id: " + id); + } + + // 刷新列表 + mModeCallBack.finishActionMode(); + startAsyncNotesListQuery(); + } catch (Exception e) { + Log.e(TAG, "Error in make_private operation: " + e.getMessage()); + e.printStackTrace(); + } + break; + case 100: + // 恢复选中的便签 + // 恢复便签到原始文件夹 + for (long noteId : selectedIds) { + // 查询原始文件夹ID + Cursor cursor = getContentResolver().query( + ContentUris.withAppendedId(Notes.CONTENT_NOTE_URI, noteId), + new String[]{NoteColumns.ORIGIN_PARENT_ID}, + null, null, null); + if (cursor != null && cursor.moveToFirst()) { + long originParentId = cursor.getLong(0); + cursor.close(); + // 如果原始文件夹ID有效,则恢复到原始文件夹,否则恢复到根文件夹 + long targetFolderId = (originParentId > 0) ? originParentId : Notes.ID_ROOT_FOLDER; + DataUtils.batchMoveToFolder(getContentResolver(), selectedIds, targetFolderId); + break; // 只需要处理一次,因为所有选中的便签都使用相同的targetFolderId + } + } + mModeCallBack.finishActionMode(); + break; + case 101: + // 处理"发送给"功能 + showSendToFriendsDialog(selectedIds); + break; default: return false; } @@ -496,52 +811,47 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * 内部触摸事件监听器 - 新建按钮透明区域事件透传处理器 - * 核心设计意图:新建便签按钮为悬浮半透明样式,包含大量透明区域,用户点击透明区域时,希望事件能透传到下方的ListView, - * 支持列表滚动/点击列表项,提升交互体验,解决「透明区域点击无响应」的问题。 - * 核心逻辑:根据触摸坐标判断是否点击了透明区域 → 若是则校准坐标并将事件分发给ListView → 否则交由按钮默认处理, - * 完美兼容按钮点击与列表交互。 - */ private class NewNoteOnTouchListener implements OnTouchListener { public boolean onTouch(View v, MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: { - // 获取屏幕与按钮尺寸,计算触摸事件的绝对坐标,用于透明区域判断 Display display = getWindowManager().getDefaultDisplay(); int screenHeight = display.getHeight(); int newNoteViewHeight = mAddNewNote.getHeight(); int start = screenHeight - newNoteViewHeight; int eventY = start + (int) event.getY(); - - // 子文件夹状态下,扣除标题栏高度,校准触摸坐标,保证判断准确性 + /** + * Minus TitleBar's height + */ if (mState == ListEditState.SUB_FOLDER) { eventY -= mTitleBar.getHeight(); start -= mTitleBar.getHeight(); } - /** - * 核心透明区域判断逻辑:基于UI设计的像素公式 y=-0.12x+94,判断触摸点是否在按钮的透明区域内 - * 该公式由UI设计稿确定,若按钮背景样式变更,需同步调整该公式 + * HACKME:When click the transparent part of "New Note" button, dispatch + * the event to the list view behind this button. The transparent part of + * "New Note" button could be expressed by formula y=-0.12x+94(Unit:pixel) + * and the line top of the button. The coordinate based on left of the "New + * Note" button. The 94 represents maximum height of the transparent part. + * Notice that, if the background of the button changes, the formula should + * also change. This is very bad, just for the UI designer's strong requirement. */ if (event.getY() < (event.getX() * (-0.12) + 94)) { - // 获取列表最后一个可见项(排除底部footer),判断是否在透明区域范围内 View view = mNotesListView.getChildAt(mNotesListView.getChildCount() - 1 - mNotesListView.getFooterViewsCount()); if (view != null && view.getBottom() > start && (view.getTop() < (start + 94))) { - mOriginY = (int) event.getY(); // 记录原始触摸坐标 - mDispatchY = eventY; // 初始化分发坐标 - event.setLocation(event.getX(), mDispatchY); // 校准事件坐标 - mDispatch = true; // 标记为需要分发事件 - return mNotesListView.dispatchTouchEvent(event); // 将事件分发给ListView + mOriginY = (int) event.getY(); + mDispatchY = eventY; + event.setLocation(event.getX(), mDispatchY); + mDispatch = true; + return mNotesListView.dispatchTouchEvent(event); } } break; } case MotionEvent.ACTION_MOVE: { - // 事件分发状态下,同步移动坐标,继续将触摸事件分发给ListView,支持列表滑动 if (mDispatch) { mDispatchY += (int) event.getY() - mOriginY; event.setLocation(event.getX(), mDispatchY); @@ -550,7 +860,6 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt break; } default: { - // 触摸事件结束(抬起/取消),分发最后一次事件,重置分发标记,恢复默认状态 if (mDispatch) { event.setLocation(event.getX(), mDispatchY); mDispatch = false; @@ -559,53 +868,84 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt break; } } - return false; // 非透明区域点击,交由按钮默认处理(触发新建便签) + return false; } + }; - /** - * 核心异步查询方法 - 加载当前文件夹下的便签/文件夹列表 - * 设计意图:所有列表数据均通过该方法异步查询,避免主线程阻塞,保证页面滑动流畅,是页面数据加载的核心入口 - * 核心逻辑:根据当前文件夹是否为根目录,选择对应的查询条件 → 调用异步查询处理器执行查询 → 查询结果在回调中更新到适配器 - */ + // 开始搜索 + private void startSearch() { + mIsSearching = true; + mTitleBar.setVisibility(View.GONE); + mSearchEditText.setVisibility(View.VISIBLE); + mCancelImageView.setVisibility(View.VISIBLE); + mSearchImageView.setVisibility(View.GONE); + mSearchEditText.requestFocus(); + showSoftInput(); + } + + // 取消搜索 + private void cancelSearch() { + mIsSearching = false; + mSearchQuery = null; + mSearchEditText.setText(""); + mSearchEditText.setVisibility(View.GONE); + mCancelImageView.setVisibility(View.GONE); + mSearchImageView.setVisibility(View.VISIBLE); + mTitleBar.setVisibility(mState == ListEditState.NOTE_LIST ? View.GONE : View.VISIBLE); + hideSoftInput(mSearchEditText); + mNotesListAdapter.setSearchQuery(null); + startAsyncNotesListQuery(); + } + + // 执行搜索 + private void performSearch() { + if (!TextUtils.isEmpty(mSearchQuery)) { + hideSoftInput(mSearchEditText); + mNotesListAdapter.setSearchQuery(mSearchQuery); + startAsyncNotesListQuery(); + } + } + private void startAsyncNotesListQuery() { - // 拼接查询条件:根目录使用特殊复合条件,普通文件夹使用精准匹配条件 - String selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION - : NORMAL_SELECTION; - // 启动异步数据库查询:指定查询标识、查询URI、查询字段、查询条件、条件参数、排序规则 + String selection; + String[] selectionArgs; + + if (mIsSearching && !TextUtils.isEmpty(mSearchQuery)) { + // 搜索模式:查询包含搜索关键词的便签 + selection = NoteColumns.TYPE + "=" + Notes.TYPE_NOTE + " AND " + + NoteColumns.SNIPPET + " LIKE ?"; + String searchQuery = "%" + mSearchQuery + "%"; + selectionArgs = new String[] { searchQuery }; + } else { + // 普通模式:根据当前文件夹查询 + selection = (mCurrentFolderId == Notes.ID_ROOT_FOLDER) ? ROOT_FOLDER_SELECTION + : NORMAL_SELECTION; + selectionArgs = new String[] { String.valueOf(mCurrentFolderId) }; + } + mBackgroundQueryHandler.startQuery(FOLDER_NOTE_LIST_QUERY_TOKEN, null, - Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, new String[] { - String.valueOf(mCurrentFolderId) - }, NoteColumns.TYPE + " DESC," + NoteColumns.MODIFIED_DATE + " DESC"); + Notes.CONTENT_NOTE_URI, NoteItemData.PROJECTION, selection, selectionArgs, + "CASE WHEN " + NoteColumns.ID + " = " + Notes.ID_TRASH_FOLER + " THEN 0 ELSE 1 END ASC, " + + NoteColumns.PINNED + " DESC," + NoteColumns.SORT_ORDER + " ASC," + NoteColumns.TYPE + " DESC," + mCurrentSortOrder); } - /** - * 内部核心异步处理器 - 数据库异步操作完整封装 - * 继承安卓原生{AsyncQueryHandler},核心职责:封装数据库的异步查询、插入、更新、删除操作, - * 将耗时的数据库操作放到子线程执行,查询结果通过回调返回主线程,避免主线程阻塞,是页面流畅性的核心保障。 - * 所有数据库操作均通过该类执行,包含列表数据查询、文件夹列表查询。 - */ private final class BackgroundQueryHandler extends AsyncQueryHandler { public BackgroundQueryHandler(ContentResolver contentResolver) { super(contentResolver); } - /** - * 异步查询完成回调 - 数据库查询结果处理核心方法 - * 执行时机:异步查询任务完成后,由系统自动调用,运行在主线程 - * 核心逻辑:根据查询标识(Token)区分不同的查询任务 → 列表数据查询:更新适配器Cursor → 文件夹列表查询:展示文件夹选择菜单 - * @param token 异步查询任务的标识,区分不同查询类型 - * @param cookie 异步查询的附加参数,当前页面未使用 - * @param cursor 数据库查询结果游标,封装了查询到的所有数据 - */ @Override protected void onQueryComplete(int token, Object cookie, Cursor cursor) { switch (token) { case FOLDER_NOTE_LIST_QUERY_TOKEN: - mNotesListAdapter.changeCursor(cursor); // 更新列表适配器数据,刷新视图 + mNotesListAdapter.changeCursor(cursor); + // 如果当前显示的是宫格模式,确保GridView也更新数据 + if (mDisplayMode == DISPLAY_MODE_GRID) { + mNotesGridView.setAdapter(mNotesListAdapter); + } break; case FOLDER_LIST_QUERY_TOKEN: - // 文件夹列表查询完成,展示文件夹选择对话框,供用户选择目标文件夹 if (cursor != null && cursor.getCount() > 0) { showFolderListMenu(cursor); } else { @@ -618,196 +958,319 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * 文件夹选择对话框展示方法 - 批量移动便签的目标文件夹选择 - * 核心逻辑:将查询到的文件夹列表封装为适配器 → 展示对话框供用户选择 → 用户选择后执行批量移动逻辑 → 提示移动成功 → 退出多选模式 - * @param cursor 数据库查询到的文件夹列表游标,封装了所有可选择的文件夹数据 - */ private void showFolderListMenu(Cursor cursor) { AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(R.string.menu_title_select_folder); // 设置对话框标题 - final FoldersListAdapter adapter = new FoldersListAdapter(this, cursor); // 初始化文件夹列表适配器 + builder.setTitle(R.string.menu_title_select_folder); + final FoldersListAdapter adapter = new FoldersListAdapter(this, cursor); builder.setAdapter(adapter, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { - // 执行批量移动逻辑:将选中的便签移至目标文件夹 DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter.getSelectedItemIds(), adapter.getItemId(which)); - // 弹出Toast提示,展示移动数量与目标文件夹名称,提升用户感知 Toast.makeText( NotesListActivity.this, getString(R.string.format_move_notes_to_folder, mNotesListAdapter.getSelectedCount(), adapter.getFolderName(NotesListActivity.this, which)), Toast.LENGTH_SHORT).show(); - mModeCallBack.finishActionMode(); // 移动完成,退出多选模式 + mModeCallBack.finishActionMode(); } }); - builder.show(); // 显示文件夹选择对话框 + builder.show(); } - /** - * 新建便签核心方法 - 跳转编辑页创建新便签 - * 核心逻辑:构建跳转Intent,标记为「新建/编辑」模式 → 传递当前文件夹ID,保证新便签归属当前文件夹 → 启动编辑页并等待返回结果 - * 设计意图:新便签默认归属用户当前浏览的文件夹,保持文件夹上下文一致性,提升用户体验 - */ private void createNewNote() { Intent intent = new Intent(this, NoteEditActivity.class); - intent.setAction(Intent.ACTION_INSERT_OR_EDIT); // 标记为新建/编辑模式 - intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, mCurrentFolderId); // 传递当前文件夹ID - this.startActivityForResult(intent, REQUEST_CODE_NEW_NODE); // 启动编辑页,接收返回结果 + intent.setAction(Intent.ACTION_INSERT_OR_EDIT); + intent.putExtra(Notes.INTENT_EXTRA_FOLDER_ID, mCurrentFolderId); + this.startActivityForResult(intent, REQUEST_CODE_NEW_NODE); } - /** - * 批量删除便签核心方法 - 异步执行差异化删除逻辑 - * 核心设计:通过{AsyncTask}异步执行删除操作,避免主线程阻塞;区分同步/非同步模式,执行差异化删除逻辑,保证数据安全; - * 删除完成后自动更新关联的桌面小组件,保证数据一致性。 - * 核心规则:非同步模式 → 直接物理删除便签;同步模式 → 将便签移至回收站,支持恢复,防止误删导致数据丢失。 - */ private void batchDelete() { new AsyncTask>() { - /** - * 后台执行方法 - 耗时删除逻辑处理,运行在子线程 - * 核心逻辑:获取选中便签关联的小组件信息 → 执行差异化删除逻辑 → 返回小组件信息供后续更新 - * @param unused 无传入参数 - * @return HashSet 选中便签关联的所有小组件属性集合 - */ protected HashSet doInBackground(Void... unused) { - HashSet widgets = mNotesListAdapter.getSelectedWidget(); - // 非同步模式:直接物理删除选中的便签 - if (!isSyncMode()) { - if (DataUtils.batchDeleteNotes(mContentResolver, mNotesListAdapter - .getSelectedItemIds())) { - } else { - Log.e(TAG, "Delete notes error, should not happens"); + HashSet selectedIds = mNotesListAdapter.getSelectedItemIds(); + HashSet allWidgets = new HashSet<>(); + + // 分离便签和文件夹 + HashSet noteIds = new HashSet<>(); + HashSet folderIds = new HashSet<>(); + + Cursor cursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.ID, NoteColumns.TYPE}, + NoteColumns.ID + " IN (" + TextUtils.join(",", selectedIds) + ")", + null, null); + + if (cursor != null) { + while (cursor.moveToNext()) { + long id = cursor.getLong(0); + int type = cursor.getInt(1); + if (type == Notes.TYPE_NOTE) { + noteIds.add(id); + } else if (type == Notes.TYPE_FOLDER) { + folderIds.add(id); + } } - } else { - // 同步模式:将便签移至回收站,而非直接删除,适配同步逻辑,支持数据恢复 - if (!DataUtils.batchMoveToFolder(mContentResolver, mNotesListAdapter - .getSelectedItemIds(), Notes.ID_TRASH_FOLER)) { + cursor.close(); + } + + // 处理便签:直接移至回收站 + if (!noteIds.isEmpty()) { + if (!DataUtils.batchMoveToFolder(mContentResolver, noteIds, Notes.ID_TRASH_FOLER)) { Log.e(TAG, "Move notes to trash folder error, should not happens"); } + allWidgets.addAll(mNotesListAdapter.getSelectedWidget()); + } + + // 处理文件夹 + for (long folderId : folderIds) { + // 查询文件夹中的便签数量 + Cursor folderCursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{"COUNT(*)"}, + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null); + + int noteCount = 0; + if (folderCursor != null) { + if (folderCursor.moveToFirst()) { + noteCount = folderCursor.getInt(0); + } + folderCursor.close(); + } + + if (noteCount > 0) { + // 文件夹中有便签,将便签移至回收站 + Cursor noteCursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.ID}, + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null); + + HashSet folderNoteIds = new HashSet<>(); + if (noteCursor != null) { + while (noteCursor.moveToNext()) { + folderNoteIds.add(noteCursor.getLong(0)); + } + noteCursor.close(); + } + + // 移动便签到回收站 + if (!DataUtils.batchMoveToFolder(mContentResolver, folderNoteIds, Notes.ID_TRASH_FOLER)) { + Log.e(TAG, "Move folder notes to trash error"); + } + + // 获取文件夹中便签的widget信息 + HashSet folderWidgets = DataUtils.getFolderNoteWidget(mContentResolver, folderId); + if (folderWidgets != null) { + allWidgets.addAll(folderWidgets); + } + + // 删除空文件夹 + HashSet emptyFolderIds = new HashSet<>(); + emptyFolderIds.add(folderId); + DataUtils.batchDeleteNotes(mContentResolver, emptyFolderIds); + } else { + // 文件夹为空,直接删除 + HashSet emptyFolderIds = new HashSet<>(); + emptyFolderIds.add(folderId); + DataUtils.batchDeleteNotes(mContentResolver, emptyFolderIds); + } } - return widgets; + + return allWidgets; } - /** - * 主线程回调方法 - 删除完成后的UI更新与联动,运行在主线程 - * 核心逻辑:遍历关联的小组件,发送广播更新小组件内容 → 退出多选模式,恢复页面默认状态 - * @param widgets 后台方法返回的小组件属性集合 - */ @Override protected void onPostExecute(HashSet widgets) { if (widgets != null) { for (AppWidgetAttribute widget : widgets) { if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { - updateWidget(widget.widgetId, widget.widgetType); // 更新桌面小组件 + updateWidget(widget.widgetId, widget.widgetType); } } } - mModeCallBack.finishActionMode(); // 退出多选模式 + mModeCallBack.finishActionMode(); } }.execute(); } - /** - * 删除文件夹核心方法 - 适配同步模式的文件夹删除逻辑 - * 核心规则:根文件夹不可删除(系统保护);非同步模式 → 直接删除文件夹及子项;同步模式 → 移至回收站; - * 删除后自动更新关联的桌面小组件,保证数据一致性。 - * @param folderId 要删除的文件夹ID - */ private void deleteFolder(long folderId) { - // 系统保护:根文件夹为核心目录,禁止删除,防止应用数据异常 if (folderId == Notes.ID_ROOT_FOLDER) { Log.e(TAG, "Wrong folder id, should not happen " + folderId); return; } - // 构建待删除的文件夹ID集合 - HashSet ids = new HashSet(); - ids.add(folderId); - // 获取该文件夹下所有便签关联的桌面小组件信息,用于后续更新 - HashSet widgets = DataUtils.getFolderNoteWidget(mContentResolver, - folderId); - - // 非同步模式:直接物理删除文件夹 - if (!isSyncMode()) { - DataUtils.batchDeleteNotes(mContentResolver, ids); - } else { - // 同步模式:将文件夹移至回收站,适配同步逻辑 - DataUtils.batchMoveToFolder(mContentResolver, ids, Notes.ID_TRASH_FOLER); - } - - // 更新关联的桌面小组件,保证小组件数据与页面一致 - if (widgets != null) { - for (AppWidgetAttribute widget : widgets) { - if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID - && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { - updateWidget(widget.widgetId, widget.widgetType); - } - } + // 查询文件夹中的便签数量 + Cursor folderCursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{"COUNT(*)"}, + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null); + + final int noteCount = folderCursor != null && folderCursor.moveToFirst() ? folderCursor.getInt(0) : 0; + if (folderCursor != null) { + folderCursor.close(); } + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.alert_title_delete)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setMessage("请选择删除方式"); + + // 移动到回收站选项 + builder.setPositiveButton("移动到回收站", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (noteCount > 0) { + // 文件夹中有便签,将便签移至回收站 + Cursor noteCursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.ID}, + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null); + + HashSet folderNoteIds = new HashSet<>(); + if (noteCursor != null) { + while (noteCursor.moveToNext()) { + folderNoteIds.add(noteCursor.getLong(0)); + } + noteCursor.close(); + } + + // 移动便签到回收站 + if (!DataUtils.batchMoveToFolder(mContentResolver, folderNoteIds, Notes.ID_TRASH_FOLER)) { + Log.e(TAG, "Move folder notes to trash error"); + } + + // 获取文件夹中便签的widget信息 + HashSet widgets = DataUtils.getFolderNoteWidget(mContentResolver, folderId); + if (widgets != null) { + for (AppWidgetAttribute widget : widgets) { + if (widget.widgetId != AppWidgetManager.INVALID_APPWIDGET_ID + && widget.widgetType != Notes.TYPE_WIDGET_INVALIDE) { + updateWidget(widget.widgetId, widget.widgetType); + } + } + } + } + + // 删除空文件夹 + HashSet emptyFolderIds = new HashSet<>(); + emptyFolderIds.add(folderId); + DataUtils.batchDeleteNotes(mContentResolver, emptyFolderIds); + } + }); + + // 直接删除选项 + builder.setNeutralButton("直接删除", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (noteCount > 0) { + // 文件夹中有便签,先删除所有便签 + Cursor noteCursor = mContentResolver.query(Notes.CONTENT_NOTE_URI, + new String[]{NoteColumns.ID}, + NoteColumns.PARENT_ID + "=? AND " + NoteColumns.TYPE + "=?", + new String[]{String.valueOf(folderId), String.valueOf(Notes.TYPE_NOTE)}, + null); + + HashSet folderNoteIds = new HashSet<>(); + if (noteCursor != null) { + while (noteCursor.moveToNext()) { + folderNoteIds.add(noteCursor.getLong(0)); + } + noteCursor.close(); + } + + // 直接彻底删除便签 + DataUtils.batchDeleteNotes(mContentResolver, folderNoteIds); + } + + // 删除文件夹 + HashSet folderIds = new HashSet<>(); + folderIds.add(folderId); + DataUtils.batchDeleteNotes(mContentResolver, folderIds); + } + }); + + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); } - /** - * 打开便签核心方法 - 跳转编辑页查看/编辑已有便签 - * 核心逻辑:构建跳转Intent,标记为「查看」模式 → 传递便签唯一ID → 启动编辑页并等待返回结果, - * 编辑完成后列表会自动刷新,保证数据一致性。 - * @param data 要打开的便签数据模型,包含便签ID等核心信息 - */ private void openNode(NoteItemData data) { - Intent intent = new Intent(this, NoteEditActivity.class); - intent.setAction(Intent.ACTION_VIEW); // 标记为查看模式,区别于新建便签 - intent.putExtra(Intent.EXTRA_UID, data.getId()); // 传递便签唯一ID - this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); // 启动编辑页 + if (data.isLocked()) { + // 便签被锁定,需要密码验证 + View passwordView = LayoutInflater.from(this).inflate(R.layout.password_input_dialog, null); + final EditText passwordEdit = (EditText) passwordView.findViewById(R.id.password_input); + final NoteItemData noteData = data; + + AlertDialog.Builder passwordDialogBuilder = new AlertDialog.Builder(this); + passwordDialogBuilder.setTitle(R.string.note_lock_password_prompt); + passwordDialogBuilder.setView(passwordView); + + passwordDialogBuilder.setPositiveButton(getString(R.string.preferences_button_confirm), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + String password = passwordEdit.getText().toString(); + + if (password.equals(NotesPreferenceActivity.getPassword(NotesListActivity.this))) { + // 密码正确,打开便签 + Intent intent = new Intent(NotesListActivity.this, NoteEditActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, noteData.getId()); + NotesListActivity.this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); + } else { + // 密码错误 + Toast.makeText(NotesListActivity.this, + R.string.note_lock_password_incorrect, + Toast.LENGTH_SHORT).show(); + } + } + }); + + passwordDialogBuilder.setNegativeButton(getString(R.string.preferences_button_cancel), null); + passwordDialogBuilder.show(); + } else { + // 便签未锁定,直接打开 + Intent intent = new Intent(this, NoteEditActivity.class); + intent.setAction(Intent.ACTION_VIEW); + intent.putExtra(Intent.EXTRA_UID, data.getId()); + this.startActivityForResult(intent, REQUEST_CODE_OPEN_NODE); + } } - /** - * 打开文件夹核心方法 - 进入文件夹层级,加载子项数据 - * 核心逻辑:更新当前文件夹ID → 异步查询该文件夹下的子项 → 切换页面状态 → 适配视图展示(标题栏、新建按钮)→ 更新标题栏文本, - * 完成文件夹层级的切换,是页面层级导航的核心方法。 - * @param data 要打开的文件夹数据模型,包含文件夹ID、名称等核心信息 - */ private void openFolder(NoteItemData data) { - mCurrentFolderId = data.getId(); // 更新当前文件夹ID,标记用户浏览层级 - startAsyncNotesListQuery(); // 异步查询该文件夹下的所有子项 - - // 切换页面状态:通话记录文件夹为专属状态,其他文件夹为子文件夹状态 + mCurrentFolderId = data.getId(); + startAsyncNotesListQuery(); if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { mState = ListEditState.CALL_RECORD_FOLDER; - mAddNewNote.setVisibility(View.GONE); // 通话记录文件夹下隐藏新建按钮,禁止创建普通便签 + mAddNewNote.setVisibility(View.GONE); + } else if (data.getId() == Notes.ID_TRASH_FOLER) { + mState = ListEditState.TRASH_FOLDER; + mAddNewNote.setVisibility(View.GONE); } else { mState = ListEditState.SUB_FOLDER; } - - // 更新标题栏展示:通话记录文件夹显示固定名称,其他文件夹显示自定义名称 if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { mTitleBar.setText(R.string.call_record_folder_name); + } else if (data.getId() == Notes.ID_TRASH_FOLER) { + mTitleBar.setText("回收站"); } else { mTitleBar.setText(data.getSnippet()); } - mTitleBar.setVisibility(View.VISIBLE); // 显示标题栏,展示当前文件夹名称 + mTitleBar.setVisibility(View.VISIBLE); } - /** - * 页面点击事件统一处理 - 实现{OnClickListener}接口 - * 核心职责:处理页面所有控件的点击事件,当前仅处理新建便签按钮的点击,触发新建便签逻辑 - * @param v 被点击的视图控件对象 - */ public void onClick(View v) { switch (v.getId()) { case R.id.btn_new_note: - createNewNote(); // 点击新建按钮,创建新便签 + createNewNote(); break; default: break; } } - /** - * 软键盘强制显示方法 - 用于文件夹名称编辑场景 - * 核心逻辑:通过系统输入法服务,强制弹出软键盘,无需用户手动点击输入框,提升交互便捷性,适用于对话框打开后自动聚焦输入的场景。 - */ private void showSoftInput() { InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); if (inputMethodManager != null) { @@ -815,102 +1278,300 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * 软键盘隐藏方法 - 适配指定视图的软键盘关闭 - * 核心逻辑:通过视图的WindowToken,精准关闭当前页面的软键盘,避免影响其他应用,适用于输入完成后自动关闭软键盘的场景。 - * @param view 用于获取WindowToken的视图,通常为输入框控件 - */ private void hideSoftInput(View view) { InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); inputMethodManager.hideSoftInputFromWindow(view.getWindowToken(), 0); } - /** - * 文件夹创建/重命名对话框展示方法 - 核心文件夹编辑逻辑 - * 核心职责:统一处理文件夹的创建与重命名,包含输入框初始化、名称唯一性校验、数据库操作、软键盘控制, - * 是文件夹管理的核心交互入口。 - * @param create boolean true=创建新文件夹,false=修改已有文件夹名称 - */ - private void showCreateOrModifyFolderDialog(final boolean create) { - final AlertDialog.Builder builder = new AlertDialog.Builder(this); - // 加载对话框布局,包含输入框用于输入文件夹名称 - View view = LayoutInflater.from(this).inflate(R.layout.dialog_edit_text, null); - final EditText etName = (EditText) view.findViewById(R.id.et_foler_name); - showSoftInput(); // 打开对话框后,强制弹出软键盘,提升输入便捷性 - - // 重命名文件夹场景:初始化输入框为当前文件夹名称,设置对话框标题为「重命名文件夹」 - if (!create) { - if (mFocusNoteDataItem != null) { - etName.setText(mFocusNoteDataItem.getSnippet()); + // 显示更多选项菜单 + private void showMoreOptionsMenu(View anchorView) { + PopupMenu popupMenu = new PopupMenu(this, anchorView); + + // 添加菜单项,移除分隔线以消除空白项 + MenuItem listModeItem = popupMenu.getMenu().add(0, DISPLAY_MODE_LIST, 1, "列表式呈现"); + MenuItem gridModeItem = popupMenu.getMenu().add(0, DISPLAY_MODE_GRID, 2, "宫格图呈现"); + MenuItem sortByCreateDateItem = popupMenu.getMenu().add(0, 3, 3, "按照创建时间排序"); + MenuItem sortByModifiedDateItem = popupMenu.getMenu().add(0, 4, 4, "按照修改日期排序"); + MenuItem friendItem = popupMenu.getMenu().add(0, 7, 5, "好友"); + MenuItem switchAccountItem = popupMenu.getMenu().add(0, 6, 6, "切换账号"); + + // 根据当前状态设置菜单项的勾选状态 + listModeItem.setChecked(mDisplayMode == DISPLAY_MODE_LIST); + gridModeItem.setChecked(mDisplayMode == DISPLAY_MODE_GRID); + listModeItem.setCheckable(true); + gridModeItem.setCheckable(true); + + // 设置菜单项点击监听器 + popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + switch (item.getItemId()) { + case DISPLAY_MODE_LIST: + // 切换到列表模式 + mDisplayMode = DISPLAY_MODE_LIST; + updateDisplayMode(); + item.setChecked(true); + break; + case DISPLAY_MODE_GRID: + // 切换到宫格模式 + mDisplayMode = DISPLAY_MODE_GRID; + updateDisplayMode(); + item.setChecked(true); + break; + case 3: + // 按照创建时间排序 + mCurrentSortOrder = SORT_BY_CREATE_DATE; + startAsyncNotesListQuery(); + break; + case 4: + // 按照修改日期排序 + mCurrentSortOrder = SORT_BY_MODIFIED_DATE; + startAsyncNotesListQuery(); + break; + case 6: + // 处理切换账号 + showSwitchAccountDialog(); + break; + case 7: + // 处理好友功能 + Intent friendIntent = new Intent(NotesListActivity.this, net.micode.notes.ui.FriendManagementActivity.class); + startActivity(friendIntent); + break; + } + return true; + } + }); + + // 显示弹出菜单 + popupMenu.show(); + } + + /** + * 显示切换账号对话框 + */ + private void showSwitchAccountDialog() { + // 准备用户列表 + final ArrayList userIds = new ArrayList<>(); + final ArrayList usernames = new ArrayList<>(); + + try { + // 直接在主线程中查询所有用户,数据库操作简单,不会导致明显卡顿 + net.micode.notes.data.NotesDatabaseHelper helper = net.micode.notes.data.NotesDatabaseHelper.getInstance(getApplicationContext()); + if (helper == null) { + Toast.makeText(this, "数据库初始化失败", Toast.LENGTH_SHORT).show(); + return; + } + + android.database.sqlite.SQLiteDatabase db = helper.getReadableDatabase(); + if (db == null || !db.isOpen()) { + Toast.makeText(this, "数据库连接失败", Toast.LENGTH_SHORT).show(); + return; + } + + android.database.Cursor cursor = null; + try { + // 查询所有用户 + cursor = db.query( + net.micode.notes.data.NotesDatabaseHelper.TABLE.USER, + new String[]{net.micode.notes.data.Users.UserColumns.ID, net.micode.notes.data.Users.UserColumns.USERNAME}, + null, + null, + null, + null, + null + ); + + if (cursor != null && cursor.moveToFirst()) { + do { + userIds.add(cursor.getLong(0)); + usernames.add(cursor.getString(1)); + } while (cursor.moveToNext()); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "查询账号失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + return; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "获取账号列表失败", Toast.LENGTH_SHORT).show(); + return; + } + + if (userIds.isEmpty()) { + Toast.makeText(this, "没有可用的账号", Toast.LENGTH_SHORT).show(); + return; + } + + // 显示用户列表对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("选择账号"); + builder.setItems(usernames.toArray(new String[0]), new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + long selectedUserId = userIds.get(which); + String selectedUsername = usernames.get(which); + + // 获取当前用户ID + long currentUserId = net.micode.notes.tool.UserManager.getInstance(NotesListActivity.this).getCurrentUserId(); + + if (selectedUserId == currentUserId) { + // 如果选择的是当前账号,显示提示 + Toast.makeText(NotesListActivity.this, "您已经在" + selectedUsername + "账号上", Toast.LENGTH_SHORT).show(); + } else { + // 否则,显示密码输入对话框 + showPasswordInputDialog(selectedUserId, selectedUsername); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(NotesListActivity.this, "账号选择失败", Toast.LENGTH_SHORT).show(); + } + } + }); + builder.show(); + } + + /** + * 显示密码输入对话框 + */ + private void showPasswordInputDialog(final long userId, final String username) { + try { + // 加载密码输入对话框布局 + View passwordView = LayoutInflater.from(this).inflate(R.layout.password_input_dialog, null); + final EditText passwordEdit = (EditText) passwordView.findViewById(R.id.password_input); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("输入密码"); + builder.setMessage("请输入" + username + "的密码"); + builder.setView(passwordView); + + // 设置确定按钮点击事件 + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + try { + String password = passwordEdit.getText().toString(); + + // 验证密码 + if (net.micode.notes.tool.UserManager.getInstance(NotesListActivity.this).validatePassword(userId, password)) { + // 密码正确,切换账号 + net.micode.notes.tool.UserManager.getInstance(NotesListActivity.this).setCurrentUser(userId); + + // 刷新便签列表 + startAsyncNotesListQuery(); + + // 显示切换成功提示 + Toast.makeText(NotesListActivity.this, "已切换到" + username + "账号", Toast.LENGTH_SHORT).show(); + } else { + // 密码错误,显示提示 + Toast.makeText(NotesListActivity.this, "密码错误", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(NotesListActivity.this, "切换账号失败", Toast.LENGTH_SHORT).show(); + } + } + }); + + // 设置取消按钮点击事件 + builder.setNegativeButton(android.R.string.cancel, null); + + // 显示对话框 + builder.show(); + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "打开密码输入框失败", Toast.LENGTH_SHORT).show(); + } + } + + // 更新显示模式 + private void updateDisplayMode() { + if (mDisplayMode == DISPLAY_MODE_LIST) { + // 切换到列表模式 + mNotesListView.setVisibility(View.VISIBLE); + mNotesGridView.setVisibility(View.GONE); + } else { + // 切换到宫格模式 + mNotesListView.setVisibility(View.GONE); + mNotesGridView.setVisibility(View.VISIBLE); + mNotesGridView.setAdapter(mNotesListAdapter); + } + } + + private void showCreateOrModifyFolderDialog(final boolean create) { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + View view = LayoutInflater.from(this).inflate(R.layout.dialog_edit_text, null); + final EditText etName = (EditText) view.findViewById(R.id.et_foler_name); + showSoftInput(); + if (!create) { + if (mFocusNoteDataItem != null) { + etName.setText(mFocusNoteDataItem.getSnippet()); builder.setTitle(getString(R.string.menu_folder_change_name)); } else { Log.e(TAG, "The long click data item is null"); return; } } else { - // 创建文件夹场景:清空输入框,设置对话框标题为「创建文件夹」 etName.setText(""); builder.setTitle(this.getString(R.string.menu_create_folder)); } - // 设置对话框按钮:确定按钮先不绑定点击事件(自定义逻辑),取消按钮点击时隐藏软键盘 builder.setPositiveButton(android.R.string.ok, null); builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { public void onClick(DialogInterface dialog, int which) { - hideSoftInput(etName); // 取消操作,隐藏软键盘 + hideSoftInput(etName); } }); - // 显示对话框,并自定义确定按钮的点击逻辑,避免默认点击直接关闭对话框 final Dialog dialog = builder.setView(view).show(); final Button positive = (Button)dialog.findViewById(android.R.id.button1); positive.setOnClickListener(new OnClickListener() { public void onClick(View v) { - hideSoftInput(etName); // 输入完成,隐藏软键盘 + hideSoftInput(etName); String name = etName.getText().toString(); - - // 核心校验:文件夹名称唯一性校验,已存在则提示用户并选中输入框文本,避免重复创建同名文件夹 if (DataUtils.checkVisibleFolderName(mContentResolver, name)) { Toast.makeText(NotesListActivity.this, getString(R.string.folder_exist, name), Toast.LENGTH_LONG).show(); - etName.setSelection(0, etName.length()); // 选中输入框文本,便于用户修改 + etName.setSelection(0, etName.length()); return; } - - // 重命名文件夹逻辑:名称非空时,更新数据库中该文件夹的名称与本地修改标记 if (!create) { if (!TextUtils.isEmpty(name)) { ContentValues values = new ContentValues(); - values.put(NoteColumns.SNIPPET, name); // 更新文件夹名称 - values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); // 标记为文件夹类型 - values.put(NoteColumns.LOCAL_MODIFIED, 1); // 标记本地修改,适配同步逻辑 - // 执行数据库更新操作,精准更新指定ID的文件夹 + values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); + values.put(NoteColumns.LOCAL_MODIFIED, 1); mContentResolver.update(Notes.CONTENT_NOTE_URI, values, NoteColumns.ID + "=?", new String[] { - String.valueOf(mFocusNoteDataItem.getId()) + String.valueOf(mFocusNoteDataItem.getId()) }); } } else if (!TextUtils.isEmpty(name)) { - // 创建文件夹逻辑:名称非空时,插入新文件夹到数据库,归属根目录 ContentValues values = new ContentValues(); - values.put(NoteColumns.SNIPPET, name); // 设置文件夹名称 - values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); // 标记为文件夹类型 - mContentResolver.insert(Notes.CONTENT_NOTE_URI, values); // 插入新文件夹 + values.put(NoteColumns.SNIPPET, name); + values.put(NoteColumns.TYPE, Notes.TYPE_FOLDER); + mContentResolver.insert(Notes.CONTENT_NOTE_URI, values); } - dialog.dismiss(); // 操作完成,关闭对话框 + dialog.dismiss(); } }); - // 初始化确定按钮状态:输入框为空时禁用,避免创建空名称的文件夹 if (TextUtils.isEmpty(etName.getText())) { positive.setEnabled(false); } /** - * 输入框文本变化监听:实时校验输入内容,为空时禁用确定按钮,非空时启用, - * 从交互层面避免用户输入空名称,提升数据有效性。 + * When the name edit text is null, disable the positive button */ etName.addTextChangedListener(new TextWatcher() { - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // TODO Auto-generated method stub + + } public void onTextChanged(CharSequence s, int start, int before, int count) { if (TextUtils.isEmpty(etName.getText())) { @@ -920,28 +1581,25 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - public void afterTextChanged(Editable s) {} + public void afterTextChanged(Editable s) { + // TODO Auto-generated method stub + + } }); } - /** - * 返回键事件重写 - 适配文件夹层级的返回逻辑 - * 核心设计意图:用户点击返回键时,根据当前页面状态执行差异化逻辑,子文件夹返回根目录,根目录退出应用, - * 实现「层级导航」的交互逻辑,符合用户使用习惯。 - * 核心规则:子文件夹状态 → 返回根目录;通话记录文件夹 → 返回根目录并恢复新建按钮;根目录 → 执行系统默认返回逻辑。 - */ @Override public void onBackPressed() { switch (mState) { case SUB_FOLDER: - // 子文件夹状态:返回根目录,重置状态,刷新列表,隐藏标题栏 + case TRASH_FOLDER: mCurrentFolderId = Notes.ID_ROOT_FOLDER; mState = ListEditState.NOTE_LIST; startAsyncNotesListQuery(); mTitleBar.setVisibility(View.GONE); + mAddNewNote.setVisibility(View.VISIBLE); break; case CALL_RECORD_FOLDER: - // 通话记录文件夹状态:返回根目录,重置状态,恢复新建按钮,隐藏标题栏,刷新列表 mCurrentFolderId = Notes.ID_ROOT_FOLDER; mState = ListEditState.NOTE_LIST; mAddNewNote.setVisibility(View.VISIBLE); @@ -949,7 +1607,6 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt startAsyncNotesListQuery(); break; case NOTE_LIST: - // 根目录状态:执行系统默认返回逻辑,退出应用 super.onBackPressed(); break; default: @@ -957,16 +1614,8 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } - /** - * 桌面小组件更新核心方法 - 发送广播同步小组件数据 - * 核心设计意图:便签数据变更后,必须同步更新关联的桌面小组件,保证应用内数据与桌面展示数据一致,是小组件联动的核心方法。 - * 核心逻辑:根据小组件类型,指定对应的广播接收者 → 封装要更新的小组件ID → 发送广播通知小组件刷新内容。 - * @param appWidgetId 要更新的小组件系统唯一标识ID - * @param appWidgetType 小组件类型:2x/4x,对应不同的广播接收者 - */ private void updateWidget(int appWidgetId, int appWidgetType) { Intent intent = new Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE); - // 根据小组件类型,绑定对应的广播接收者,保证精准更新 if (appWidgetType == Notes.TYPE_WIDGET_2X) { intent.setClass(this, NoteWidgetProvider_2x.class); } else if (appWidgetType == Notes.TYPE_WIDGET_4X) { @@ -976,37 +1625,25 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt return; } - // 传递要更新的小组件ID数组,支持批量更新 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, new int[] { - appWidgetId + appWidgetId }); - sendBroadcast(intent); // 发送广播,通知小组件执行刷新 - setResult(RESULT_OK, intent); // 设置返回结果,标记更新成功 + sendBroadcast(intent); + setResult(RESULT_OK, intent); } - /** - * 文件夹上下文菜单创建监听器 - 长按文件夹时展示右键菜单 - * 核心职责:创建文件夹的右键菜单,包含「查看」「删除」「重命名」三个核心操作,菜单标题为文件夹名称, - * 是文件夹的核心右键交互入口。 - */ private final OnCreateContextMenuListener mFolderOnCreateContextMenuListener = new OnCreateContextMenuListener() { public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { if (mFocusNoteDataItem != null) { - menu.setHeaderTitle(mFocusNoteDataItem.getSnippet()); // 菜单标题为文件夹名称 - menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view); // 查看文件夹 - menu.add(0, MENU_FOLDER_DELETE, 0, R.string.menu_folder_delete); // 删除文件夹 - menu.add(0, MENU_FOLDER_CHANGE_NAME, 0, R.string.menu_folder_change_name); // 重命名文件夹 + menu.setHeaderTitle(mFocusNoteDataItem.getSnippet()); + menu.add(0, MENU_FOLDER_VIEW, 0, R.string.menu_folder_view); + menu.add(0, MENU_FOLDER_DELETE, 0, R.string.menu_folder_delete); + menu.add(0, MENU_FOLDER_CHANGE_NAME, 0, R.string.menu_folder_change_name); } } }; - /** - * 上下文菜单关闭回调 - 内存泄漏防护处理 - * 执行时机:上下文菜单关闭时调用 - * 核心逻辑:清空列表的上下文菜单创建监听器,避免监听器持有页面引用导致内存泄漏,提升应用稳定性。 - * @param menu 被关闭的上下文菜单对象 - */ @Override public void onContextMenuClosed(Menu menu) { if (mNotesListView != null) { @@ -1015,26 +1652,17 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt super.onContextMenuClosed(menu); } - /** - * 上下文菜单项选择处理 - 文件夹右键菜单的核心逻辑执行 - * 核心职责:处理文件夹右键菜单的「查看」「删除」「重命名」操作,包含前置校验、用户确认、业务逻辑执行, - * 是文件夹右键交互的核心出口。 - * @param item 被选中的菜单项对象 - * @return boolean true表示事件已处理完成 - */ @Override public boolean onContextItemSelected(MenuItem item) { - // 前置校验:无聚焦的文件夹数据,直接返回,避免空指针异常 if (mFocusNoteDataItem == null) { Log.e(TAG, "The long click data item is null"); return false; } switch (item.getItemId()) { case MENU_FOLDER_VIEW: - openFolder(mFocusNoteDataItem); // 查看文件夹 → 进入该文件夹层级 + openFolder(mFocusNoteDataItem); break; case MENU_FOLDER_DELETE: - // 删除文件夹:弹出确认对话框,防止误删,用户确认后执行删除逻辑 AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setTitle(getString(R.string.alert_title_delete)); builder.setIcon(android.R.drawable.ic_dialog_alert); @@ -1049,197 +1677,200 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt builder.show(); break; case MENU_FOLDER_CHANGE_NAME: - showCreateOrModifyFolderDialog(false); // 重命名文件夹 → 打开修改对话框 + showCreateOrModifyFolderDialog(false); break; default: break; } + return true; } - /** - * 选项菜单准备回调 - 动态加载不同状态的菜单资源 - * 执行时机:页面顶部菜单展示前调用,每次菜单刷新都会执行 - * 核心逻辑:清空原有菜单 → 根据当前页面状态加载对应的菜单资源 → 更新同步按钮的文本(同步/取消同步), - * 保证不同页面状态下展示的菜单功能与当前场景匹配。 - * @param menu 要初始化的选项菜单对象 - * @return boolean true表示菜单初始化成功,展示菜单 - */ @Override public boolean onPrepareOptionsMenu(Menu menu) { - menu.clear(); // 清空原有菜单,避免重复加载导致菜单异常 - // 根据页面状态加载对应的菜单资源 + menu.clear(); if (mState == ListEditState.NOTE_LIST) { - getMenuInflater().inflate(R.menu.note_list, menu); // 根目录加载完整菜单 - // 更新同步按钮文本:同步中显示「取消同步」,未同步显示「同步」 + getMenuInflater().inflate(R.menu.note_list, menu); + // set sync or sync_cancel menu.findItem(R.id.menu_sync).setTitle( GTaskSyncService.isSyncing() ? R.string.menu_sync_cancel : R.string.menu_sync); } else if (mState == ListEditState.SUB_FOLDER) { - getMenuInflater().inflate(R.menu.sub_folder, menu); // 子文件夹加载精简菜单 + getMenuInflater().inflate(R.menu.sub_folder, menu); } else if (mState == ListEditState.CALL_RECORD_FOLDER) { - getMenuInflater().inflate(R.menu.call_record_folder, menu); // 通话记录文件夹加载专属菜单 + getMenuInflater().inflate(R.menu.call_record_folder, menu); + } else if (mState == ListEditState.TRASH_FOLDER) { + // 为回收站添加特定菜单 + menu.add(0, 100, 0, "恢复"); + menu.add(0, 101, 0, "清空回收站"); } else { Log.e(TAG, "Wrong state:" + mState); } return true; } - /** - * 选项菜单点击事件处理 - 页面顶部菜单的核心交互逻辑 - * 核心职责:处理顶部菜单的所有点击事件,包含「新建文件夹」「导出文本」「同步」「设置」「新建便签」「搜索」, - * 是页面顶部功能的核心出口。 - * @param item 被选中的菜单项对象 - * @return boolean true表示事件已处理完成 - */ @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { - case R.id.menu_new_folder: - showCreateOrModifyFolderDialog(true); // 新建文件夹 → 打开创建对话框 + case R.id.menu_new_folder: { + showCreateOrModifyFolderDialog(true); break; - case R.id.menu_export_text: - exportNoteToText(); // 导出文本 → 执行便签导出逻辑,保存为本地文件 + } + case R.id.menu_export_text: { + exportNoteToText(); break; - case R.id.menu_sync: - // 同步操作:区分同步模式与非同步模式,执行差异化逻辑 + } + case R.id.menu_sync: { if (isSyncMode()) { - // 同步模式下:点击「同步」启动同步,点击「取消同步」终止同步 if (TextUtils.equals(item.getTitle(), getString(R.string.menu_sync))) { GTaskSyncService.startSync(this); } else { GTaskSyncService.cancelSync(this); } } else { - // 非同步模式下:跳转至设置页面,引导用户配置同步账号 startPreferenceActivity(); } break; - case R.id.menu_setting: - startPreferenceActivity(); // 设置 → 跳转至应用偏好设置页面 + } + case R.id.menu_setting: { + startPreferenceActivity(); break; - case R.id.menu_new_note: - createNewNote(); // 新建便签 → 执行新建逻辑 + } + case R.id.menu_new_note: { + createNewNote(); break; + } case R.id.menu_search: - onSearchRequested(); // 搜索 → 触发系统全局搜索功能 + onSearchRequested(); break; + case R.id.menu_change_background: + // 直接在主界面更换背景 + changeBackground(); + break; + + case 100: { + // 恢复选中的便签(兼容旧版本) + if (mNotesListAdapter.getSelectedCount() == 0) { + Toast.makeText(this, getString(R.string.menu_select_none), + Toast.LENGTH_SHORT).show(); + return true; + } + // 将便签恢复到根文件夹 + DataUtils.batchMoveToFolder(getContentResolver(), mNotesListAdapter.getSelectedItemIds(), Notes.ID_ROOT_FOLDER); + mModeCallBack.finishActionMode(); + break; + } + case 101: { + // 清空回收站(兼容旧版本) + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(getString(R.string.alert_title_delete)); + builder.setIcon(android.R.drawable.ic_dialog_alert); + builder.setMessage(getString(R.string.alert_message_delete_notes, "所有")); + builder.setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int which) { + // 查询回收站中的所有便签并彻底删除 + Cursor cursor = getContentResolver().query(Notes.CONTENT_NOTE_URI, + new String[] { NoteColumns.ID }, + NoteColumns.PARENT_ID + "=?", + new String[] { String.valueOf(Notes.ID_TRASH_FOLER) }, + null); + if (cursor != null) { + HashSet ids = new HashSet(); + while (cursor.moveToNext()) { + ids.add(cursor.getLong(0)); + } + cursor.close(); + // 彻底删除 + DataUtils.batchDeleteNotes(getContentResolver(), ids); + } + } + }); + builder.setNegativeButton(android.R.string.cancel, null); + builder.show(); + break; + } default: break; } return true; } - /** - * 系统搜索触发方法 - 重写原生方法 - * 核心逻辑:调用系统搜索接口,启动应用内搜索页面,支持便签内容的全局搜索,提升数据查找效率。 - * @return boolean true表示搜索请求已成功触发 - */ @Override public boolean onSearchRequested() { - startSearch(null, false, null, false); + startSearch(null, false, null /* appData */, false); return true; } - /** - * 便签导出核心方法 - 异步导出便签为文本文件 - * 核心设计:通过{AsyncTask}异步执行导出操作,避免主线程阻塞;适配SD卡状态、导出结果,展示对应的提示对话框, - * 支持本地备份,提升数据安全性。 - * 核心逻辑:调用备份工具类执行导出 → 根据导出状态码展示不同提示 → 成功则显示文件路径,失败则提示原因。 - */ - private void exportNoteToText() { - final BackupUtils backup = BackupUtils.getInstance(NotesListActivity.this); - new AsyncTask() { - - @Override - protected Integer doInBackground(Void... unused) { - return backup.exportToText(); // 后台执行导出逻辑,返回状态码 - } - - @Override - protected void onPostExecute(Integer result) { - // 根据导出状态码,展示对应的提示对话框 - if (result == BackupUtils.STATE_SD_CARD_UNMOUONTED) { - // SD卡未挂载,导出失败,提示用户挂载SD卡 - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.failed_sdcard_export)); - builder.setMessage(NotesListActivity.this - .getString(R.string.error_sdcard_unmounted)); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } else if (result == BackupUtils.STATE_SUCCESS) { - // 导出成功,提示用户文件路径与名称 - AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.success_sdcard_export)); - builder.setMessage(NotesListActivity.this.getString( - R.string.format_exported_file_location, backup - .getExportedTextFileName(), backup.getExportedTextFileDir())); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } else if (result == BackupUtils.STATE_SYSTEM_ERROR) { - // 系统异常,导出失败,提示用户重试 - alertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); - builder.setTitle(NotesListActivity.this - .getString(R.string.failed_sdcard_export)); - builder.setMessage(NotesListActivity.this - .getString(R.string.error_sdcard_export)); - builder.setPositiveButton(android.R.string.ok, null); - builder.show(); - } - } - }.execute(); - } + private void exportNoteToText() { + final BackupUtils backup = BackupUtils.getInstance(NotesListActivity.this); + new AsyncTask() { + + @Override + protected Integer doInBackground(Void... unused) { + return backup.exportToText(); + } + + @Override + protected void onPostExecute(Integer result) { + if (result == BackupUtils.STATE_SD_CARD_UNMOUONTED) { + AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); + builder.setTitle(NotesListActivity.this + .getString(R.string.failed_sdcard_export)); + builder.setMessage(NotesListActivity.this + .getString(R.string.error_sdcard_unmounted)); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } else if (result == BackupUtils.STATE_SUCCESS) { + AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); + builder.setTitle(NotesListActivity.this + .getString(R.string.success_sdcard_export)); + builder.setMessage(NotesListActivity.this.getString( + R.string.format_exported_file_location, backup + .getExportedTextFileName(), backup.getExportedTextFileDir())); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } else if (result == BackupUtils.STATE_SYSTEM_ERROR) { + AlertDialog.Builder builder = new AlertDialog.Builder(NotesListActivity.this); + builder.setTitle(NotesListActivity.this + .getString(R.string.failed_sdcard_export)); + builder.setMessage(NotesListActivity.this + .getString(R.string.error_sdcard_export)); + builder.setPositiveButton(android.R.string.ok, null); + builder.show(); + } + } + + }.execute(); + } - /** - * 同步模式判断方法 - 核心模式区分依据 - * 核心规则:通过判断是否配置了同步账号,区分同步模式与非同步模式,同步模式下所有删除操作均移至回收站,非同步模式直接物理删除, - * 是页面差异化业务逻辑的核心判断依据。 - * @return boolean true=已配置同步账号(同步模式),false=未配置同步账号(非同步模式) - */ private boolean isSyncMode() { return NotesPreferenceActivity.getSyncAccountName(this).trim().length() > 0; } - /** - * 偏好设置页面启动方法 - 适配嵌套Activity场景 - * 核心逻辑:判断当前页面是否有父Activity,有则通过父Activity启动,无则直接启动,适配应用的嵌套页面结构, - * 保证设置页面正常打开。 - */ private void startPreferenceActivity() { Activity from = getParent() != null ? getParent() : this; Intent intent = new Intent(from, NotesPreferenceActivity.class); from.startActivityIfNeeded(intent, -1); } - /** - * 内部列表项点击监听器 - 列表项核心交互逻辑 - * 实现{OnItemClickListener}接口,核心职责:区分多选模式与普通模式,处理列表项的点击事件, - * 多选模式下切换选中状态,普通模式下打开便签/文件夹,是列表项点击交互的核心实现类。 - */ private class OnListItemClickListener implements OnItemClickListener { public void onItemClick(AdapterView parent, View view, int position, long id) { - // 仅处理自定义的NotesListItem列表项,避免类型错误 if (view instanceof NotesListItem) { NoteItemData item = ((NotesListItem) view).getItemData(); - - // 多选模式下的点击逻辑:仅处理普通便签,切换选中状态,文件夹不可选 if (mNotesListAdapter.isInChoiceMode()) { - if (item.getType() == Notes.TYPE_NOTE) { - // 校准列表项位置,排除头部视图的影响 + // 允许在多选模式下选中文件夹和便签,但禁止选中回收站 + if (item.getId() != Notes.ID_TRASH_FOLER) { position = position - mNotesListView.getHeaderViewsCount(); - // 切换当前项的选中状态:选中→取消,取消→选中 mModeCallBack.onItemCheckedStateChanged(null, position, id, !mNotesListAdapter.isSelectedItem(position)); } return; } - // 普通模式下的点击逻辑:根据当前页面状态,执行差异化操作 switch (mState) { case NOTE_LIST: - // 根目录状态:文件夹/系统文件夹→打开文件夹,普通便签→打开便签编辑 if (item.getType() == Notes.TYPE_FOLDER || item.getType() == Notes.TYPE_SYSTEM) { openFolder(item); @@ -1251,11 +1882,11 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt break; case SUB_FOLDER: case CALL_RECORD_FOLDER: - // 子文件夹/通话记录文件夹状态:仅处理普通便签,打开编辑,无文件夹层级 + case TRASH_FOLDER: if (item.getType() == Notes.TYPE_NOTE) { openNode(item); } else { - Log.e(TAG, "Wrong note type in SUB_FOLDER"); + Log.e(TAG, "Wrong note type in folder view"); } break; default: @@ -1263,21 +1894,14 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt } } } + } - /** - * 目标文件夹查询启动方法 - 批量移动便签的前置查询 - * 核心设计意图:查询所有可用于移动便签的目标文件夹,构建差异化的查询条件,排除无效文件夹,保证移动逻辑的准确性。 - * 核心查询规则:包含所有用户文件夹 → 排除回收站、当前文件夹 → 子文件夹状态下额外包含根文件夹(允许移回根目录)。 - */ private void startQueryDestinationFolders() { - // 基础查询条件:类型为文件夹 + 父文件夹不是回收站 + 文件夹ID不是当前文件夹 String selection = NoteColumns.TYPE + "=? AND " + NoteColumns.PARENT_ID + "<>? AND " + NoteColumns.ID + "<>?"; - // 子文件夹状态下,额外包含根文件夹,允许用户将便签移回根目录 selection = (mState == ListEditState.NOTE_LIST) ? selection: - "(" + selection + ") OR (" + NoteColumns.ID + "=" + Notes.ID_ROOT_FOLDER + ")"; + "(" + selection + ") OR (" + NoteColumns.ID + "=" + Notes.ID_ROOT_FOLDER + ")"; - // 启动异步查询,获取目标文件夹列表,用于展示选择对话框 mBackgroundQueryHandler.startQuery(FOLDER_LIST_QUERY_TOKEN, null, Notes.CONTENT_NOTE_URI, @@ -1290,34 +1914,770 @@ public class NotesListActivity extends Activity implements OnClickListener, OnIt }, NoteColumns.MODIFIED_DATE + " DESC"); } + + /** + * 显示选择好友的对话框,用于发送便签 + */ + private void showSendToFriendsDialog(final HashSet selectedNoteIds) { + try { + // 查询所有好友 + final ArrayList friendIds = new ArrayList<>(); + final ArrayList friendUsernames = new ArrayList<>(); + final boolean[] selectedFriends = new boolean[0]; + + // 获取当前用户ID + long currentUserId = UserManager.getInstance(this).getCurrentUserId(); + + // 获取数据库实例 + SQLiteDatabase db = NotesDatabaseHelper.getInstance(this).getReadableDatabase(); + + // 查询除当前用户以外的所有用户 + Cursor cursor = db.query( + NotesDatabaseHelper.TABLE.USER, + new String[]{Users.UserColumns.ID, Users.UserColumns.USERNAME}, + Users.UserColumns.ID + " != ?", + new String[]{String.valueOf(currentUserId)}, + null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + do { + long friendId = cursor.getLong(cursor.getColumnIndexOrThrow(Users.UserColumns.ID)); + String friendUsername = cursor.getString(cursor.getColumnIndexOrThrow(Users.UserColumns.USERNAME)); + friendIds.add(friendId); + friendUsernames.add(friendUsername); + } while (cursor.moveToNext()); + cursor.close(); + } + + if (friendIds.isEmpty()) { + Toast.makeText(this, "没有可用的好友", Toast.LENGTH_SHORT).show(); + return; + } + + // 创建选择数组 + final boolean[] isFriendSelected = new boolean[friendIds.size()]; + + // 创建对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("选择好友"); + + // 设置多选项 + builder.setMultiChoiceItems( + friendUsernames.toArray(new String[0]), + isFriendSelected, + new DialogInterface.OnMultiChoiceClickListener() { + @Override + public void onClick(DialogInterface dialog, int which, boolean isChecked) { + isFriendSelected[which] = isChecked; + } + }); + + // 设置确定按钮 + builder.setPositiveButton("发送", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + // 获取选中的好友 + ArrayList selectedFriendIds = new ArrayList<>(); + for (int i = 0; i < isFriendSelected.length; i++) { + if (isFriendSelected[i]) { + selectedFriendIds.add(friendIds.get(i)); + } + } + + if (selectedFriendIds.isEmpty()) { + Toast.makeText(NotesListActivity.this, "请选择至少一个好友", Toast.LENGTH_SHORT).show(); + return; + } + + // 发送便签到选中的好友 + sendNotesToFriends(selectedNoteIds, selectedFriendIds); + + // 结束操作模式 + mModeCallBack.finishActionMode(); + } + }); + + // 设置取消按钮 + builder.setNegativeButton("取消", null); + + // 显示对话框 + builder.show(); + } catch (Exception e) { + Log.e(TAG, "Error in showSendToFriendsDialog: " + e.getMessage(), e); + Toast.makeText(this, "显示好友列表失败", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 发送便签到选中的好友 + */ + private void sendNotesToFriends(HashSet noteIds, ArrayList friendIds) { + try { + SQLiteDatabase db = NotesDatabaseHelper.getInstance(this).getWritableDatabase(); + long currentUserId = UserManager.getInstance(this).getCurrentUserId(); + + // 遍历每个选中的便签 + for (Long noteId : noteIds) { + // 查询便签内容 + Cursor noteCursor = db.query( + NotesDatabaseHelper.TABLE.NOTE, + new String[]{NoteColumns.TITLE, NoteColumns.SNIPPET}, + NoteColumns.ID + " = ?", + new String[]{String.valueOf(noteId)}, + null, null, null); + + if (noteCursor != null && noteCursor.moveToFirst()) { + String noteTitle = noteCursor.getString(noteCursor.getColumnIndexOrThrow(NoteColumns.TITLE)); + String noteContent = noteCursor.getString(noteCursor.getColumnIndexOrThrow(NoteColumns.SNIPPET)); + noteCursor.close(); + + // 便签内容格式:标题|内容|便签ID + String noteData = noteTitle + "|" + noteContent + "|" + noteId; + + // 遍历每个选中的好友,发送便签 + for (Long friendId : friendIds) { + ContentValues values = new ContentValues(); + values.put(Messages.MessageColumns.SENDER_ID, currentUserId); + values.put(Messages.MessageColumns.RECEIVER_ID, friendId); + values.put(Messages.MessageColumns.CONTENT, noteData); + values.put(Messages.MessageColumns.MESSAGE_TYPE, Messages.MessageType.NOTE); + values.put(Messages.MessageColumns.CREATED_DATE, System.currentTimeMillis()); + values.put(Messages.MessageColumns.IS_READ, 0); + + // 插入消息到数据库 + long messageId = db.insert(NotesDatabaseHelper.TABLE.MESSAGE, null, values); + if (messageId != -1) { + Log.d(TAG, "Note sent to friend " + friendId + ", messageId: " + messageId); + } else { + Log.e(TAG, "Failed to send note to friend " + friendId); + } + } + } else { + if (noteCursor != null) { + noteCursor.close(); + } + Log.e(TAG, "Failed to get note content for note " + noteId); + } + } + + Toast.makeText(this, "便签发送成功", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "Error in sendNotesToFriends: " + e.getMessage(), e); + Toast.makeText(this, "发送便签失败", Toast.LENGTH_SHORT).show(); + } + } + + /** + * 更换背景方法 + */ + private void changeBackground() { + Log.d(TAG, "changeBackground called"); + + // 创建选择背景的对话框 + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle("更换背景"); + + // 创建列表项 + String[] items = {"从相册选择", "拍照"}; + + // 设置列表项点击事件 + builder.setItems(items, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Intent intent; + switch (which) { + case 0: // 从相册选择 + intent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI); + intent.setType("image/*"); + startActivityForResult(intent, 1); + break; + case 1: // 拍照 + intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + startActivityForResult(intent, 2); + break; + } + } + }); + + // 显示对话框 + builder.show(); + Log.d(TAG, "Dialog shown"); + } + + + /** + * 根据Uri获取图片路径,适配不同Android版本 + */ + private String getPathFromUri(android.net.Uri uri) { + Log.d(TAG, "Getting path from Uri: " + uri.toString() + ", scheme: " + uri.getScheme()); + + String path = null; + + try { + // 检查Uri scheme + if ("content".equals(uri.getScheme())) { + // 处理content://类型的Uri + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + // Android 10及以上,使用MediaStore API + path = getPathFromContentUriQ(uri); + } else { + // Android 9及以下,使用传统方式 + path = getPathFromContentUriLegacy(uri); + } + } else if ("file".equals(uri.getScheme())) { + // 处理file://类型的Uri + path = uri.getPath(); + Log.d(TAG, "File scheme Uri, path: " + path); + } + + Log.d(TAG, "Final path from Uri: " + path); + } catch (Exception e) { + Log.e(TAG, "Error getting path from Uri: " + e.getMessage(), e); + } + + return path; + } + + /** + * Android 9及以下,根据Content Uri获取图片路径 + */ + private String getPathFromContentUriLegacy(android.net.Uri uri) { + String path = null; + String[] projection = {android.provider.MediaStore.Images.Media.DATA}; + Cursor cursor = getContentResolver().query(uri, projection, null, null, null); + if (cursor != null) { + try { + if (cursor.moveToFirst()) { + int columnIndex = cursor.getColumnIndexOrThrow(android.provider.MediaStore.Images.Media.DATA); + path = cursor.getString(columnIndex); + Log.d(TAG, "Legacy path: " + path); + } + } catch (Exception e) { + Log.e(TAG, "Error in legacy path retrieval: " + e.getMessage(), e); + } finally { + cursor.close(); + } + } + return path; + } + /** - * 列表项长按事件处理 - 实现{OnItemLongClickListener}接口,页面核心长按交互逻辑 - * 核心职责:区分长按的是便签还是文件夹,执行差异化逻辑 → 长按便签:触发多选模式,选中当前项并震动反馈; - * 长按文件夹:绑定上下文菜单监听器,展示右键菜单,是页面长按交互的核心入口。 - * @param parent 列表控件对象 - * @param view 被长按的列表项视图 - * @param position 列表项位置 - * @param id 列表项对应的便签/文件夹ID - * @return boolean false表示不消费事件,允许后续逻辑执行 + * Android 10及以上,根据Content Uri获取图片路径 */ + private String getPathFromContentUriQ(android.net.Uri uri) { + String path = null; + + // 对于Android Q及以上,我们可以直接使用Uri打开InputStream,而不需要获取真实路径 + // 这里我们创建一个临时文件来保存图片 + try { + // 创建临时文件 + java.io.File tempFile = java.io.File.createTempFile("notes_bg", ".jpg", getExternalCacheDir()); + tempFile.deleteOnExit(); + + // 从Uri复制到临时文件 + java.io.InputStream inputStream = getContentResolver().openInputStream(uri); + if (inputStream != null) { + java.io.FileOutputStream outputStream = new java.io.FileOutputStream(tempFile); + + // 复制文件 + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + + outputStream.close(); + inputStream.close(); + + path = tempFile.getAbsolutePath(); + Log.d(TAG, "Q path: " + path); + } + } catch (Exception e) { + Log.e(TAG, "Error in Q path retrieval: " + e.getMessage(), e); + } + + return path; + } + + /** + * 保存Bitmap到本地 + */ + private String saveBitmap(android.graphics.Bitmap bitmap) { + String path = android.os.Environment.getExternalStorageDirectory().getAbsolutePath() + "/notes_background.jpg"; + try { + java.io.FileOutputStream fos = new java.io.FileOutputStream(path); + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 100, fos); + fos.flush(); + fos.close(); + } catch (java.io.IOException e) { + e.printStackTrace(); + path = null; + } + return path; + } + + /** + * 设置背景 + */ + private void setBackground(String imagePath) { + Log.d(TAG, "Setting background with path: " + imagePath); + + try { + // 首先检查图片文件是否存在 + java.io.File imageFile = new java.io.File(imagePath); + if (!imageFile.exists()) { + Log.e(TAG, "Image file not found: " + imagePath); + Toast.makeText(this, "图片文件不存在", Toast.LENGTH_SHORT).show(); + return; + } + + Log.d(TAG, "Image file exists, size: " + imageFile.length() + " bytes"); + + // 1. 尝试直接获取note_list.xml中的根FrameLayout + FrameLayout rootFrameLayout = findViewById(R.id.root_layout); + if (rootFrameLayout != null) { + Log.d(TAG, "Found root_layout (note_list.xml root), setting background directly"); + + // 尝试加载图片 + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeFile(imagePath); + if (bitmap != null) { + Log.d(TAG, "Bitmap loaded successfully, width: " + bitmap.getWidth() + ", height: " + bitmap.getHeight()); + + // 创建Drawable + android.graphics.drawable.Drawable drawable; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap); + } else { + drawable = new android.graphics.drawable.BitmapDrawable(bitmap); + } + + // 直接设置根FrameLayout的背景 + rootFrameLayout.setBackground(drawable); + Log.d(TAG, "Root layout background set successfully"); + Toast.makeText(this, "背景设置成功", Toast.LENGTH_SHORT).show(); + } else { + Log.e(TAG, "Failed to load bitmap from path: " + imagePath); + Toast.makeText(this, "图片加载失败", Toast.LENGTH_SHORT).show(); + } + } else { + Log.d(TAG, "root_layout not found, trying other methods"); + + // 2. 尝试获取Activity的根View + View rootView = getWindow().getDecorView().findViewById(android.R.id.content); + if (rootView != null) { + Log.d(TAG, "Root view found: " + rootView.getClass().getName()); + + // 3. 如果是ViewGroup,尝试设置其所有子View的背景为透明,然后设置自身背景 + if (rootView instanceof ViewGroup) { + Log.d(TAG, "Root view is ViewGroup, clearing child backgrounds"); + clearChildBackgrounds((ViewGroup) rootView); + } + + // 尝试加载图片 + android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeFile(imagePath); + if (bitmap != null) { + Log.d(TAG, "Bitmap loaded successfully, width: " + bitmap.getWidth() + ", height: " + bitmap.getHeight()); + + // 创建Drawable + android.graphics.drawable.Drawable drawable; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + drawable = new android.graphics.drawable.BitmapDrawable(getResources(), bitmap); + } else { + drawable = new android.graphics.drawable.BitmapDrawable(bitmap); + } + + // 设置背景 + rootView.setBackground(drawable); + Log.d(TAG, "Root view background set successfully"); + Toast.makeText(this, "背景设置成功", Toast.LENGTH_SHORT).show(); + } else { + Log.e(TAG, "Failed to load bitmap from path: " + imagePath); + Toast.makeText(this, "图片加载失败", Toast.LENGTH_SHORT).show(); + } + } else { + Log.e(TAG, "Root view not found"); + Toast.makeText(this, "无法获取根布局", Toast.LENGTH_SHORT).show(); + } + } + } catch (Exception e) { + Log.e(TAG, "Error setting background: " + e.getMessage(), e); + Toast.makeText(this, "设置背景时出错: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + /** + * 清除ViewGroup中所有子View的背景 + */ + private void clearChildBackgrounds(ViewGroup viewGroup) { + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + if (child instanceof ViewGroup) { + // 递归清除子ViewGroup的背景 + clearChildBackgrounds((ViewGroup) child); + } + // 设置子View背景为透明 + child.setBackgroundColor(getResources().getColor(android.R.color.transparent)); + } + } + + /** + * 加载保存的背景图片 + */ + private void loadSavedBackground() { + // 从SharedPreferences获取保存的图片路径 + SharedPreferences sharedPreferences = getSharedPreferences("background", MODE_PRIVATE); + String imagePath = sharedPreferences.getString("background_path", null); + + if (imagePath != null) { + // 设置背景 + setBackground(imagePath); + } + } + public boolean onItemLongClick(AdapterView parent, View view, int position, long id) { if (view instanceof NotesListItem) { mFocusNoteDataItem = ((NotesListItem) view).getItemData(); - // 长按普通便签且非多选模式:启动多选模式,选中当前项,触发震动反馈,提升交互感知 - if (mFocusNoteDataItem.getType() == Notes.TYPE_NOTE && !mNotesListAdapter.isInChoiceMode()) { + // 禁用回收站文件夹的长按功能 + if (mFocusNoteDataItem.getId() == Notes.ID_TRASH_FOLER) { + return false; + } + if (!mNotesListAdapter.isInChoiceMode()) { if (mNotesListView.startActionMode(mModeCallBack) != null) { mModeCallBack.onItemCheckedStateChanged(null, position, id, true); - // 执行长按震动反馈,符合安卓交互规范,提升用户体验 mNotesListView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); } else { Log.e(TAG, "startActionMode fails"); } - } else if (mFocusNoteDataItem.getType() == Notes.TYPE_FOLDER) { - // 长按文件夹:绑定上下文菜单创建监听器,准备展示右键菜单 - mNotesListView.setOnCreateContextMenuListener(mFolderOnCreateContextMenuListener); } } return false; } -} \ No newline at end of file + + // 拖拽触摸监听器 + private class OnDragTouchListener implements OnTouchListener { + private static final long LONG_PRESS_DURATION = 500; // 长按检测时长 + private boolean mIsLongPress = false; + private long mPressStartTime = 0; + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (mIsDragging) { + handleDragEvent(event); + return true; + } + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mPressStartTime = System.currentTimeMillis(); + mIsLongPress = false; + break; + case MotionEvent.ACTION_MOVE: + if (!mIsLongPress && System.currentTimeMillis() - mPressStartTime > LONG_PRESS_DURATION) { + mIsLongPress = true; + startDrag(v, event); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + mIsLongPress = false; + break; + } + return false; + } + } + + // 开始拖拽 + private void startDrag(View listView, MotionEvent event) { + try { + if (listView instanceof ListView) { + ListView lv = (ListView) listView; + int position = lv.pointToPosition((int) event.getX(), (int) event.getY()); + int listCount = lv.getCount(); + if (position >= 0 && position < listCount) { + // 保存当前列表数据 + Cursor cursor = mNotesListAdapter.getCursor(); + if (cursor != null && cursor.getCount() > 0) { + try { + cursor.moveToFirst(); + int cursorCount = cursor.getCount(); + mDragTempData = new NoteItemData[cursorCount]; + for (int i = 0; i < cursorCount; i++) { + if (!cursor.isAfterLast()) { + mDragTempData[i] = new NoteItemData(this, cursor); + cursor.moveToNext(); + } + } + } catch (Exception e) { + Log.e(TAG, "Error saving list data: " + e.getMessage(), e); + mDragTempData = null; + } finally { + if (!cursor.isClosed()) { + try { + cursor.close(); + } catch (Exception e) { + Log.e(TAG, "Error closing cursor: " + e.getMessage(), e); + } + } + } + } + + mDragStartPosition = position; + mDragCurrentPosition = position; + mDragStartY = event.getY(); + mDragOffsetY = event.getY(); + mIsDragging = true; + + // 获取拖拽视图 + int firstVisiblePosition = lv.getFirstVisiblePosition(); + int childIndex = position - firstVisiblePosition; + if (childIndex >= 0 && childIndex < lv.getChildCount()) { + View itemView = lv.getChildAt(childIndex); + if (itemView != null) { + // 创建拖拽视图的副本 + mDraggingView = itemView; + mDraggingViewHeight = itemView.getHeight(); + // 显示拖拽效果 + mDraggingView.setAlpha(0.5f); + mDraggingView.setScaleX(1.1f); + mDraggingView.setScaleY(1.1f); + } + } + } + } + } catch (Exception e) { + Log.e(TAG, "Error starting drag: " + e.getMessage(), e); + // 发生异常时,重置拖拽状态 + mIsDragging = false; + mDragStartPosition = -1; + mDragCurrentPosition = -1; + mDraggingView = null; + mDragTempData = null; + } + } + + // 处理拖拽事件 + private void handleDragEvent(MotionEvent event) { + try { + switch (event.getAction()) { + case MotionEvent.ACTION_MOVE: + // 更新拖拽视图位置 + if (mDraggingView != null) { + float deltaY = event.getY() - mDragOffsetY; + mDraggingView.setTranslationY(deltaY); + } + + // 计算当前拖拽到的位置 + ListView lv = mNotesListView; + if (lv == null) { + break; + } + + int firstVisiblePosition = lv.getFirstVisiblePosition(); + int lastVisiblePosition = lv.getLastVisiblePosition(); + int childCount = lv.getChildCount(); + + // 计算相对于列表顶部的Y坐标 + int listTop = lv.getTop(); + int relativeY = (int) event.getY() + listTop; + + // 计算拖拽到的项索引 + int newPosition = -1; + + // 遍历可见项,计算当前Y坐标对应的项 + if (childCount > 0) { + for (int i = 0; i < childCount; i++) { + View child = lv.getChildAt(i); + if (child == null) continue; + + int childTop = child.getTop() + listTop; + int childBottom = childTop + child.getHeight(); + + if (relativeY >= childTop && relativeY <= childBottom) { + // 找到了对应的项 + newPosition = firstVisiblePosition + i; + break; + } + } + } + + // 处理边界情况 + if (newPosition == -1) { + if (childCount > 0) { + View firstVisibleItem = lv.getChildAt(0); + View lastVisibleItem = lv.getChildAt(childCount - 1); + + if (firstVisibleItem != null && event.getY() < firstVisibleItem.getTop()) { + // 向上拖拽到可见区域外,应该插入到第一个可见项之前 + newPosition = firstVisiblePosition; + } else if (lastVisibleItem != null && event.getY() > lastVisibleItem.getBottom()) { + // 向下拖拽到可见区域外,应该插入到最后一个可见项之后 + newPosition = lastVisiblePosition + 1; + } else { + break; + } + } else { + break; + } + } + + // 确保新位置在有效范围内 + int listCount = lv.getCount(); + if (listCount > 0) { + newPosition = Math.max(0, Math.min(newPosition, listCount - 1)); + } else { + break; + } + + if (newPosition != mDragCurrentPosition && mDragTempData != null) { + // 只更新临时数据列表,不立即更新数据库 + // 这样可以避免在拖拽过程中频繁更新数据库导致的性能问题 + if (mDragCurrentPosition >= 0 && mDragCurrentPosition < mDragTempData.length) { + NoteItemData draggedItem = mDragTempData[mDragCurrentPosition]; + if (draggedItem != null && newPosition >= 0 && newPosition < mDragTempData.length) { + if (newPosition > mDragCurrentPosition) { + // 向下移动 + for (int i = mDragCurrentPosition; i < newPosition; i++) { + if (i + 1 < mDragTempData.length) { + mDragTempData[i] = mDragTempData[i + 1]; + } + } + } else if (newPosition < mDragCurrentPosition) { + // 向上移动 + for (int i = mDragCurrentPosition; i > newPosition; i--) { + if (i - 1 >= 0) { + mDragTempData[i] = mDragTempData[i - 1]; + } + } + } + // 将被拖拽的项放到新位置 + mDragTempData[newPosition] = draggedItem; + // 更新当前位置 + mDragCurrentPosition = newPosition; + } + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + stopDrag(); + break; + } + } catch (Exception e) { + Log.e(TAG, "Error in handleDragEvent: " + e.getMessage(), e); + // 发生异常时,停止拖拽 + stopDrag(); + } + } + + // 更新临时数据列表的顺序 + private void updateTempDataOrder(int newPosition) { + if (mDragTempData == null || mDragStartPosition < 0 || mDragStartPosition >= mDragTempData.length) { + return; + } + + // 保存当前拖拽位置 + int currentPosition = mDragCurrentPosition; + + // 先更新当前位置 + mDragCurrentPosition = newPosition; + + // 保存被拖拽的项 + NoteItemData draggedItem = mDragTempData[currentPosition]; + + // 移动数组中的元素 + if (newPosition > currentPosition) { + // 向下移动 + for (int i = currentPosition; i < newPosition; i++) { + mDragTempData[i] = mDragTempData[i + 1]; + } + } else if (newPosition < currentPosition) { + // 向上移动 + for (int i = currentPosition; i > newPosition; i--) { + mDragTempData[i] = mDragTempData[i - 1]; + } + } + + // 将被拖拽的项放到新位置 + mDragTempData[newPosition] = draggedItem; + + // 更新起始位置为当前新位置 + mDragStartPosition = newPosition; + + // 立即更新数据库中的排序 + updateSortOrderInDatabase(); + } + + // 更新数据库中的排序 + private void updateSortOrderInDatabase() { + if (mDragTempData == null || mDragTempData.length == 0) { + return; + } + + try { + // 使用ContentResolver批量更新,不需要直接操作数据库 + ContentResolver resolver = getContentResolver(); + if (resolver == null) { + return; + } + + for (int i = 0; i < mDragTempData.length; i++) { + NoteItemData item = mDragTempData[i]; + if (item != null && item.getType() == Notes.TYPE_NOTE) { + ContentValues values = new ContentValues(); + values.put(NoteColumns.SORT_ORDER, i); + resolver.update(Notes.CONTENT_NOTE_URI, + values, + "_id=?", + new String[]{String.valueOf(item.getId())}); + } + } + + // 刷新列表 + startAsyncNotesListQuery(); + } catch (Exception e) { + Log.e(TAG, "Error updating sort order: " + e.getMessage(), e); + } + } + + // 停止拖拽 + private void stopDrag() { + try { + if (mDraggingView != null) { + // 恢复拖拽视图 + try { + mDraggingView.setAlpha(1.0f); + mDraggingView.setScaleX(1.0f); + mDraggingView.setScaleY(1.0f); + mDraggingView.setTranslationY(0); + } catch (Exception e) { + Log.e(TAG, "Error resetting dragging view: " + e.getMessage(), e); + } + } + + // 在拖拽结束时更新数据库 + if (mDragTempData != null) { + updateSortOrderInDatabase(); + } + } catch (Exception e) { + Log.e(TAG, "Error in stopDrag: " + e.getMessage(), e); + } finally { + // 无论如何都重置拖拽状态 + mIsDragging = false; + mDragStartPosition = -1; + mDragCurrentPosition = -1; + mDraggingView = null; + mDragTempData = null; + } + } + + // 获取指定位置的便签ID + private long getItemId(int position) { + Cursor cursor = mNotesListAdapter.getCursor(); + if (cursor != null && cursor.moveToPosition(position)) { + return cursor.getLong(0); // ID_COLUMN 是 0 + } + return -1; + } +} + diff --git a/src/Notes-master/src/net/micode/notes/ui/NotesListAdapter.java b/src/Notes-master/src/net/micode/notes/ui/NotesListAdapter.java index d8720c9..fcdee3f 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NotesListAdapter.java +++ b/src/Notes-master/src/net/micode/notes/ui/NotesListAdapter.java @@ -14,231 +14,133 @@ * limitations under the License. */ -// 包声明:小米便签 核心UI模块,该包承载应用所有可视化交互页面及配套适配器,本类为列表页核心数据适配桥梁 package net.micode.notes.ui; -// -------------------------- 安卓系统核心依赖包 - 上下文/数据库/日志/视图/适配器能力 -------------------------- -// 安卓应用全局上下文:提供资源访问、视图创建、数据解析等基础能力,适配器必备依赖 import android.content.Context; -// 安卓数据库游标核心类:封装数据库查询结果集,承载便签数据表的查询数据,列表展示的核心数据来源 import android.database.Cursor; -// 安卓系统日志工具类:输出适配器运行的调试/错误日志,便于问题定位与线上排查 import android.util.Log; -// 安卓视图体系核心类:视图创建、视图容器的核心父类,适配列表项的创建与挂载 import android.view.View; import android.view.ViewGroup; -// 安卓游标适配器基类:专为Cursor数据设计的列表适配器,封装数据绑定、视图复用、数据变化监听等核心能力,本类的核心父类 import android.widget.CursorAdapter; -// -------------------------- 小米便签业务层核心依赖 - 数据常量/集合工具 -------------------------- -// 小米便签数据层核心常量类:定义便签/文件夹/小部件的类型、特殊ID、业务状态等全局核心常量 import net.micode.notes.data.Notes; -// Java集合框架相关类:封装选中项状态的存储、遍历、统计,支撑批量选择模式的核心逻辑 import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; + /** - * 小米便签 列表页核心数据适配器 + * 便签列表适配器 *

- * 继承安卓系统{CursorAdapter}游标适配器,隶属于MVC架构的UI层适配桥梁,是{NotesListActivity}与数据库/Cursor数据、{NotesListItem}列表项之间的核心纽带; - * 核心设计定位:封装Cursor数据库数据与列表项视图的绑定逻辑,统一管理列表的展示规则、选择模式、数据变化监听,解耦列表页的业务逻辑与数据渲染逻辑; - * 核心业务职责:创建并复用自定义列表项视图、将Cursor数据映射为业务模型并绑定至列表项、完整支撑批量选择模式的所有操作(单选/全选/取消全选/选中状态维护)、 - * 统计选中项数据与普通便签总数、提取选中项的小部件关联属性、监听数据库数据变化并实时刷新视图; - * 技术实现特点:基于安卓原生CursorAdapter实现视图复用,提升列表滑动性能;通过HashMap维护选中项状态,保证选择操作的高效性; - * 封装数据统计与业务属性提取逻辑,对外提供简洁的调用接口;自动监听数据库数据变化,保证视图与数据的一致性。 - *

+ * 该类是便签列表的适配器,用于将数据库中的便签数据绑定到列表项视图上。 + * 它支持选择模式、搜索高亮和小部件属性管理等功能。 */ public class NotesListAdapter extends CursorAdapter { - /** 日志常量标签:适配器相关日志的统一标识,便于日志过滤与问题定位 */ private static final String TAG = "NotesListAdapter"; - /** 应用上下文对象:用于创建列表项视图、解析业务数据,全局复用避免多次创建 */ private Context mContext; - /** 选中项状态映射容器:核心数据结构,Key=列表项的索引位置,Value=该位置的选中状态,支撑选择模式的核心存储 */ private HashMap mSelectedIndex; - /** 普通便签数量统计:仅统计{Notes.TYPE_NOTE}类型的项,排除文件夹/通话记录等类型,用于全选状态的判定依据 */ private int mNotesCount; - /** 选择模式状态标记:true=开启批量选择模式(批量操作),false=关闭选择模式(普通浏览),控制列表项勾选框的显隐 */ private boolean mChoiceMode; + private String mSearchQuery; /** - * 内部静态数据载体类:便签关联的桌面小部件属性封装 - * 核心设计目的:批量操作场景下,统一封装选中便签所绑定的桌面小部件核心属性,便于后续同步更新小部件数据, - * 静态类设计减少内存开销,无上下文引用避免内存泄漏 + * 小部件属性类 + *

+ * 用于存储小部件的ID和类型信息 */ public static class AppWidgetAttribute { - /** 小部件系统唯一标识ID:桌面小部件的注册ID,用于精准定位目标小部件 */ public int widgetId; - /** 小部件类型标识:区分2x/4x两种尺寸的便签小部件,对应{Notes.TYPE_WIDGET_2X}/{Notes.TYPE_WIDGET_4X} */ public int widgetType; }; - /** - * 构造方法:适配器的初始化入口,完成核心成员变量的初始化配置 - * 核心初始化逻辑:调用父类构造方法完成基础配置、初始化选中状态映射容器、保存上下文引用、重置普通便签统计数, - * 初始无绑定Cursor,后续通过{changeCursor}方法动态绑定数据库查询结果 - * @param context 应用上下文对象,传递至父类并全局保存 - */ public NotesListAdapter(Context context) { - // 父类构造:传入上下文+空Cursor,Cursor数据后续动态绑定,保证初始化灵活性 super(context, null); - // 初始化选中项状态映射容器,空HashMap保证初始无选中项 mSelectedIndex = new HashMap(); - // 保存应用上下文引用,供后续视图创建与数据解析使用 mContext = context; - // 重置普通便签统计数量为0,初始无数据状态 mNotesCount = 0; + mSearchQuery = null; + } + + // 设置搜索查询 + public void setSearchQuery(String query) { + mSearchQuery = query; + notifyDataSetChanged(); } - /** - * 重写父类核心方法:创建新的列表项视图 - * 执行时机:列表首次加载、滑动时需要创建新视图的场景,遵循安卓视图复用机制 - * 核心逻辑:创建自定义的{NotesListItem}列表项视图并返回,该方法仅负责视图创建,不负责数据绑定 - * @param context 应用上下文对象 - * @param cursor 当前位置的数据库游标(本方法未使用,仅遵循父类接口规范) - * @param parent 列表项的父容器,即承载所有列表项的ListView - * @return View 创建完成的、未绑定数据的自定义列表项视图 - */ @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { - // 创建小米便签自定义列表项视图,作为列表的最小展示单元 return new NotesListItem(context); } - /** - * 重写父类核心方法:数据与视图的绑定核心实现 - * 执行时机:列表首次加载、视图复用、数据变化刷新时调用,是适配器的核心业务方法 - * 核心逻辑:将指定位置的Cursor数据库数据,解析为业务数据模型{NoteItemData},并调用列表项的绑定方法, - * 完成数据渲染、选择模式适配、选中状态赋值,实现「数据→模型→视图」的完整映射 - * @param view 待绑定数据的列表项视图,为{NotesListItem}类型,支持视图复用 - * @param context 应用上下文对象 - * @param cursor 当前列表位置的数据库游标,封装了该位置的完整便签数据 - */ @Override public void bindView(View view, Context context, Cursor cursor) { - // 类型安全校验:仅处理自定义的NotesListItem视图,防止视图类型错误导致崩溃 if (view instanceof NotesListItem) { - // 将Cursor数据库数据解析为业务数据模型,封装所有展示所需的业务字段 NoteItemData itemData = new NoteItemData(context, cursor); - // 调用列表项的核心绑定方法,完成数据渲染+选择模式适配+选中状态赋值 ((NotesListItem) view).bind(context, itemData, mChoiceMode, - isSelectedItem(cursor.getPosition())); + isSelectedItem(cursor.getPosition()), mSearchQuery); } } - /** - * 公开业务方法:设置指定位置列表项的选中状态 - * 核心作用:单选操作的核心接口,支持选择模式下的单个列表项勾选/取消勾选, - * 更新状态后主动通知列表刷新视图,保证选中状态的实时展示 - * @param position 待设置状态的列表项索引位置 - * @param checked 目标选中状态,true=勾选,false=取消勾选 - */ public void setCheckedItem(final int position, final boolean checked) { - // 更新选中状态映射容器,保存当前位置的选中状态 mSelectedIndex.put(position, checked); - // 通知列表数据发生变化,触发视图刷新,展示最新的选中状态 notifyDataSetChanged(); } - /** - * 公开查询方法:获取当前列表的选择模式状态 - * @return boolean true=处于批量选择模式,false=处于普通浏览模式 - */ public boolean isInChoiceMode() { return mChoiceMode; } - /** - * 公开业务方法:开启/关闭列表的批量选择模式 - * 核心逻辑:切换模式时自动清空所有选中状态,避免模式切换后残留选中标记,保证视图展示的一致性, - * 是选择模式的总开关接口 - * @param mode true=开启选择模式,false=关闭选择模式 - */ public void setChoiceMode(boolean mode) { - // 清空所有选中项状态,重置选择容器为初始状态 mSelectedIndex.clear(); - // 更新选择模式标记,控制后续视图绑定的逻辑分支 mChoiceMode = mode; } - /** - * 公开业务方法:全选/取消全选的批量操作 - * 核心规则:仅对{Notes.TYPE_NOTE}类型的普通便签生效,文件夹/通话记录等特殊类型不参与选择, - * 避免用户误操作系统特殊项,保证业务数据的安全性 - * @param checked true=执行全选操作,false=执行取消全选操作 - */ public void selectAll(boolean checked) { - // 获取当前绑定的数据库游标,遍历所有列表项数据 Cursor cursor = getCursor(); for (int i = 0; i < getCount(); i++) { - // 移动游标到当前列表项的位置,匹配对应数据 if (cursor.moveToPosition(i)) { - // 仅处理普通便签类型,过滤文件夹等非选择项 if (NoteItemData.getNoteType(cursor) == Notes.TYPE_NOTE) { - // 批量设置当前位置的选中状态 setCheckedItem(i, checked); } } } } - /** - * 公开业务方法:获取所有选中项的便签ID集合 - * 核心作用:批量删除/批量移动等业务操作的核心数据来源,返回去重的选中便签ID, - * 自动过滤根文件夹等无效ID并输出日志提示,保证业务数据的有效性 - * @return HashSet 所有选中便签的ID集合,无选中项则返回空集合 - */ public HashSet getSelectedItemIds() { HashSet itemSet = new HashSet(); - // 遍历所有已记录的选中状态位置 - for (Integer position : mSelectedIndex.keySet()) { - // 仅处理选中状态为true的有效项 - if (mSelectedIndex.get(position) == true) { - // 获取当前位置对应的便签ID - Long id = getItemId(position); - // 过滤系统根文件夹的无效ID,避免业务操作异常 - if (id == Notes.ID_ROOT_FOLDER) { - Log.d(TAG, "Wrong item id, should not happen"); - } else { - // 将有效ID添加至结果集合 - itemSet.add(id); + Cursor cursor = getCursor(); + if (cursor != null) { + for (Integer position : mSelectedIndex.keySet()) { + if (mSelectedIndex.get(position) == true) { + if (cursor.moveToPosition(position)) { + long id = cursor.getLong(NoteItemData.ID_COLUMN); + if (id != Notes.ID_ROOT_FOLDER) { + itemSet.add(id); + } + } } } } return itemSet; } - /** - * 公开业务方法:获取所有选中项关联的小部件属性集合 - * 核心作用:批量操作后同步更新桌面小部件的核心数据来源,提取选中便签所绑定的小部件ID与类型, - * 自动校验游标有效性,异常时输出错误日志并返回null,保证数据安全性 - * @return HashSet 选中项的小部件属性集合,无效数据时返回null - */ public HashSet getSelectedWidget() { HashSet itemSet = new HashSet(); - // 遍历所有已记录的选中状态位置 for (Integer position : mSelectedIndex.keySet()) { - // 仅处理选中状态为true的有效项 if (mSelectedIndex.get(position) == true) { - // 获取当前位置对应的数据库游标 Cursor c = (Cursor) getItem(position); if (c != null) { - // 创建小部件属性对象,封装核心数据 AppWidgetAttribute widget = new AppWidgetAttribute(); - // 将游标数据解析为业务模型,提取小部件关联属性 NoteItemData item = new NoteItemData(mContext, c); widget.widgetId = item.getWidgetId(); widget.widgetType = item.getWidgetType(); - // 添加至结果集合 itemSet.add(widget); /** - * 重要说明:此处不主动关闭Cursor - * Cursor由CursorAdapter统一管理生命周期,外部关闭会导致列表数据异常、游标越界等崩溃问题 + * Don't close cursor here, only the adapter could close it */ } else { - // 游标无效时输出错误日志,返回null标记异常状态 Log.e(TAG, "Invalid cursor"); return null; } @@ -247,18 +149,11 @@ public class NotesListAdapter extends CursorAdapter { return itemSet; } - /** - * 公开业务方法:统计当前选中的普通便签数量 - * 核心作用:列表页展示选中数量、判定全选状态的核心依据,仅统计选中状态为true的有效项,保证计数准确性 - * @return int 选中的普通便签数量,无选中项则返回0 - */ public int getSelectedCount() { - // 获取所有已记录的选中状态值集合 Collection values = mSelectedIndex.values(); if (null == values) { return 0; } - // 遍历状态值,统计选中状态为true的项数 Iterator iter = values.iterator(); int count = 0; while (iter.hasNext()) { @@ -269,80 +164,42 @@ public class NotesListAdapter extends CursorAdapter { return count; } - /** - * 公开业务方法:判断当前是否处于「全选」状态 - * 核心判定规则:选中数量大于0 且 选中数量等于列表中普通便签的总数,双条件保证判定准确性, - * 避免无数据时误判为全选、选中部分项时误判为全选 - * @return boolean true=已全选所有普通便签,false=未全选/无选中项/无普通便签 - */ public boolean isAllSelected() { int checkedCount = getSelectedCount(); return (checkedCount != 0 && checkedCount == mNotesCount); } - /** - * 公开查询方法:判断指定位置的列表项是否被选中 - * 核心规则:未记录状态的位置默认视为「未选中」,避免空指针异常,保证业务逻辑的健壮性 - * @param position 待查询的列表项索引位置 - * @return boolean true=该位置已选中,false=该位置未选中/无状态记录 - */ public boolean isSelectedItem(final int position) { - // 状态容器中无该位置记录时,默认返回未选中 if (null == mSelectedIndex.get(position)) { return false; } - // 返回该位置的实际选中状态 return mSelectedIndex.get(position); } - /** - * 重写父类回调方法:监听数据库内容变化的核心回调 - * 触发时机:当适配器绑定的数据库表数据发生增/删/改操作时,系统自动调用该方法 - * 核心逻辑:执行父类默认刷新逻辑后,重新统计普通便签数量,保证全选状态判定的准确性, - * 是视图与数据库数据一致性的核心保障 - */ @Override protected void onContentChanged() { super.onContentChanged(); - // 数据变化后重新统计普通便签数量 calcNotesCount(); } - /** - * 重写父类核心方法:更换适配器绑定的游标数据 - * 触发时机:列表页切换文件夹、刷新数据、查询条件变更时主动调用 - * 核心逻辑:执行父类游标更换逻辑后,重新统计普通便签数量,适配新数据集的全选判定规则 - * @param cursor 新的数据库游标,封装了新的查询结果集 - */ @Override public void changeCursor(Cursor cursor) { super.changeCursor(cursor); - // 更换游标后重新统计普通便签数量 calcNotesCount(); } - /** - * 私有核心方法:统计列表中普通便签的总数 - * 核心业务规则:仅遍历统计{Notes.TYPE_NOTE}类型的项,排除文件夹、通话记录等非便签类型, - * 自动校验游标有效性,异常时终止统计并输出日志,保证计数准确性,是全选功能的核心支撑方法 - */ private void calcNotesCount() { - // 重置计数为0,避免累计统计错误 mNotesCount = 0; - // 遍历适配器绑定的所有列表项数据 for (int i = 0; i < getCount(); i++) { - // 获取当前位置对应的数据库游标 Cursor c = (Cursor) getItem(i); if (c != null) { - // 仅统计普通便签类型的项 if (NoteItemData.getNoteType(c) == Notes.TYPE_NOTE) { mNotesCount++; } } else { - // 游标无效时输出错误日志,终止统计避免数据异常 Log.e(TAG, "Invalid cursor"); return; } } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/NotesListItem.java b/src/Notes-master/src/net/micode/notes/ui/NotesListItem.java index d890f6c..3554ce4 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NotesListItem.java +++ b/src/Notes-master/src/net/micode/notes/ui/NotesListItem.java @@ -14,88 +14,53 @@ * limitations under the License. */ -// 包声明:小米便签 核心UI模块,该包承载应用所有可视化交互页面及自定义UI组件,本类为列表页核心子项组件 package net.micode.notes.ui; -// -------------------------- 安卓系统核心依赖包 - 上下文/视图/布局/基础控件能力 -------------------------- -// 安卓应用全局上下文:提供资源访问、样式加载、布局渲染等基础能力,自定义控件必备依赖 import android.content.Context; -// 安卓系统时间格式化工具类:提供相对时间格式化能力,将时间戳转为「几分钟前/昨天/上周」等友好展示格式 import android.text.format.DateUtils; -// 安卓视图体系核心父类:控制控件的显示/隐藏、可见性状态、视图属性等核心操作 import android.view.View; -// 安卓复选框控件:列表选择模式下的核心勾选组件,用于便签的批量操作场景 import android.widget.CheckBox; -// 安卓图片展示控件:承载提醒图标、通话记录图标等所有图片类展示内容 import android.widget.ImageView; -// 安卓线性布局容器:本类的父布局,提供横向/纵向的线性控件排列能力,作为列表项的根布局 import android.widget.LinearLayout; -// 安卓文本展示控件:承载所有文本类展示内容,如标题、时间、通话名称等 import android.widget.TextView; -// -------------------------- 小米便签业务层核心依赖 - 资源/数据/工具适配 -------------------------- -// 小米便签资源常量类:统一管理布局、字符串、样式、图片等所有本地资源ID引用 import net.micode.notes.R; -// 小米便签数据层核心常量类:定义便签/文件夹的类型、特殊ID、业务状态等全局核心常量 import net.micode.notes.data.Notes; -// 小米便签数据格式化工具类:封装便签摘要文本的格式化处理逻辑,统一文本展示规则 import net.micode.notes.tool.DataUtils; -// 小米便签资源解析工具类:封装便签/文件夹的背景资源适配逻辑,提供不同状态的背景资源获取能力 import net.micode.notes.tool.ResourceParser.NoteItemBgResources; + /** - * 小米便签 列表页核心自定义列表项组件 + * 便签列表项视图 *

- * 继承安卓系统{LinearLayout}线性布局,隶属于MVC架构的UI层视图组件,是{NotesListActivity}列表页的最小展示单元; - * 核心设计定位:作为便签列表的标准化子项容器,封装所有列表项的UI渲染逻辑、数据绑定逻辑、样式适配逻辑,解耦列表页与子项展示逻辑; - * 核心业务职责:统一承载所有类型便签/文件夹的差异化展示、选择模式的UI适配、提醒状态的图标展示、背景样式的精准适配、通话记录的专属布局; - * 技术实现特点:基于数据驱动视图的设计思想,通过统一的bind方法完成数据与视图的绑定,内置多分支逻辑适配不同业务类型, - * 封装背景设置的复杂逻辑,对外提供简洁的调用与数据获取接口,是列表页高性能展示的核心基础组件。 - *

+ * 该类是便签列表的列表项视图,负责显示单个便签的信息,包括标题、时间、提醒图标、 + * 锁定图标、分类标签等。它还包含复杂的背景圆角计算逻辑,根据首项、末项、单项动态设置背景。 */ public class NotesListItem extends LinearLayout { - /** 功能图标控件:展示闹钟提醒、通话记录等业务类型图标,不同场景展示对应功能标识 */ private ImageView mAlert; - /** 主标题文本控件:核心文本展示区,适配展示便签摘要、文件夹名称+数量、通话记录内容等核心信息 */ + private ImageView mLock; + private ImageView mPublic; private TextView mTitle; - /** 时间文本控件:展示便签/文件夹的最后修改时间,统一格式化为相对友好时间格式 */ private TextView mTime; - /** 通话名称文本控件:通话记录专属展示区,仅通话记录项展示来电/去电的联系人名称,其他场景隐藏 */ private TextView mCallName; - /** 数据模型载体:保存当前列表项绑定的业务数据,用于后续视图刷新与数据获取 */ + private TextView mCategory; private NoteItemData mItemData; - /** 选择复选框控件:批量操作模式专属控件,用于勾选待操作的便签,非选择模式下默认隐藏 */ private CheckBox mCheckBox; - /** - * 构造方法:自定义控件的初始化入口 - * 核心初始化逻辑:加载列表项的基础布局文件,完成所有子控件的视图绑定,为后续数据绑定做准备; - * 该方法仅在列表项创建时执行一次,保证控件初始化的性能最优 - * @param context 应用上下文对象,用于加载布局资源与查找子控件 - */ public NotesListItem(Context context) { super(context); - // 加载列表项的基础布局文件,将xml布局解析为当前线性布局的子视图 inflate(context, R.layout.note_item, this); - // 绑定布局内所有子控件,通过ID精准获取并赋值给成员变量 mAlert = (ImageView) findViewById(R.id.iv_alert_icon); + mLock = (ImageView) findViewById(R.id.iv_lock_icon); + mPublic = (ImageView) findViewById(R.id.iv_public_icon); mTitle = (TextView) findViewById(R.id.tv_title); mTime = (TextView) findViewById(R.id.tv_time); mCallName = (TextView) findViewById(R.id.tv_name); + mCategory = (TextView) findViewById(R.id.tv_category); mCheckBox = (CheckBox) findViewById(android.R.id.checkbox); } - /** - * 核心公开绑定方法:数据与视图的统一绑定入口,列表项所有展示逻辑的核心处理方法 - * 核心业务能力:接收业务数据模型,根据数据类型/状态/模式完成所有视图的差异化渲染,包括控件显隐、文本赋值、图标切换、样式适配、勾选状态设置; - * 该方法为列表项的核心对外接口,列表页通过调用此方法完成所有子项的内容展示 - * @param context 应用上下文,用于资源加载、样式设置、字符串格式化 - * @param data 当前列表项对应的业务数据模型,承载所有展示所需的业务字段 - * @param choiceMode 是否开启列表选择模式:true=批量操作模式,false=普通浏览模式 - * @param checked 选择模式下当前项的勾选状态:true=已勾选,false=未勾选 - */ - public void bind(Context context, NoteItemData data, boolean choiceMode, boolean checked) { - // 选择模式适配逻辑:仅普通便签在选择模式下展示勾选框,其他类型/模式均隐藏,防止误操作文件夹 + public void bind(Context context, NoteItemData data, boolean choiceMode, boolean checked, String searchQuery) { if (choiceMode && data.getType() == Notes.TYPE_NOTE) { mCheckBox.setVisibility(View.VISIBLE); mCheckBox.setChecked(checked); @@ -103,106 +68,146 @@ public class NotesListItem extends LinearLayout { mCheckBox.setVisibility(View.GONE); } - // 缓存当前绑定的业务数据模型,供后续背景设置与外部数据获取使用 mItemData = data; - - // ===== 分支一:通话记录专属文件夹(系统特殊固定ID),独立的展示样式 ===== if (data.getId() == Notes.ID_CALL_RECORD_FOLDER) { mCallName.setVisibility(View.GONE); mAlert.setVisibility(View.VISIBLE); mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); - // 标题展示规则:固定文件夹名称 + 文件夹内的通话记录数量,格式化展示 mTitle.setText(context.getString(R.string.call_record_folder_name) + context.getString(R.string.format_folder_files_count, data.getNotesCount())); - // 展示通话记录专属图标,明确业务类型标识 mAlert.setImageResource(R.drawable.call_record); - } - // ===== 分支二:通话记录子项(归属通话记录文件夹),通话类专属展示样式 ===== - else if (data.getParentId() == Notes.ID_CALL_RECORD_FOLDER) { + } else if (data.getId() == Notes.ID_TRASH_FOLER) { + mCallName.setVisibility(View.GONE); + mAlert.setVisibility(View.VISIBLE); + mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); + mTitle.setText("回收站" + + context.getString(R.string.format_folder_files_count, data.getNotesCount())); + // 使用现有的clock图标作为临时垃圾桶图标 + mAlert.setImageResource(R.drawable.clock); + } else if (data.getParentId() == Notes.ID_CALL_RECORD_FOLDER) { mCallName.setVisibility(View.VISIBLE); mCallName.setText(data.getCallName()); mTitle.setTextAppearance(context,R.style.TextAppearanceSecondaryItem); - // 标题展示格式化后的通话记录摘要内容,保证文本展示的规范性 - mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet())); - // 提醒状态适配:有闹钟提醒则展示闹钟图标,无则隐藏 + String formattedSnippet = DataUtils.getFormattedSnippet(data.getSnippet()); + mTitle.setText(highlightText(formattedSnippet, searchQuery, context)); if (data.hasAlert()) { mAlert.setImageResource(R.drawable.clock); mAlert.setVisibility(View.VISIBLE); } else { mAlert.setVisibility(View.GONE); } - } - // ===== 分支三:普通业务类型(普通文件夹/普通便签),通用展示样式 ===== - else { - mCallName.setVisibility(View.GONE); - mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); + } else { + mCallName.setVisibility(View.GONE); + mTitle.setTextAppearance(context, R.style.TextAppearancePrimaryItem); - // 子分支1:普通文件夹类型,展示文件夹名称+包含便签数量 - if (data.getType() == Notes.TYPE_FOLDER) { - mTitle.setText(data.getSnippet() - + context.getString(R.string.format_folder_files_count, - data.getNotesCount())); - mAlert.setVisibility(View.GONE); - } - // 子分支2:普通便签类型,展示便签核心摘要内容 - else { - mTitle.setText(DataUtils.getFormattedSnippet(data.getSnippet())); - // 提醒状态适配:有闹钟提醒展示闹钟图标,无则隐藏 - if (data.hasAlert()) { - mAlert.setImageResource(R.drawable.clock); - mAlert.setVisibility(View.VISIBLE); - } else { + if (data.getType() == Notes.TYPE_FOLDER) { + mTitle.setText(data.getSnippet() + + context.getString(R.string.format_folder_files_count, + data.getNotesCount())); mAlert.setVisibility(View.GONE); + } else { + // 显示标题,如果标题为空则显示内容摘要 + String displayText; + if (data.getTitle() != null && !data.getTitle().isEmpty()) { + displayText = data.getTitle(); + } else { + String formattedSnippet = DataUtils.getFormattedSnippet(data.getSnippet()); + displayText = formattedSnippet; + } + mTitle.setText(highlightText(displayText, searchQuery, context)); + if (data.hasAlert()) { + mAlert.setImageResource(R.drawable.clock); + mAlert.setVisibility(View.VISIBLE); + } else { + mAlert.setVisibility(View.GONE); + } } } - } - // 统一设置最后修改时间:将时间戳转为「几分钟前/昨天」等相对友好的时间格式,提升用户体验 mTime.setText(DateUtils.getRelativeTimeSpanString(data.getModifiedDate())); - // 根据当前绑定的数据模型,完成列表项背景样式的精准适配 + // 设置分类标签,优先使用标题进行分类 + String contentForCategory = data.getTitle(); + if (contentForCategory == null || contentForCategory.isEmpty()) { + contentForCategory = data.getSnippet(); + } + String category = net.micode.notes.tool.CategoryUtil.autoCategorize(contentForCategory); + mCategory.setText(category); + + // 处理锁定图标 + if (data.isLocked() && data.getType() == Notes.TYPE_NOTE) { + mLock.setVisibility(View.VISIBLE); + mLock.setImageResource(R.drawable.clock); // 使用clock图标作为锁图标 + } else { + mLock.setVisibility(View.GONE); + } + + // 处理公开图标 + if (data.isPublic() && data.getType() == Notes.TYPE_NOTE) { + mPublic.setVisibility(View.VISIBLE); + mPublic.setImageResource(R.drawable.call_record); // 使用call_record图标作为公开图标,与置顶图标区分 + } else { + mPublic.setVisibility(View.GONE); + } + + // 处理置顶和提醒图标 + if (data.isPinned() && data.getType() == Notes.TYPE_NOTE) { + mAlert.setVisibility(View.VISIBLE); + mAlert.setImageResource(R.drawable.selected); // 使用selected图标作为置顶图标 + } else if (data.hasAlert() && data.getType() == Notes.TYPE_NOTE) { + mAlert.setVisibility(View.VISIBLE); + mAlert.setImageResource(R.drawable.call_record); // 使用call_record图标作为提醒图标 + } else { + mAlert.setVisibility(View.GONE); + } + setBackground(data); } - /** - * 私有核心方法:列表项背景样式的统一适配处理 - * 核心业务逻辑:封装复杂的背景适配规则,区分普通便签与文件夹的不同背景策略; - * 普通便签根据列表中的位置(首项/中项/尾项/单独项)+ 背景色ID,匹配对应的背景资源; - * 文件夹统一使用固定背景,简化适配逻辑,保证列表样式的统一性与美观性 - * @param data 当前列表项绑定的业务数据模型,提供背景色ID、类型、列表位置等适配所需字段 - */ private void setBackground(NoteItemData data) { - // 获取当前数据模型的背景色标识ID,作为背景资源匹配的核心依据 int id = data.getBgColorId(); - - // 普通便签的背景适配逻辑:多场景精准匹配,保证列表连贯的视觉效果 if (data.getType() == Notes.TYPE_NOTE) { if (data.isSingle() || data.isOneFollowingFolder()) { - // 场景1:列表中唯一项 / 文件夹下的唯一项 → 使用独立完整背景 setBackgroundResource(NoteItemBgResources.getNoteBgSingleRes(id)); } else if (data.isLast()) { - // 场景2:列表中的最后一项 → 使用底部收尾样式背景 setBackgroundResource(NoteItemBgResources.getNoteBgLastRes(id)); } else if (data.isFirst() || data.isMultiFollowingFolder()) { - // 场景3:列表中的第一项 / 文件夹下的多项首项 → 使用顶部起始样式背景 setBackgroundResource(NoteItemBgResources.getNoteBgFirstRes(id)); } else { - // 场景4:列表中的中间项 → 使用标准连贯样式背景 setBackgroundResource(NoteItemBgResources.getNoteBgNormalRes(id)); } - } - // 文件夹类型统一适配逻辑:所有文件夹(含通话记录文件夹)使用固定背景样式 - else { + } else { setBackgroundResource(NoteItemBgResources.getFolderBgRes()); } } - /** - * 公开数据获取方法:获取当前列表项绑定的业务数据模型 - * 核心作用:为列表页提供数据访问接口,列表页可通过该方法获取选中项、点击项的业务数据,完成跳转/删除/编辑等后续操作; - * 是视图组件与业务逻辑之间的核心数据桥梁 - * @return NoteItemData 当前列表项绑定的完整业务数据模型 - */ + // 高亮匹配的文本 + private CharSequence highlightText(String text, String searchQuery, Context context) { + if (text == null || searchQuery == null || searchQuery.isEmpty()) { + return text; + } + + android.text.SpannableString spannable = new android.text.SpannableString(text); + try { + String lowerText = text.toLowerCase(); + String lowerQuery = searchQuery.toLowerCase(); + int startIndex = lowerText.indexOf(lowerQuery); + + while (startIndex != -1) { + int endIndex = startIndex + searchQuery.length(); + spannable.setSpan( + new android.text.style.BackgroundColorSpan(context.getResources().getColor(R.color.user_query_highlight)), + startIndex, endIndex, android.text.Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + startIndex = lowerText.indexOf(lowerQuery, endIndex); + } + } catch (Exception e) { + // 处理可能的异常,比如空指针或索引越界 + return text; + } + + return spannable; + } + public NoteItemData getItemData() { return mItemData; } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/NotesPreferenceActivity.java b/src/Notes-master/src/net/micode/notes/ui/NotesPreferenceActivity.java index 0151e15..3183386 100644 --- a/src/Notes-master/src/net/micode/notes/ui/NotesPreferenceActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/NotesPreferenceActivity.java @@ -14,176 +14,184 @@ * limitations under the License. */ -// 包声明:小米便签 核心UI业务模块,承载应用所有可视化交互页面,本类为应用设置页核心实现 package net.micode.notes.ui; -// -------------------------- 安卓系统核心依赖包 - 账号/页面/弹窗/广播/数据存储能力 -------------------------- -// 安卓账号体系核心类:封装Google账号的账号名、账号类型等基础信息载体 import android.accounts.Account; -// 安卓账号管理核心服务类:系统级账号管理器,负责设备上所有账号的查询、管理、鉴权等操作 import android.accounts.AccountManager; -// 安卓顶部导航栏核心类:配置页面导航样式、返回按钮、标题等ActionBar相关属性 import android.app.ActionBar; -// 安卓系统弹窗核心类:构建标准化的对话框,承载账号选择、确认提示等交互弹窗 import android.app.AlertDialog; -// 安卓广播核心组件:监听系统/应用内部的广播消息,本类用于监听同步服务状态变更广播 import android.content.BroadcastReceiver; -// 安卓数据封装类:封装键值对数据,用于ContentProvider执行数据库字段更新操作 import android.content.ContentValues; -// 安卓应用全局上下文:提供资源访问、组件通信、偏好设置读写等核心基础能力 import android.content.Context; -// 安卓对话框交互核心接口:监听弹窗选项的点击事件,处理用户选择逻辑 import android.content.DialogInterface; -// 安卓组件通信核心类:封装页面跳转指令、广播指令、参数传递,实现跨组件通信 import android.content.Intent; -// 安卓广播过滤核心类:筛选需要监听的广播动作,精准匹配目标广播消息 import android.content.IntentFilter; -// 安卓轻量级存储核心类:键值对持久化存储,用于保存应用偏好配置,非数据库存储 import android.content.SharedPreferences; -// 安卓页面状态存储类:保存页面销毁重建时的临时数据,保证页面状态不丢失 import android.os.Bundle; -// 安卓系统偏好设置组件:构建设置页面的标准化UI控件,封装设置项的展示与交互逻辑 import android.preference.Preference; import android.preference.Preference.OnPreferenceClickListener; import android.preference.PreferenceActivity; import android.preference.PreferenceCategory; -// 安卓文本工具类:封装字符串判空、内容对比等常用操作,避免空指针与硬编码判断 import android.text.TextUtils; -// 安卓系统日期格式化工具类:标准化格式化时间戳为指定样式的字符串,适配多语言展示 import android.text.format.DateFormat; -// 安卓布局加载核心类:将xml布局文件解析为Java视图对象,加载自定义布局与弹窗样式 import android.view.LayoutInflater; -// 安卓页面菜单核心类:配置ActionBar右侧菜单的创建与点击事件处理 import android.view.Menu; import android.view.MenuItem; -// 安卓视图体系核心类:操作所有可视化控件的父类,实现控件的点击、赋值、显隐等操作 import android.view.View; import android.widget.Button; +import android.widget.EditText; import android.widget.TextView; -// 安卓轻量级提示组件:展示短时操作结果提示,无焦点不阻塞用户交互 import android.widget.Toast; -// -------------------------- 小米便签业务层核心依赖 - 资源/数据/同步服务 -------------------------- -// 小米便签资源常量类:统一管理布局、字符串、颜色、样式等所有本地资源ID引用 import net.micode.notes.R; -// 小米便签数据层核心常量类:定义便签ContentProvider URI、数据表字段、业务常量等核心配置 import net.micode.notes.data.Notes; import net.micode.notes.data.Notes.NoteColumns; -// 小米便签核心同步服务类:封装便签与Google Task的双向同步逻辑,提供同步启停、状态查询等核心能力 import net.micode.notes.gtask.remote.GTaskSyncService; + /** - * 小米便签 应用核心设置页面Activity - *

- * 继承安卓系统{PreferenceActivity},隶属于MVC架构的UI层核心业务页面,是便签应用的全局配置中心; - * 核心设计定位:统一承载应用所有用户可配置项,聚焦「Google账号绑定与GTask云同步」核心能力,兼顾基础偏好配置; - * 核心业务职责:Google账号的绑定/切换/移除管理、手动同步/取消同步操作、同步状态实时展示、同步时间持久化、 - * 偏好配置读写、同步服务广播监听、页面导航与交互反馈,是便签应用云同步能力的唯一配置入口; - * 技术实现特点:通过SharedPreferences实现轻量级配置持久化,通过广播接收器实现同步状态实时刷新, - * 通过异步线程处理数据库更新避免主线程阻塞,通过系统账号管理器实现Google账号的标准化管理。 - *

+ * 便签设置活动类 + * 负责管理应用的各种设置选项,包括账户同步、密码设置、好友管理和背景更换等功能 + * 提供用户界面来配置应用行为和个性化选项 + * + * 架构设计: + * - 继承自PreferenceActivity,使用传统偏好设置界面 + * - 支持Google账户同步配置 + * - 集成密码设置功能,保护用户隐私 + * - 提供好友管理入口 + * - 支持背景更换功能 + * + * 核心功能: + * - 账户同步管理:添加、修改、删除Google账户 + * - 密码设置:设置和修改应用密码 + * - 好友管理:进入好友管理界面 + * - 背景更换:进入背景设置界面 + * - 同步状态显示:显示上次同步时间和同步进度 */ public class NotesPreferenceActivity extends PreferenceActivity { /** - * 全局常量:SharedPreferences偏好设置存储文件名,应用所有配置项均持久化存储在该文件中,单文件统一管理 + * 偏好设置文件名 */ public static final String PREFERENCE_NAME = "notes_preferences"; + /** - * 全局常量:偏好配置存储键 - 当前绑定的Google同步账号名,核心云同步关联标识 + * 同步账户名偏好键 */ public static final String PREFERENCE_SYNC_ACCOUNT_NAME = "pref_key_account_name"; + /** - * 全局常量:偏好配置存储键 - 最后一次同步成功的时间戳,用于展示同步历史记录 + * 上次同步时间偏好键 */ public static final String PREFERENCE_LAST_SYNC_TIME = "pref_last_sync_time"; + /** - * 全局常量:偏好配置存储键 - 便签背景色随机展示开关,控制新建便签的背景色展示策略 + * 背景颜色设置偏好键 */ public static final String PREFERENCE_SET_BG_COLOR_KEY = "pref_key_bg_random_appear"; /** - * 私有常量:偏好设置分类标识 - 账号同步相关配置项的分类容器Key,用于页面UI组件定位 + * 密码偏好键 */ - private static final String PREFERENCE_SYNC_ACCOUNT_KEY = "pref_sync_account_key"; + public static final String PREFERENCE_PASSWORD_KEY = "pref_key_password"; + /** - * 私有常量:系统账号过滤标识 - 添加新账号时的权限过滤关键字,用于精准筛选Google账号类型 + * 密码设置状态偏好键 */ + public static final String PREFERENCE_PASSWORD_SET_KEY = "pref_key_password_set"; + + private static final String PREFERENCE_SYNC_ACCOUNT_KEY = "pref_sync_account_key"; + private static final String PREFERENCE_PASSWORD_SETTING_KEY = "pref_password_setting_key"; + private static final String AUTHORITIES_FILTER_KEY = "authorities"; - /** - * 页面核心控件:账号同步相关的偏好设置分类容器,承载账号选择配置项的父组件 - */ private PreferenceCategory mAccountCategory; - /** - * 核心广播接收器:监听GTaskSyncService的同步状态广播,实现同步中/同步完成的UI实时刷新 - */ + private GTaskReceiver mReceiver; - /** - * 账号缓存数组:存储操作前的设备Google账号列表,用于对比判断是否新增账号 - */ + private Account[] mOriAccounts; - /** - * 状态标记位:标记是否触发了系统添加账号的操作,用于页面恢复时的账号自动绑定逻辑 - */ + private boolean mHasAddedAccount; - /** - * 重写页面生命周期:页面创建初始化入口方法 - * 执行时机:页面第一次被创建时调用,仅执行一次 - * 核心初始化逻辑:配置ActionBar导航样式、加载偏好设置页面布局、初始化核心控件、注册同步状态广播接收器、 - * 添加页面自定义头部布局,完成页面所有基础初始化工作,为后续交互做准备 - * @param icicle 页面状态存储Bundle,恢复重建时的临时数据载体 - */ @Override protected void onCreate(Bundle icicle) { super.onCreate(icicle); - /* 配置ActionBar导航:启用左上角返回按钮,跳转回便签列表页 */ + /* using the app icon for navigation */ getActionBar().setDisplayHomeAsUpEnabled(true); - // 从xml资源加载设置页面的标准化偏好配置UI结构,页面主体内容初始化 addPreferencesFromResource(R.xml.preferences); - // 根据标识获取账号同步分类容器控件,完成核心控件绑定 mAccountCategory = (PreferenceCategory) findPreference(PREFERENCE_SYNC_ACCOUNT_KEY); - // 初始化同步状态广播接收器,注册监听同步服务的状态变更广播 + + // Add password setting preference + Preference passwordPreference = new Preference(this); + passwordPreference.setTitle(getString(R.string.preferences_password_title)); + passwordPreference.setSummary(getString(R.string.preferences_password_summary)); + passwordPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + public boolean onPreferenceClick(Preference preference) { + showPasswordSettingDialog(); + return true; + } + }); + + // Add friend management preference + Preference friendPreference = new Preference(this); + friendPreference.setTitle("好友"); + friendPreference.setSummary("管理和查看好友的公开便签"); + friendPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + public boolean onPreferenceClick(Preference preference) { + // 启动好友管理活动 + Intent intent = new Intent(NotesPreferenceActivity.this, FriendManagementActivity.class); + startActivity(intent); + return true; + } + }); + + // Add change background preference + Preference changeBackgroundPreference = new Preference(this); + changeBackgroundPreference.setTitle("更换背景"); + changeBackgroundPreference.setSummary("更换便签界面的背景图片"); + changeBackgroundPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + public boolean onPreferenceClick(Preference preference) { + // 直接启动NoteEditActivity并传递更换背景的标志 + Intent intent = new Intent(NotesPreferenceActivity.this, NoteEditActivity.class); + intent.putExtra("CHANGE_BACKGROUND", true); + startActivity(intent); + return true; + } + }); + + PreferenceCategory generalCategory = (PreferenceCategory) getPreferenceScreen().getPreference(1); + generalCategory.addPreference(passwordPreference); + generalCategory.addPreference(friendPreference); + generalCategory.addPreference(changeBackgroundPreference); + mReceiver = new GTaskReceiver(); IntentFilter filter = new IntentFilter(); filter.addAction(GTaskSyncService.GTASK_SERVICE_BROADCAST_NAME); registerReceiver(mReceiver, filter); - // 初始化账号缓存数组与状态标记位,默认无账号无新增操作 mOriAccounts = null; - mHasAddedAccount = false; - // 加载自定义页面头部布局并添加至列表顶部,丰富页面视觉层级 View header = LayoutInflater.from(this).inflate(R.layout.settings_header, null); getListView().addHeaderView(header, null, true); } - /** - * 重写页面生命周期:页面恢复可见状态回调方法 - * 执行时机:页面从后台切回前台、弹窗关闭后、页面创建后首次展示时调用 - * 核心业务逻辑:检测是否触发过添加账号操作,若有则自动绑定新增的Google账号;刷新页面所有UI控件状态, - * 保证页面展示的账号信息、同步按钮、同步状态均为最新数据,是页面数据一致性的核心保障 - */ @Override protected void onResume() { super.onResume(); - // 检测添加账号标记位,处理新增账号的自动绑定逻辑 + // need to set sync account automatically if user has added a new + // account if (mHasAddedAccount) { Account[] accounts = getGoogleAccounts(); - // 对比操作前后的账号数量,判断是否有新账号添加成功 if (mOriAccounts != null && accounts.length > mOriAccounts.length) { for (Account accountNew : accounts) { boolean found = false; - // 遍历原始账号列表,过滤已存在的账号 for (Account accountOld : mOriAccounts) { if (TextUtils.equals(accountOld.name, accountNew.name)) { found = true; break; } } - // 匹配到新增账号,自动完成绑定并终止遍历 if (!found) { setSyncAccount(accountNew.name); break; @@ -192,53 +200,36 @@ public class NotesPreferenceActivity extends PreferenceActivity { } } - // 统一刷新页面所有UI组件状态,保证数据与视图一致 refreshUI(); } - /** - * 重写页面生命周期:页面销毁回收资源回调方法 - * 执行时机:页面退出、被销毁时调用,仅执行一次 - * 核心优化逻辑:注销已注册的广播接收器,释放系统资源,防止内存泄漏,是安卓组件开发的必做优化项 - */ @Override protected void onDestroy() { - // 安全注销广播接收器,判空避免空指针异常 if (mReceiver != null) { unregisterReceiver(mReceiver); } super.onDestroy(); } - /** - * 私有核心方法:加载并初始化账号同步偏好配置项 - * 核心业务逻辑:动态构建账号选择配置项,绑定点击事件,实现「未绑定账号展示选择弹窗、已绑定账号展示变更弹窗」的交互逻辑, - * 同步中禁用账号操作防止数据异常,是账号管理能力的核心实现 - */ private void loadAccountPreference() { - // 清空分类容器内原有配置项,避免重复添加导致的UI重复展示问题 mAccountCategory.removeAll(); - // 新建账号选择偏好配置项,初始化展示文案与交互行为 Preference accountPref = new Preference(this); final String defaultAccount = getSyncAccountName(this); accountPref.setTitle(getString(R.string.preferences_account_title)); accountPref.setSummary(getString(R.string.preferences_account_summary)); - - // 绑定配置项点击事件,处理账号选择与变更的核心交互逻辑 accountPref.setOnPreferenceClickListener(new OnPreferenceClickListener() { public boolean onPreferenceClick(Preference preference) { - // 同步中禁止账号操作,防止同步数据与账号信息不一致导致异常 if (!GTaskSyncService.isSyncing()) { if (TextUtils.isEmpty(defaultAccount)) { - // 未绑定账号:展示账号选择弹窗,供用户选择绑定 + // the first time to set account showSelectAccountAlertDialog(); } else { - // 已绑定账号:展示账号变更确认弹窗,提示风险并提供操作选项 + // if the account has already been set, we need to promp + // user about the risk showChangeAccountConfirmAlertDialog(); } } else { - // 同步中操作拦截,展示友好的吐司提示 Toast.makeText(NotesPreferenceActivity.this, R.string.preferences_toast_cannot_change_account, Toast.LENGTH_SHORT) .show(); @@ -247,23 +238,15 @@ public class NotesPreferenceActivity extends PreferenceActivity { } }); - // 将配置项添加至分类容器,完成UI渲染 mAccountCategory.addPreference(accountPref); } - /** - * 私有核心方法:加载并初始化同步按钮与同步状态展示控件 - * 核心业务逻辑:根据同步服务的运行状态,动态切换按钮文本与点击事件(立即同步/取消同步); - * 展示同步进度或最后同步时间;无绑定账号时禁用同步按钮,是同步能力的核心交互入口实现 - */ private void loadSyncButton() { - // 获取页面同步按钮与同步状态文本控件,完成视图绑定 Button syncButton = (Button) findViewById(R.id.preference_sync_button); TextView lastSyncTimeView = (TextView) findViewById(R.id.prefenerece_sync_status_textview); - // 根据同步服务状态,动态配置按钮行为与文本 + // set button state if (GTaskSyncService.isSyncing()) { - // 同步中状态:按钮文本为取消同步,点击触发同步终止逻辑 syncButton.setText(getString(R.string.preferences_button_sync_cancel)); syncButton.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { @@ -271,7 +254,6 @@ public class NotesPreferenceActivity extends PreferenceActivity { } }); } else { - // 未同步状态:按钮文本为立即同步,点击触发同步启动逻辑 syncButton.setText(getString(R.string.preferences_button_sync_immediately)); syncButton.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { @@ -279,16 +261,13 @@ public class NotesPreferenceActivity extends PreferenceActivity { } }); } - // 无绑定账号时禁用同步按钮,避免无账号同步的无效操作 syncButton.setEnabled(!TextUtils.isEmpty(getSyncAccountName(this))); - // 配置同步状态文本展示逻辑,区分同步中与历史同步记录 + // set last sync time if (GTaskSyncService.isSyncing()) { - // 同步中:展示实时同步进度文案 lastSyncTimeView.setText(GTaskSyncService.getProgressString()); lastSyncTimeView.setVisibility(View.VISIBLE); } else { - // 未同步:读取最后同步时间戳,展示格式化的历史同步记录 long lastSyncTime = getLastSyncTime(this); if (lastSyncTime != 0) { lastSyncTimeView.setText(getString(R.string.preferences_last_sync_time, @@ -296,185 +275,137 @@ public class NotesPreferenceActivity extends PreferenceActivity { lastSyncTime))); lastSyncTimeView.setVisibility(View.VISIBLE); } else { - // 无同步记录:隐藏状态文本,简化页面展示 lastSyncTimeView.setVisibility(View.GONE); } } } - /** - * 私有统一刷新方法:页面UI状态刷新总入口 - * 核心职责:统一调用账号配置项与同步按钮的加载方法,实现页面所有核心控件的状态刷新, - * 简化多处刷新逻辑的调用成本,保证刷新行为的一致性 - */ private void refreshUI() { loadAccountPreference(); loadSyncButton(); } - /** - * 私有弹窗方法:展示Google账号选择对话框 - * 核心业务逻辑:展示设备上已有的Google账号列表供用户选择绑定;提供添加新账号入口跳转系统设置; - * 选择账号后自动完成绑定并刷新页面,是账号绑定的核心交互弹窗实现 - */ - private void showSelectAccountAlertDialog() { - AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); - - // 加载自定义弹窗标题布局,初始化弹窗头部文案 - View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); - TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); - titleTextView.setText(getString(R.string.preferences_dialog_select_account_title)); - TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); - subtitleTextView.setText(getString(R.string.preferences_dialog_select_account_tips)); - - dialogBuilder.setCustomTitle(titleView); - dialogBuilder.setPositiveButton(null, null); - - // 获取设备上所有Google账号,构建账号选择列表 - Account[] accounts = getGoogleAccounts(); - String defAccount = getSyncAccountName(this); - - // 缓存当前账号列表,用于后续新增账号判断 - mOriAccounts = accounts; - mHasAddedAccount = false; - - // 存在Google账号时,构建单选列表供用户选择 - if (accounts.length > 0) { - CharSequence[] items = new CharSequence[accounts.length]; - final CharSequence[] itemMapping = items; - int checkedItem = -1; - int index = 0; - for (Account account : accounts) { - if (TextUtils.equals(account.name, defAccount)) { - checkedItem = index; - } - items[index++] = account.name; - } - // 绑定列表选择事件,选择后完成账号绑定并关闭弹窗 - dialogBuilder.setSingleChoiceItems(items, checkedItem, - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - setSyncAccount(itemMapping[which].toString()); - dialog.dismiss(); - refreshUI(); - } - }); - } - - // 加载添加新账号的自定义视图,提供账号新增入口 - View addAccountView = LayoutInflater.from(this).inflate(R.layout.add_account_text, null); - dialogBuilder.setView(addAccountView); - - // 展示弹窗并绑定添加账号点击事件,跳转系统账号添加页面 - final AlertDialog dialog = dialogBuilder.show(); - addAccountView.setOnClickListener(new View.OnClickListener() { - public void onClick(View v) { - mHasAddedAccount = true; - Intent intent = new Intent("android.settings.ADD_ACCOUNT_SETTINGS"); - intent.putExtra(AUTHORITIES_FILTER_KEY, new String[] { - "gmail-ls" - }); - startActivityForResult(intent, -1); - dialog.dismiss(); - } - }); - } + private void showSelectAccountAlertDialog() { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); - /** - * 私有弹窗方法:展示账号变更/移除确认对话框 - * 核心业务逻辑:针对已绑定账号的场景,展示风险提示与操作选项(更改账号/移除账号/取消); - * 处理账号变更与解绑逻辑,是账号管理的核心确认交互弹窗实现 - */ - private void showChangeAccountConfirmAlertDialog() { - AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); - - // 加载自定义弹窗标题布局,展示当前绑定账号与风险提示 - View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); - TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); - titleTextView.setText(getString(R.string.preferences_dialog_change_account_title, - getSyncAccountName(this))); - TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); - subtitleTextView.setText(getString(R.string.preferences_dialog_change_account_warn_msg)); - dialogBuilder.setCustomTitle(titleView); - - // 构建弹窗操作选项,绑定点击事件处理不同操作逻辑 - CharSequence[] menuItemArray = new CharSequence[] { - getString(R.string.preferences_menu_change_account), - getString(R.string.preferences_menu_remove_account), - getString(R.string.preferences_menu_cancel) - }; - dialogBuilder.setItems(menuItemArray, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int which) { - if (which == 0) { - // 选择更改账号:跳转账号选择弹窗 - showSelectAccountAlertDialog(); - } else if (which == 1) { - // 选择移除账号:执行账号解绑逻辑 - removeSyncAccount(); - refreshUI(); - } - } - }); - dialogBuilder.show(); - } + View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); + TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); + titleTextView.setText(getString(R.string.preferences_dialog_select_account_title)); + TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); + subtitleTextView.setText(getString(R.string.preferences_dialog_select_account_tips)); + + dialogBuilder.setCustomTitle(titleView); + dialogBuilder.setPositiveButton(null, null); + + Account[] accounts = getGoogleAccounts(); + String defAccount = getSyncAccountName(this); + + mOriAccounts = accounts; + mHasAddedAccount = false; + + if (accounts.length > 0) { + CharSequence[] items = new CharSequence[accounts.length]; + final CharSequence[] itemMapping = items; + int checkedItem = -1; + int index = 0; + for (Account account : accounts) { + if (TextUtils.equals(account.name, defAccount)) { + checkedItem = index; + } + items[index++] = account.name; + } + dialogBuilder.setSingleChoiceItems(items, checkedItem, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + setSyncAccount(itemMapping[which].toString()); + dialog.dismiss(); + refreshUI(); + } + }); + } + + View addAccountView = LayoutInflater.from(this).inflate(R.layout.add_account_text, null); + dialogBuilder.setView(addAccountView); + + final AlertDialog dialog = dialogBuilder.show(); + addAccountView.setOnClickListener(new View.OnClickListener() { + public void onClick(View v) { + mHasAddedAccount = true; + Intent intent = new Intent("android.settings.ADD_ACCOUNT_SETTINGS"); + intent.putExtra(AUTHORITIES_FILTER_KEY, new String[] { + "gmail-ls" + }); + startActivityForResult(intent, -1); + dialog.dismiss(); + } + }); + } + + private void showChangeAccountConfirmAlertDialog() { + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); + + View titleView = LayoutInflater.from(this).inflate(R.layout.account_dialog_title, null); + TextView titleTextView = (TextView) titleView.findViewById(R.id.account_dialog_title); + titleTextView.setText(getString(R.string.preferences_dialog_change_account_title, + getSyncAccountName(this))); + TextView subtitleTextView = (TextView) titleView.findViewById(R.id.account_dialog_subtitle); + subtitleTextView.setText(getString(R.string.preferences_dialog_change_account_warn_msg)); + dialogBuilder.setCustomTitle(titleView); + + CharSequence[] menuItemArray = new CharSequence[] { + getString(R.string.preferences_menu_change_account), + getString(R.string.preferences_menu_remove_account), + getString(R.string.preferences_menu_cancel) + }; + dialogBuilder.setItems(menuItemArray, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + if (which == 0) { + showSelectAccountAlertDialog(); + } else if (which == 1) { + removeSyncAccount(); + refreshUI(); + } + } + }); + dialogBuilder.show(); + } - /** - * 私有工具方法:获取设备上所有已登录的Google账号列表 - * 核心实现:通过系统账号管理器,根据Google账号类型精准筛选,返回纯净的Google账号数组, - * 是账号管理能力的基础数据来源 - * @return Account[] 设备上所有com.google类型的账号数组,无账号则返回空数组 - */ private Account[] getGoogleAccounts() { AccountManager accountManager = AccountManager.get(this); return accountManager.getAccountsByType("com.google"); } - /** - * 私有核心方法:设置当前绑定的Google同步账号 - * 核心业务逻辑:更新偏好配置中的账号信息、清空历史同步时间、异步清理本地便签的同步关联字段、 - * 展示操作成功提示;账号未变化时不执行任何操作,避免无效处理,是账号绑定的核心业务逻辑实现 - * @param account 待绑定的Google账号名 - */ - private void setSyncAccount(String account) { - // 账号未发生变化时,直接返回避免无效操作 - if (!getSyncAccountName(this).equals(account)) { - // 写入偏好配置,持久化存储绑定的账号名 - SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = settings.edit(); - if (account != null) { - editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, account); - } else { - editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, ""); - } - editor.commit(); - - // 账号变更后清空历史同步时间,保证同步记录的准确性 - setLastSyncTime(this, 0); - - // 异步线程清理本地便签的同步关联字段,避免主线程阻塞导致页面卡顿 - new Thread(new Runnable() { - public void run() { - ContentValues values = new ContentValues(); - values.put(NoteColumns.GTASK_ID, ""); - values.put(NoteColumns.SYNC_ID, 0); - getContentResolver().update(Notes.CONTENT_NOTE_URI, values, null, null); - } - }).start(); - - // 展示账号绑定成功的友好提示 - Toast.makeText(NotesPreferenceActivity.this, - getString(R.string.preferences_toast_success_set_accout, account), - Toast.LENGTH_SHORT).show(); - } - } + private void setSyncAccount(String account) { + if (!getSyncAccountName(this).equals(account)) { + SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = settings.edit(); + if (account != null) { + editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, account); + } else { + editor.putString(PREFERENCE_SYNC_ACCOUNT_NAME, ""); + } + editor.commit(); + + // clean up last sync time + setLastSyncTime(this, 0); + + // clean up local gtask related info + new Thread(new Runnable() { + public void run() { + ContentValues values = new ContentValues(); + values.put(NoteColumns.GTASK_ID, ""); + values.put(NoteColumns.SYNC_ID, 0); + getContentResolver().update(Notes.CONTENT_NOTE_URI, values, null, null); + } + }).start(); + + Toast.makeText(NotesPreferenceActivity.this, + getString(R.string.preferences_toast_success_set_accout, account), + Toast.LENGTH_SHORT).show(); + } + } - /** - * 私有核心方法:移除当前绑定的Google同步账号,完成账号解绑 - * 核心业务逻辑:清理偏好配置中的账号名与同步时间、异步清理本地便签的同步关联字段, - * 彻底解除本地便签与原账号的云同步关联,是账号解绑的核心业务逻辑实现 - */ private void removeSyncAccount() { - // 读取偏好配置并清理账号与同步时间相关配置项 SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); SharedPreferences.Editor editor = settings.edit(); if (settings.contains(PREFERENCE_SYNC_ACCOUNT_NAME)) { @@ -485,7 +416,7 @@ public class NotesPreferenceActivity extends PreferenceActivity { } editor.commit(); - // 异步线程清理本地便签的同步关联字段,释放系统资源 + // clean up local gtask related info new Thread(new Runnable() { public void run() { ContentValues values = new ContentValues(); @@ -496,24 +427,12 @@ public class NotesPreferenceActivity extends PreferenceActivity { }).start(); } - /** - * 公开静态工具方法:获取当前绑定的Google同步账号名 - * 全局通用能力:供应用其他组件调用,获取同步账号信息,解耦配置读取逻辑 - * @param context 应用上下文,用于访问偏好配置 - * @return String 当前绑定的账号名,无绑定则返回空字符串 - */ public static String getSyncAccountName(Context context) { SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); return settings.getString(PREFERENCE_SYNC_ACCOUNT_NAME, ""); } - /** - * 公开静态工具方法:设置最后一次同步的时间戳 - * 全局通用能力:供同步服务调用,持久化同步完成时间,解耦配置写入逻辑 - * @param context 应用上下文,用于访问偏好配置 - * @param time 同步完成的时间戳(毫秒值) - */ public static void setLastSyncTime(Context context, long time) { SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); @@ -522,37 +441,104 @@ public class NotesPreferenceActivity extends PreferenceActivity { editor.commit(); } - /** - * 公开静态工具方法:获取最后一次同步的时间戳 - * 全局通用能力:供应用其他组件调用,读取同步历史时间,解耦配置读取逻辑 - * @param context 应用上下文,用于访问偏好配置 - * @return long 最后同步时间戳,无记录则返回0 - */ public static long getLastSyncTime(Context context) { SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); return settings.getLong(PREFERENCE_LAST_SYNC_TIME, 0); } - /** - * 内部私有广播接收器类:监听GTask同步服务的状态变更广播 - * 核心设计定位:页面与同步服务的通信桥梁,无耦合监听同步状态变化; - * 核心职责:接收同步中/同步完成的广播消息,触发页面UI刷新,展示实时同步进度, - * 是同步状态实时更新的核心实现,生命周期与宿主页面一致 - */ + public static boolean isPasswordSet(Context context) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME, + Context.MODE_PRIVATE); + return settings.getBoolean(PREFERENCE_PASSWORD_SET_KEY, false); + } + + public static String getPassword(Context context) { + SharedPreferences settings = context.getSharedPreferences(PREFERENCE_NAME, + Context.MODE_PRIVATE); + return settings.getString(PREFERENCE_PASSWORD_KEY, ""); + } + + private void setPassword(String password) { + SharedPreferences settings = getSharedPreferences(PREFERENCE_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = settings.edit(); + editor.putString(PREFERENCE_PASSWORD_KEY, password); + editor.putBoolean(PREFERENCE_PASSWORD_SET_KEY, !TextUtils.isEmpty(password)); + editor.commit(); + } + + private void showPasswordSettingDialog() { + final boolean hasPassword = isPasswordSet(this); + + View view = LayoutInflater.from(this).inflate(R.layout.password_setting_dialog, null); + final EditText currentPasswordEdit = (EditText) view.findViewById(R.id.current_password); + final EditText newPasswordEdit = (EditText) view.findViewById(R.id.new_password); + final EditText confirmPasswordEdit = (EditText) view.findViewById(R.id.confirm_password); + + final TextView currentPasswordLabel = (TextView) view.findViewById(R.id.current_password_label); + + if (!hasPassword) { + currentPasswordLabel.setVisibility(View.GONE); + currentPasswordEdit.setVisibility(View.GONE); + } + + AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(this); + dialogBuilder.setTitle(hasPassword ? getString(R.string.preferences_password_change_title) : getString(R.string.preferences_password_set_title)); + dialogBuilder.setView(view); + + dialogBuilder.setPositiveButton(getString(R.string.preferences_button_confirm), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + String currentPassword = currentPasswordEdit.getText().toString(); + String newPassword = newPasswordEdit.getText().toString(); + String confirmPassword = confirmPasswordEdit.getText().toString(); + + if (hasPassword) { + // 验证当前密码 + if (!currentPassword.equals(getPassword(NotesPreferenceActivity.this))) { + Toast.makeText(NotesPreferenceActivity.this, + getString(R.string.preferences_password_incorrect), + Toast.LENGTH_SHORT).show(); + return; + } + } + + // 验证新密码和确认密码 + if (TextUtils.isEmpty(newPassword)) { + Toast.makeText(NotesPreferenceActivity.this, + getString(R.string.preferences_password_empty), + Toast.LENGTH_SHORT).show(); + return; + } + + if (!newPassword.equals(confirmPassword)) { + Toast.makeText(NotesPreferenceActivity.this, + getString(R.string.preferences_password_not_match), + Toast.LENGTH_SHORT).show(); + return; + } + + // 设置密码 + setPassword(newPassword); + Toast.makeText(NotesPreferenceActivity.this, + getString(R.string.preferences_password_set_success), + Toast.LENGTH_SHORT).show(); + } + }); + + dialogBuilder.setNegativeButton(getString(R.string.preferences_button_cancel), new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + } + }); + + dialogBuilder.show(); + } + private class GTaskReceiver extends BroadcastReceiver { - /** - * 重写广播接收回调方法:处理同步服务的状态广播 - * 核心逻辑:接收到广播后立即刷新页面UI,同步中时更新进度文本,保证页面状态与同步服务一致 - * @param context 广播上下文对象 - * @param intent 携带同步状态与进度的广播意图 - */ @Override public void onReceive(Context context, Intent intent) { - // 刷新页面所有UI控件,同步最新状态 refreshUI(); - // 同步中状态:更新实时同步进度文本 if (intent.getBooleanExtra(GTaskSyncService.GTASK_SERVICE_BROADCAST_IS_SYNCING, false)) { TextView syncStatus = (TextView) findViewById(R.id.prefenerece_sync_status_textview); syncStatus.setText(intent @@ -562,17 +548,9 @@ public class NotesPreferenceActivity extends PreferenceActivity { } } - /** - * 重写ActionBar菜单点击事件处理方法 - * 核心业务逻辑:处理左上角返回按钮的点击事件,跳转回便签列表主页面并清除顶部Activity栈, - * 避免返回时重复创建页面,优化用户导航体验,是页面导航的核心实现 - * @param item 被点击的菜单项对象 - * @return boolean 是否成功处理该点击事件 - */ public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case android.R.id.home: - // 构建返回主页面的意图,清除顶部栈保证页面唯一性 Intent intent = new Intent(this, NotesListActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(intent); @@ -581,4 +559,4 @@ public class NotesPreferenceActivity extends PreferenceActivity { return false; } } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/ui/SplashActivity.java b/src/Notes-master/src/net/micode/notes/ui/SplashActivity.java index f3d7a3f..35a3b4a 100644 --- a/src/Notes-master/src/net/micode/notes/ui/SplashActivity.java +++ b/src/Notes-master/src/net/micode/notes/ui/SplashActivity.java @@ -10,6 +10,12 @@ import android.widget.LinearLayout; import net.micode.notes.R; +/** + * 开机动画活动 + *

+ * 该类负责显示应用启动时的开机动画,使用淡入淡出动画效果展示应用Logo, + * 然后延迟跳转到登录界面。 + */ public class SplashActivity extends Activity { private static final int SPLASH_DISPLAY_DURATION = 2000; // 2秒 diff --git a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider.java b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider.java index fd6f02f..3ad7508 100644 --- a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider.java +++ b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider.java @@ -14,88 +14,61 @@ * limitations under the License. */ -// 包声明:小米便签 桌面小部件功能模块,该包统管所有小部件基类与不同规格的实现子类 package net.micode.notes.widget; - -// -------------------------- 安卓系统核心依赖包 - 桌面小部件基础能力 -------------------------- -// 安卓延迟意图类:小部件跨进程点击事件核心载体,桌面进程通过该类触发应用内页面跳转,异步执行意图逻辑 import android.app.PendingIntent; -// 安卓系统桌面小部件核心管理类:负责小部件的创建、更新、销毁、状态维护等全生命周期系统调度 import android.appwidget.AppWidgetManager; -// 安卓系统小部件基类:所有桌面小部件的标准父类,封装系统层小部件生命周期回调方法 import android.appwidget.AppWidgetProvider; -// 安卓数据封装类:封装ContentProvider的更新数据键值对,用于便签数据的字段更新操作 import android.content.ContentValues; -// 安卓应用全局上下文:提供资源访问、ContentResolver获取、系统服务调用等核心能力,小部件核心依赖 import android.content.Context; -// 安卓组件通信核心类:封装页面跳转指令与传递参数,用于小部件点击后的页面跳转逻辑 import android.content.Intent; -// 安卓数据库游标类:承载ContentProvider的查询结果集,按需读取便签数据,用完需手动释放资源 import android.database.Cursor; -// 安卓系统日志工具类:输出调试与异常日志,便于小部件功能的问题定位与线上排查 import android.util.Log; -// 安卓远程视图类:小部件核心UI载体,因小部件运行在桌面系统进程,需通过该类跨进程渲染UI布局与数据 import android.widget.RemoteViews; -// -------------------------- 小米便签业务层核心依赖 - 资源/数据/页面/工具 -------------------------- -// 小米便签资源常量类:统一管理布局、字符串、图片、颜色等所有本地资源ID引用 import net.micode.notes.R; -// 小米便签数据层核心常量类:定义便签URI、意图参数、业务状态、小部件类型等全局核心常量,数据层与UI层通用 import net.micode.notes.data.Notes; -// 小米便签数据表字段子类:简化便签数据库表的列名引用,避免硬编码,提升代码可维护性 import net.micode.notes.data.Notes.NoteColumns; -// 小米便签资源解析工具类:封装多规格小部件背景资源的映射适配逻辑,统一提供不同尺寸的背景资源获取能力 import net.micode.notes.tool.ResourceParser; -// 小米便签核心业务页面:便签新建/编辑页面,小部件点击跳转的核心目标页面 import net.micode.notes.ui.NoteEditActivity; -// 小米便签核心业务页面:便签列表展示页面,隐私模式下小部件点击的跳转目标页面 - import net.micode.notes.ui.NotesListActivity; /** - * 小米便签 桌面小部件通用抽象基类 + * 便签小部件提供者抽象基类 *

- * 继承安卓系统标准基类{AppWidgetProvider},隶属于MVC架构的UI层核心组件,是所有尺寸规格便签小部件的父类; - * 核心设计理念:采用「模板方法设计模式」,封装所有小部件的通用核心业务逻辑与生命周期管理,下沉共性能力, - * 把与尺寸强相关的差异化适配逻辑抽离为抽象方法,交由子类{NoteWidgetProvider_2x}/{NoteWidgetProvider_4x}实现; - * 统一承载能力:小部件删除时的关联数据清理、小部件更新时的通用数据查询/UI渲染/点击事件绑定、隐私模式适配、资源释放等; - * 本类为抽象类,无法实例化,仅作为子类的标准化模板与能力基座。 - *

+ * 该类是所有便签小部件的基类,定义了小部件的基本行为和抽象方法。 + * 它继承自AppWidgetProvider,负责处理小部件的更新、删除等操作。 */ public abstract class NoteWidgetProvider extends AppWidgetProvider { /** - * 数据库查询投影数组:指定查询便签表的核心业务字段,按需查询,减少无效字段的内存占用与查询耗时 - * 投影字段严格匹配业务需求,仅查询小部件展示所需的核心数据,无冗余字段 + * 查询小部件信息的投影列 */ public static final String [] PROJECTION = new String [] { - NoteColumns.ID, // 便签数据表-主键ID,唯一标识单条便签数据 - NoteColumns.BG_COLOR_ID, // 便签数据表-背景色标识ID,用于匹配小部件对应背景资源 - NoteColumns.SNIPPET // 便签数据表-内容摘要,小部件UI上展示的核心文本内容 + NoteColumns.ID, + NoteColumns.BG_COLOR_ID, + NoteColumns.SNIPPET }; - // 投影数组对应的列索引常量:固化查询结果集的字段下标,简化Cursor取值逻辑,避免硬编码索引值导致的错误 - public static final int COLUMN_ID = 0; // 投影数组中-便签ID的列索引 - public static final int COLUMN_BG_COLOR_ID = 1; // 投影数组中-背景色ID的列索引 - public static final int COLUMN_SNIPPET = 2; // 投影数组中-便签摘要的列索引 + /** + * 投影列索引定义 + */ + public static final int COLUMN_ID = 0; + public static final int COLUMN_BG_COLOR_ID = 1; + public static final int COLUMN_SNIPPET = 2; - // 日志统一标签:小部件模块所有日志输出的固定标识,便于日志过滤与问题精准定位 private static final String TAG = "NoteWidgetProvider"; /** - * 重写系统小部件生命周期回调:小部件删除触发方法 - * 触发时机:用户在桌面手动删除任意规格的便签小部件实例时由系统回调 - * 核心业务逻辑:清理当前小部件与便签的关联关系,将关联便签的WIDGET_ID字段置为无效值, - * 保证数据层的关联关系一致性,防止出现无效的脏数据关联 - * @param context 应用全局上下文对象,提供ContentResolver数据操作能力 - * @param appWidgetIds 被用户删除的小部件ID数组,支持批量删除处理 + * 当小部件被删除时调用 + *

+ * 更新数据库中对应小部件的ID为无效值 + * + * @param context 上下文对象 + * @param appWidgetIds 被删除的小部件ID数组 */ @Override public void onDeleted(Context context, int[] appWidgetIds) { - // 构建数据更新载体:封装需要更新的字段与对应值,将关联标识置为系统无效值 ContentValues values = new ContentValues(); values.put(NoteColumns.WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); - - // 遍历所有待删除的小部件ID,逐一对关联便签执行数据更新操作 for (int i = 0; i < appWidgetIds.length; i++) { context.getContentResolver().update(Notes.CONTENT_NOTE_URI, values, @@ -105,11 +78,13 @@ public abstract class NoteWidgetProvider extends AppWidgetProvider { } /** - * 私有核心工具方法:根据小部件ID,查询其关联的有效便签数据信息 - * 核心过滤条件:匹配当前小部件ID + 排除回收站中的便签数据,保证查询结果为有效展示的便签 - * @param context 应用上下文,用于获取ContentResolver执行数据查询 - * @param widgetId 目标小部件的唯一标识ID - * @return Cursor 匹配条件的便签数据结果集,无匹配数据时返回null;结果集需调用方手动关闭释放资源 + * 获取小部件关联的便签信息 + *

+ * 通过ContentResolver查询数据库,获取与指定小部件ID关联的便签信息 + * + * @param context 上下文对象 + * @param widgetId 小部件ID + * @return 查询结果游标 */ private Cursor getNoteWidgetInfo(Context context, int widgetId) { return context.getContentResolver().query(Notes.CONTENT_NOTE_URI, @@ -120,125 +95,105 @@ public abstract class NoteWidgetProvider extends AppWidgetProvider { } /** - * 受保护的重载更新方法:对外暴露的小部件通用更新入口 - * 业务适配:默认以【非隐私模式】执行小部件的UI渲染与数据绑定,为子类提供极简的调用入口 - * @param context 应用全局上下文 - * @param appWidgetManager 系统小部件管理器,负责最终的UI更新调度 - * @param appWidgetIds 需要执行更新操作的小部件ID数组,支持批量更新 + * 更新小部件 + *

+ * 调用带privacyMode参数的update方法,默认privacyMode为false + * + * @param context 上下文对象 + * @param appWidgetManager 小部件管理器 + * @param appWidgetIds 需要更新的小部件ID数组 */ protected void update(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { update(context, appWidgetManager, appWidgetIds, false); } /** - * 私有核心业务方法:小部件通用更新逻辑的总入口,封装所有规格小部件的完整更新流程 - * 核心职责:统一实现 数据查询 → 数据解析 → 视图初始化 → 内容赋值 → 点击事件绑定 → UI刷新 全链路逻辑, - * 兼容普通模式/隐私模式双场景,处理有无关联便签的分支逻辑,是整个小部件的核心业务实现方法 - * @param context 应用全局上下文,支撑资源访问与数据操作 - * @param appWidgetManager 系统小部件管理器,提供小部件UI更新的核心能力 - * @param appWidgetIds 需要更新的小部件ID数组,支持多实例批量处理 - * @param privacyMode 是否启用隐私模式:true=隐私模式(隐藏内容),false=普通模式(展示内容) + * 更新小部件 + *

+ * 遍历所有需要更新的小部件,根据小部件ID获取关联的便签信息, + * 然后更新小部件的显示内容和点击事件 + * + * @param context 上下文对象 + * @param appWidgetManager 小部件管理器 + * @param appWidgetIds 需要更新的小部件ID数组 + * @param privacyMode 是否为隐私模式 */ private void update(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds, boolean privacyMode) { - // 遍历所有待更新的小部件ID,逐个完成独立的更新逻辑处理 for (int i = 0; i < appWidgetIds.length; i++) { - // 过滤无效的小部件ID,跳过无意义的更新操作,提升执行效率 if (appWidgetIds[i] != AppWidgetManager.INVALID_APPWIDGET_ID) { - // 初始化默认背景ID:从资源工具类获取应用全局默认的便签背景标识 int bgId = ResourceParser.getDefaultBgId(context); - // 初始化便签摘要:默认空字符串,防止空指针异常 String snippet = ""; - - // 构建默认跳转意图:无关联便签时,跳转至便签编辑页执行新建操作 Intent intent = new Intent(context, NoteEditActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); // 启动模式:栈顶复用,避免重复创建页面实例 - intent.putExtra(Notes.INTENT_EXTRA_WIDGET_ID, appWidgetIds[i]); // 携带小部件ID参数,供编辑页关联使用 - intent.putExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, getWidgetType()); // 携带小部件类型,由子类实现差异化适配 + intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.putExtra(Notes.INTENT_EXTRA_WIDGET_ID, appWidgetIds[i]); + intent.putExtra(Notes.INTENT_EXTRA_WIDGET_TYPE, getWidgetType()); - // 查询当前小部件关联的有效便签数据 Cursor c = getNoteWidgetInfo(context, appWidgetIds[i]); if (c != null && c.moveToFirst()) { - // 异常日志埋点:同一小部件ID关联多条便签数据,属于数据异常场景,输出错误日志便于排查 if (c.getCount() > 1) { Log.e(TAG, "Multiple message with same widget id:" + appWidgetIds[i]); c.close(); return; } - // 从结果集中解析业务数据,赋值给本地变量用于后续视图渲染 snippet = c.getString(COLUMN_SNIPPET); bgId = c.getInt(COLUMN_BG_COLOR_ID); - // 携带便签ID参数,跳转编辑页时直接定位到当前关联的便签内容 intent.putExtra(Intent.EXTRA_UID, c.getLong(COLUMN_ID)); - // 修改意图动作:由「新建」改为「查看/编辑」已有便签 intent.setAction(Intent.ACTION_VIEW); } else { - // 无关联便签数据时:展示应用默认的空内容提示文本 snippet = context.getResources().getString(R.string.widget_havenot_content); - // 修改意图动作:执行新建便签的业务逻辑 intent.setAction(Intent.ACTION_INSERT_OR_EDIT); } - // 安全关闭游标,释放数据库连接资源,防止内存泄漏 if (c != null) { c.close(); } - // 创建远程视图实例:加载子类实现的规格专属布局,完成跨进程UI初始化 RemoteViews rv = new RemoteViews(context.getPackageName(), getLayoutId()); - // 为远程视图设置背景资源:加载子类实现的规格专属背景,完成尺寸与背景的精准适配 rv.setImageViewResource(R.id.widget_bg_image, getBgResourceId(bgId)); - // 携带背景ID参数,跳转编辑页时同步使用当前小部件的背景样式 intent.putExtra(Notes.INTENT_EXTRA_BACKGROUND_ID, bgId); - /** - * 构建小部件点击的延迟意图:区分隐私模式与普通模式,实现差异化的交互逻辑 - * PendingIntent为跨进程意图载体,是小部件点击事件的核心实现方式,保证桌面进程能触发应用内逻辑 + * 生成启动便签编辑活动的PendingIntent */ PendingIntent pendingIntent = null; if (privacyMode) { - // 隐私模式逻辑:隐藏便签真实内容,展示隐私提示文本 rv.setTextViewText(R.id.widget_text, context.getString(R.string.widget_under_visit_mode)); - // 隐私模式跳转:点击后跳转至便签列表页面,而非编辑页 pendingIntent = PendingIntent.getActivity(context, appWidgetIds[i], new Intent( context, NotesListActivity.class), PendingIntent.FLAG_UPDATE_CURRENT); } else { - // 普通模式逻辑:展示解析后的便签摘要文本 rv.setTextViewText(R.id.widget_text, snippet); - // 普通模式跳转:点击后跳转至便签编辑页,携带完整业务参数 pendingIntent = PendingIntent.getActivity(context, appWidgetIds[i], intent, PendingIntent.FLAG_UPDATE_CURRENT); } - // 为小部件核心文本区域绑定点击事件,触发上述构建的延迟意图 rv.setOnClickPendingIntent(R.id.widget_text, pendingIntent); - // 通过系统管理器完成最终的UI刷新,将渲染完成的远程视图同步至桌面小部件 appWidgetManager.updateAppWidget(appWidgetIds[i], rv); } } } /** - * 抽象方法:获取小部件规格专属的背景资源ID - * 由子类根据自身尺寸规格实现,完成背景资源与小部件尺寸的精准适配,避免背景拉伸/变形 - * @param bgId 便签数据表中定义的背景色标识ID,为全局统一的背景风格常量 - * @return int 对应尺寸规格的背景Drawable资源ID + * 获取背景资源ID + *

+ * 根据背景颜色ID获取对应的背景资源ID + * + * @param bgId 背景颜色ID + * @return 背景资源ID */ protected abstract int getBgResourceId(int bgId); /** - * 抽象方法:获取小部件规格专属的布局资源ID - * 由子类根据自身尺寸规格实现,加载与桌面占位大小匹配的专属布局,保证UI展示效果 - * @return int 对应尺寸规格的布局资源ID + * 获取布局ID + * + * @return 布局资源ID */ protected abstract int getLayoutId(); /** - * 抽象方法:获取小部件的标准化业务类型标识 - * 由子类根据自身尺寸规格实现,返回数据层统一定义的类型常量; - * 该标识是数据层识别小部件规格的核心依据,用于数据关联、筛选与同步,保证数据与视图的一致性 - * @return int 小部件业务类型常量,取值为Notes.TYPE_WIDGET_2X / Notes.TYPE_WIDGET_4X + * 获取小部件类型 + * + * @return 小部件类型 */ protected abstract int getWidgetType(); -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_2x.java b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_2x.java index 78af6c9..0ee0643 100644 --- a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_2x.java +++ b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_2x.java @@ -14,7 +14,6 @@ * limitations under the License. */ -// 包声明:小米便签 桌面小部件功能模块,该包下统管所有尺寸规格的便签桌面小组件实现类 package net.micode.notes.widget; import android.appwidget.AppWidgetManager; @@ -24,20 +23,22 @@ import net.micode.notes.R; import net.micode.notes.data.Notes; import net.micode.notes.tool.ResourceParser; + /** - * 小米便签 2X规格桌面小部件提供者核心实现类 - * 继承抽象基类NoteWidgetProvider,作为MVC架构中UI层的桌面可视化组件,仅负责2X规格专属适配逻辑; - * 核心职责:实现基类抽象方法,为2X尺寸小部件提供专属布局、匹配的背景资源、标准化业务类型标识; - * 设计原则:通用的小部件生命周期管理、数据更新、视图渲染逻辑完全复用父类,子类只做规格差异化实现,解耦通用逻辑与尺寸适配逻辑。 + * 2x2 大小的便签小部件提供者 + *

+ * 该类继承自NoteWidgetProvider,实现了2x2大小的便签小部件。 + * 它提供了小部件的布局、背景资源和类型等信息。 */ public class NoteWidgetProvider_2x extends NoteWidgetProvider { /** - * 重写系统小部件生命周期的更新回调方法 - * 触发场景:小部件首次添加至桌面、系统触发定时刷新、便签数据变更主动同步时调用 - * 方法逻辑:无差异化业务处理,直接调用父类统一的update核心方法,完成2X小部件的视图刷新与便签数据绑定 - * @param context 全局上下文对象,提供系统服务调用、应用资源访问的基础能力 - * @param appWidgetManager 系统桌面小部件管理器,负责小部件的创建、更新、销毁等全生命周期调度 - * @param appWidgetIds 待更新的2X规格小部件ID数组,支持多实例批量更新操作 + * 当小部件需要更新时调用 + *

+ * 调用父类的update方法更新小部件 + * + * @param context 上下文对象 + * @param appWidgetManager 小部件管理器 + * @param appWidgetIds 需要更新的小部件ID数组 */ @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { @@ -45,9 +46,11 @@ public class NoteWidgetProvider_2x extends NoteWidgetProvider { } /** - * 重写父类抽象方法,获取2X规格小部件的专属布局资源ID - * 布局文件为固定尺寸适配,与2X桌面占位大小完全匹配,保证便签内容在该规格下的完整展示 - * @return int 2X小部件专属布局资源ID,对应布局文件:R.layout.widget_2x + * 获取布局ID + *

+ * 返回2x2小部件的布局资源ID + * + * @return 布局资源ID */ @Override protected int getLayoutId() { @@ -55,11 +58,12 @@ public class NoteWidgetProvider_2x extends NoteWidgetProvider { } /** - * 重写父类抽象方法,获取2X规格小部件对应的背景资源ID - * 核心适配说明:不同尺寸的小部件背景资源为独立资源文件,需根据背景主题ID精准匹配对应规格的背景; - * 避免因尺寸不一致导致的背景拉伸、变形等UI兼容问题,通过工具类封装所有背景资源的映射关系,统一管理。 - * @param bgId 背景主题标识ID,为Notes模块统一定义的背景风格常量,与具体尺寸解耦 - * @return int 适配2X规格的背景Drawable资源ID + * 获取背景资源ID + *

+ * 根据背景颜色ID获取对应的2x2小部件背景资源ID + * + * @param bgId 背景颜色ID + * @return 背景资源ID */ @Override protected int getBgResourceId(int bgId) { @@ -67,13 +71,14 @@ public class NoteWidgetProvider_2x extends NoteWidgetProvider { } /** - * 重写父类抽象方法,获取当前小部件的标准化业务类型标识 - * 该标识为数据层核心常量,用于NotesProvider数据持久化、便签数据同步、小部件类型筛选的核心依据; - * 保证数据层能精准识别当前小部件的尺寸规格,返回对应规格的便签数据,实现「数据-视图」的规格一致性适配。 - * @return int 2X规格小部件的业务类型常量,定值:Notes.TYPE_WIDGET_2X + * 获取小部件类型 + *

+ * 返回2x2小部件的类型 + * + * @return 小部件类型 */ @Override protected int getWidgetType() { return Notes.TYPE_WIDGET_2X; } -} \ No newline at end of file +} diff --git a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_4x.java b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_4x.java index c339503..e4fe4fc 100644 --- a/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_4x.java +++ b/src/Notes-master/src/net/micode/notes/widget/NoteWidgetProvider_4x.java @@ -14,35 +14,31 @@ * limitations under the License. */ -// 包声明:小米便签 桌面小部件功能模块,该包统一承载所有尺寸规格的便签桌面小组件实现类 package net.micode.notes.widget; -// 安卓系统核心依赖:桌面小部件全生命周期调度的核心管理类,负责小部件的创建、更新、销毁等系统操作 import android.appwidget.AppWidgetManager; -// 安卓系统核心依赖:应用全局上下文,提供资源访问、系统服务调用、组件通信的基础能力,小部件初始化必备 import android.content.Context; -// 小米便签业务依赖:应用资源常量类,统一管理所有布局、样式等资源ID引用 import net.micode.notes.R; -// 小米便签业务依赖:数据层核心常量类,定义便签及小部件的类型、状态等全局业务枚举常量 import net.micode.notes.data.Notes; -// 小米便签业务依赖:资源解析工具类,封装多规格小部件背景资源的映射适配逻辑,统一提供背景资源获取能力 import net.micode.notes.tool.ResourceParser; + /** - * 小米便签 4X规格桌面小部件提供者核心实现类 - * 继承抽象基类NoteWidgetProvider,隶属于MVC架构的UI层桌面可视化组件,专注4X规格的差异化适配; - * 核心设计职责:实现基类定义的抽象方法,为4X尺寸小部件提供专属的布局资源、匹配的背景样式资源、标准化业务类型标识; - * 设计思想遵循:父类封装所有小部件通用的生命周期、数据更新、视图渲染核心逻辑,子类仅实现当前规格的差异化配置,解耦通用逻辑与尺寸适配逻辑,提升扩展性。 + * 4x4 大小的便签小部件提供者 + *

+ * 该类继承自NoteWidgetProvider,实现了4x4大小的便签小部件。 + * 它提供了小部件的布局、背景资源和类型等信息。 */ public class NoteWidgetProvider_4x extends NoteWidgetProvider { /** - * 重写系统小部件生命周期的更新回调方法 - * 触发场景:4X小部件首次添加至桌面、系统执行定时刷新任务、便签数据变更触发主动同步时调用 - * 方法核心逻辑:无4X规格的差异化业务处理,直接复用父类统一的update核心方法,完成4X小部件的视图刷新与最新便签数据绑定 - * @param context 应用全局上下文对象,支撑小部件的资源访问与系统服务调用 - * @param appWidgetManager 系统桌面小部件管理器,统一调度所有小部件实例的更新操作 - * @param appWidgetIds 待执行更新的4X规格小部件ID数组,原生支持多小部件实例的批量更新 + * 当小部件需要更新时调用 + *

+ * 调用父类的update方法更新小部件 + * + * @param context 上下文对象 + * @param appWidgetManager 小部件管理器 + * @param appWidgetIds 需要更新的小部件ID数组 */ @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { @@ -50,20 +46,24 @@ public class NoteWidgetProvider_4x extends NoteWidgetProvider { } /** - * 实现父类抽象方法:获取4X规格小部件的专属布局资源ID - * 该布局为小米便签定制化的4X尺寸UI布局,与桌面4格占位大小精准匹配,保障便签内容完整展示无适配问题 - * @return int 4X小部件专属布局资源ID,对应固定布局文件:R.layout.widget_4x + * 获取布局ID + *

+ * 返回4x4小部件的布局资源ID + * + * @return 布局资源ID */ + @Override protected int getLayoutId() { return R.layout.widget_4x; } /** - * 重写父类抽象方法:根据背景主题标识ID,获取4X规格小部件的专属背景资源ID - * 核心适配逻辑:不同尺寸规格的小部件对应独立的背景资源文件,避免尺寸不匹配导致的背景拉伸、变形等UI兼容问题; - * 背景资源的映射关系由工具类统一封装管理,实现业务逻辑与资源适配的解耦,便于后续扩展新的背景样式。 - * @param bgId 背景主题枚举标识ID,为项目统一定义的小部件背景风格常量,与具体尺寸规格解耦 - * @return int 精准匹配4X规格的背景Drawable资源ID + * 获取背景资源ID + *

+ * 根据背景颜色ID获取对应的4x4小部件背景资源ID + * + * @param bgId 背景颜色ID + * @return 背景资源ID */ @Override protected int getBgResourceId(int bgId) { @@ -71,13 +71,14 @@ public class NoteWidgetProvider_4x extends NoteWidgetProvider { } /** - * 重写父类抽象方法:获取当前4X规格小部件的标准化业务类型标识 - * 该标识为数据层核心业务常量,是NotesProvider数据持久化、便签数据同步、小部件类型筛选的核心依据; - * 作用是让数据层能够精准识别当前小部件的尺寸规格,返回对应规格的适配数据,实现「业务数据-桌面视图」的规格一致性闭环。 - * @return int 4X规格小部件的业务类型常量,定值:Notes.TYPE_WIDGET_4X + * 获取小部件类型 + *

+ * 返回4x4小部件的类型 + * + * @return 小部件类型 */ @Override protected int getWidgetType() { return Notes.TYPE_WIDGET_4X; } -} \ No newline at end of file +} -- 2.34.1