From eae1c533ce1f7a1870899ba5642ef024d2ea07b5 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:01:41 +0800 Subject: [PATCH 01/30] =?UTF-8?q?feat(theme):=20=E6=8E=A5=E5=85=A5=20Obsid?= =?UTF-8?q?ian=20Flare=20=E8=AE=BE=E8=AE=A1=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + assets/fonts/OFL-Outfit.txt | 93 ++++++++++++++++++++++ assets/fonts/OFL-PlusJakartaSans.txt | 93 ++++++++++++++++++++++ assets/fonts/Outfit-Bold.ttf | Bin 0 -> 48340 bytes assets/fonts/Outfit-Regular.ttf | Bin 0 -> 47844 bytes assets/fonts/Outfit-SemiBold.ttf | Bin 0 -> 48456 bytes assets/fonts/PlusJakartaSans-Bold.ttf | Bin 0 -> 63336 bytes assets/fonts/PlusJakartaSans-Medium.ttf | Bin 0 -> 63404 bytes assets/fonts/PlusJakartaSans-Regular.ttf | Bin 0 -> 63336 bytes assets/fonts/PlusJakartaSans-SemiBold.ttf | Bin 0 -> 63412 bytes lib/core/theme/app_colors.dart | 73 ++++++++++------- lib/core/theme/app_dimens.dart | 10 ++- lib/core/theme/app_theme.dart | 50 +++++++++--- lib/core/widgets/glass_panel.dart | 54 +++++++++++++ pubspec.yaml | 20 +++++ test/theme/app_theme_test.dart | 21 +++-- test/theme/glass_panel_test.dart | 43 ++++++++++ 17 files changed, 411 insertions(+), 49 deletions(-) create mode 100644 assets/fonts/OFL-Outfit.txt create mode 100644 assets/fonts/OFL-PlusJakartaSans.txt create mode 100644 assets/fonts/Outfit-Bold.ttf create mode 100644 assets/fonts/Outfit-Regular.ttf create mode 100644 assets/fonts/Outfit-SemiBold.ttf create mode 100644 assets/fonts/PlusJakartaSans-Bold.ttf create mode 100644 assets/fonts/PlusJakartaSans-Medium.ttf create mode 100644 assets/fonts/PlusJakartaSans-Regular.ttf create mode 100644 assets/fonts/PlusJakartaSans-SemiBold.ttf create mode 100644 lib/core/widgets/glass_panel.dart create mode 100644 test/theme/glass_panel_test.dart diff --git a/.gitignore b/.gitignore index 92b30ba..034bed8 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ app.*.map.json /assets/* !/assets/icon/ !/assets/icon/*.png +!/assets/fonts/ +!/assets/fonts/*.ttf +!/assets/fonts/OFL-*.txt /server/ /docs/ /AGENTS.md diff --git a/assets/fonts/OFL-Outfit.txt b/assets/fonts/OFL-Outfit.txt new file mode 100644 index 0000000..723cd44 --- /dev/null +++ b/assets/fonts/OFL-Outfit.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/OFL-PlusJakartaSans.txt b/assets/fonts/OFL-PlusJakartaSans.txt new file mode 100644 index 0000000..ea69b0c --- /dev/null +++ b/assets/fonts/OFL-PlusJakartaSans.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Outfit-Bold.ttf b/assets/fonts/Outfit-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0389ff27183e883930b4d83a18de380bf699ac03 GIT binary patch literal 48340 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 J^8tML{|`q-nqL3_ literal 0 HcmV?d00001 diff --git a/assets/fonts/Outfit-Regular.ttf b/assets/fonts/Outfit-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0b0a0d7d6c94b22d97c44352fbf0657e2a26ac1f GIT binary patch literal 47844 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 z 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 literal 0 HcmV?d00001 diff --git a/assets/fonts/Outfit-SemiBold.ttf b/assets/fonts/Outfit-SemiBold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..52e25bc9289c4a73c93a617ed75c5a420199acb6 GIT binary patch literal 48456 zcmc$n2YgjU`mkrtO>Qb7q!UO;B^5|-)R54tbP;JGbO?sfMX3s?h@$AO+a#>ImbIZG zXhd;wDI%H(mZ%9J#6+?Y!N@B2e$ULgDIl)w_y0=rd*+_ZoipW~ciwqt&fFUzg%I8Z zPC^XG@7I6S-HYxO!iW+=ZW}Oi#OSZS|LQ{_LOq4ZiyAO`Y~NFRK5Z1j>PniYM~v>4 zvN(9*qojS8T&9j5lRIX`BXhsz@2UJ9U$9`>qCDp%UP5@hKmkK%OYs zzhdT@0}FQvF)c}mNddD9r_G4(KQV~+se{OvO@xnAo2~ragTJF@FDO~ww^w?$5N&@J z!ujI-qJnAS@xVLCYB%-ydBL>hi{!)7Li&!R4_i2GL19wQiPb__)ca!=6)i5Yx~$0+ z!oPug+Ak_DTr}Hy??%$oKH;)Q+Bd%7bRT~TX}62Eyb8Hm>=s^Pn6Rq9TK!m5Dsrx| zQ5UZY`yIAEUXNXqM%}Gax$1a79d;DPvRSiBq{_|EF+#c+6LiihPPYHOub1DuQ`+{p zBrKL16<_)J*EaQ9l@l0GIqSS0vwX*&R_-@{oQy%_;7o>FbWTIeS2M+ zZpm43Uf=5^q?|ySt|FfIB6v4g1PC95{DhDmyt*M3ml`3hqlI)T6VejL9S>jcjy7Gn zV>5T07SgpRcRb1+7r0|6cRWFhJBlFc<1IYN@jc$p=KW`QKZN)1r{1bA$&S}l$7_n? zbr!V_=Gp_iL?^;<5k(jyx)COeOu}q2fN-c7LpV-MC!8T>6V4TP5Uvp$2seud2)BzJ zgufGy6P6+wDV`O32=|J8gl`FICk}~^2~Ud83BM3u6DpY*vb*d~J$uXEgp(LM7CBwc z6AleSE(U2-ydZwI>Sx-!L%r|h#`RM&z;P{+^^cBggErMVt}P->yz01iYL&)Wfe|n&G4qbkc|2WDk z)^`?(h3ITD_a}=ae!G*$OmZ&LHCZffA+9U^+Et{`!?#|Qawd69BYpSZC~Xv_&7wu~ zNnLzZ+OS{LERS**i4~-4l1&&TCqXjx9D_6pd2QXgFfo$UMd(-|k}2WcTrIuX#Hluh z(KkxQtz|gNA=xG9XjkqkLi$Q_EmEID+|@B%kgJlGk~$-h-gFoF;xEF;?>}^mh?+mG zWFb1F?18`9-%eaPw~AF(tSQ!|pkTowF-C`ju`LB6fBu|V(?qXD3zsYqS;b3> z7mAb;b)872Qn=_)>98bM6*8|7Dh6K4?tvXAV`UCQz*mJ&CH^whT~}@(@>MEmbSsc~6Q4aaqST z$J>9<@&8t{7kM|uClVXLJ=9vd(87-5A~9aVDxLLT7o^n@t9s`<@jYSFy{e=jWp{b4 z(Y1V#da9cJu2m`}9WRhrE<^ktyVJV1Dqgi{XLJ7;VmTT!Sq!9g0(+_QH|wj?s(UTm z5#UIV<#NQD^=e9^a)zXgO#39!2wX3UZ2aN^jf`cTer4L+OBQ;WZR0i4Q+q+cJcP{ z4)KolPV*k7_o8NAEyJyME0@P-KAh=_>fh@KIHBF08ci?}1=v4|6q?IPnMb0Y^uj*7fD^1aA&Q7%zoQQ1+0 zqHc~Vidr4DJz7Q&j$Rf0Md$9F@9+G2=dU|oh_S{@h}jxb8QUp#O6-={J+be_ejZyB zC*l(0ddH25n;Ca!+>>#y$Gsod5I;D+Fn&$^WAU%VACIq1a8Bryke@IyVRpjOgnJU6 zNhnMBFrhv%II&OSu*BOEA5Pqt_(kH^iRTk9C0^;`(TLV1TgW^^zzjW>+#m5pGx)Hg4ALbVp92OcD5!N{@CoDg# zB<$Jnu<(fR?cu+R@QLt`@%_nQMi7(~#!QyO`1`wbmjNP(|w^LPn757!fSIM7+JD=0CoJqtw+7clvm7|eWDy2orU^zf~Bf++^qiiRA=%*oaC^Ai_UTv_@z9JB5hl^cX~7P{tWzpxuS@E2%*<+lHqb8v-_Q5jo8Rc_8?yBBifqpVkUS7 ztN9Z1Xc;ry0cNSA;sm~FC7$Ui86|tlXgNZ9Nq=V2e0sAV+LI;c%WP3k50AvoE|oo) zjopw|ksO6~1<7U7Rs2hKlgv)&ULfocg1-?j642b9A`h+2 z#mnw12jP!Q78AuJys~*>u=polQ;Aq6Rx{(=jy|s!_p{!(SKKH5Bpwrg5PuYRi5Ku| zUuK22SG*zzi`T`6;(hUfI4-^vZ;C4B_LYp0CCuKdSk0|vuG+wiy-xUxyP2Uj;qh)` z7T=2R|A1&O?qRL+5Hs2iW|xOqxjc#|^%us{<9I8-XI%XmpY5-B9Z%vN|4pQer|=)2 zM*E)=J;d|)jDHus#EYUIGf}>HjrnmOtH%BKVDE_G;%#vg^V4W?P>d4)5aY#RF;$e~ ziyp&Y`&dj7AK^(>;HQ3yPx%$T+ahrqU+-&in>fSlx)k5#Jyu#r{Z zZI1jF*)kBXg>f4;YWE{ z#oLdFAMHO&X}?2ZcykXp(nqkXq^^VIX8N)~=cn@gCE1ky>$FwxS$2_TDEFFllrM=K z2c3BhmTw5JYcU3qW+S^}SAbXQ*5!uq5QYU`|5^+W;c9sO68Vt`YK?IWdCq`YFkQI2 zjT6CM70{hFyg*%MQrC&1je)h2MU1&P;ya@!lSF&2+Zptq(T4a05zcjpF6vD z@Pkn33~|r}qK)UdN3mPK21ySQD92E@k-|@^ywxt%0TUU6#SRkLvOO~L73r2}{`L`R z>_ueDM$u88WuDr_IG!lF%UbfjSH#edR?RW`A(=j%%DwCv$t2p5L>a?bw+$B|@@CNw z!gT)iO`#Z6z%yyBpPi+l7*Sl zh!t7J`?O<=2$zv!oWgij1R3t6oj_eiiFn?3H*OQ%jR(k|dLp}22WhRYo8r`KtcsUM zkm(@u`!j8Pkg;zP(CS*n>A#U&4ormDR*YzM-8x=fhdW^BO~lGCk=H;mQKoUN_!RcH z;wG+Z;3$NnBi(p)6Oq~;tG3ck?LCs2{SS=4>-@;oL`I22(vTMBoay2@>BL&IfZ1&u zzDH+q1#fHytIinl4RgFJEh(goe~QD>o%zW_dg{6TM|>PFks#ZOMCpxZ@jf%%zr_d2 zvtjn{ipF%q2l7W-PAP9j2I4)m7c*rrwK~o$K1-D2A9ZA2ex5l#l@)uMNXMrMm7PR} z3}aUO3Ew6{Wa2qRi7ZheqnSr%ixYT0G0b;yXhe>l^_c&dn|p{l*;VXAB0bTxv+N1x zi#hmUsbU^8?p&FMpS6&A_b<%f{e=xpE0P&_CAIi9lDT}5%u;(Vct5wv9Cqsl;h$W_ z|LG})$X@tgC-GF4;;SrS7q5@Z6~km-FVyiyn%Q*%kfl3i52)H zw~Lj^%frK1jps00)HBbI#q%33N1&x6ng4&5H{rL9$G;lQ{C@|daSeXVoy_0kn87FD z^G%dDGq2ym_`C(r>05cL*vr1}t#XPuhd(e)PR9p%TNdCW6k<32i0|Z!-{p`06DWf4 zdXzub5g%f z_yN4BHu!ixc%$BUlrOXE=OH}t<)-2h^}+A?2OiohcxtbSzu_?{f76-$oMre~Zg^pL z;dv>)Zascbja-h8_NH)=EAUu%$d$rN{7W?8Qz_rK6aMQotV*t2C0yn0ay6@%JJ}an zE7!^Oa)aC`?~-@RO>(o`BJYv+%KPM2dB5ByACM2q?f7|>c$@d(UwtB~@shs4+xiSo zu}VI~Q-dAy5o_-B;=-kcE=v~9NlMPkcgbBat)RGQp-XPjtfGa5^W5`h6qQUXC@5T5 zV$Cm@#yh+e7fma%_S1j4_iKLNrJv*0e&(%amMJOj1DcaK4V*r$*cdd}WsoE3pw>xK zGps|W6)Y(!v<}sIn;E3`wGKC9hqsDNOSkk}IIGQw*-IAAnpV7I!Tf1UN?b;mxmrh= z=|{CnpPpeItz&hjDal#tHMw7QKkJy5R4K{1*0EX~ZN^@e@mNQ7$C}l39$P$T;VkQT zouSA0f;q(nOBT$WU%1?D{ERt;#f6LKEVhoHRXlBJp~uZFZ(48GQPzq2Hus6mQn5~J z-39$zra4NR=IDZgmXtGeWvzw!#l5ik4VOa4J%z0Wnv&{1t2v3&Y$f|Sb6w^*lFn(B zG$l3DA?-}-d{cV!TjY|QnrB^T#x86Xo0efIqz{U&5>=6zyOoVl)sw}o(x+!y7wcF@ zA7oogTHZ}b?rU9Q%6`dJ87^^DcZoyxOSJ5l>5M#|e)~|Dx>h~AVS+3q+%y3(%E(+)HOiU$MI7ero#gf!F+wq#> zcuiHWDM^`WM&2khDmO_-WhR?%C8s1g^;=S0q$5+3)6GPE(_N+&7Z)vCvPj1#XJ@+W zzl&9=j;Jg*9W|q9*+NH5wiz?OaAs4Q44qwSk{&ThsY#h`T9gGv3#PkiX{swnn!X;& zjHD*zb)7a}-_Nz#4B{MVn)1taE15HYMq#VF)65jfjyqBuDN{_78fm= zT^MLup{{*L<>n3PH>T^-X~ntoOOlfPm8JScY~YNd`SYh0{~})b0!(}CHODenna=>c zu6YJv(P`MDoma?Ce#C-l#q&h)f_V$(F{7xv)%$9{*U*2lkKJa5aRPhq&g|e#yt|J7 zJ*#QSTz) z=T>sFc$!ZOo!RNH<%z(z_$1EqIJ0yawe}Q-Ctf;t_%UN43&Y6_{ ziKnoT)=y`)ZryrQ2EPoY=Lg75x`e9yTtZcT&YM*XAu-OIRgC4LiXkM%a#6(?Q&kM1 zs>@Veiz6yV98ocNYN!AAYt&|f%E@J}%E@J}%G>!}6+=jj^Sdg>DOAM}662(6ZLCl+ zgv1ytbdAbY4ErF&;P0JAOMXo{re>I-bY)#cu4i1mho>fnk+?Tpd5*D{w zdjFu?V7Gj?UT#@BOwnOttFW`a4%1<<4$bs_`r1o}u5OGQ*PnIxg9_cumwX>XZ58g;*MHaHQ##zKLo@v!Tz7EqHXWMRJgp#RgXTrw>hwE^l4*Tj*r{_L(K0}AD8LmjpHA#o&wHd~{MiC$C8bs)$ zLr)!=F;;c0^AN7o$K~HH7j;;#!x|l)(qW|zPpHt5^0E) zmnU@i7af{0X83!Thj@3Z4$bRLF6&%w=ShwV7wK@W4rl0avJNNcaFhF}Se z!guub8#;Vdhi3X0^!3v^+-3a}c|NMc2X$!1+^etea(ULe#=266C9T4R`g*nwr|WQ% z4$bsut&Ah+mTZ?$qHQbhtx@ z+jO`^m$gBMcj(ZJS+1;#^L!oh{Eurh#tf%Avzm8q9gcAx?mW=BuMT_YFhhq)I*iw0 zlnz66Xr?#AAZH(P^VFeLheCx;|JLC}r+Thyba+aKl{!43!{Z?pyn9%O@9MBjhkKk} zBK}#YClD=GOr0?iPqJGf#WVW)8GYTT=gmfTaHVL}^J$~9kep0nRwd+=4k7OnGTTc1 zhto(>oYOTtr)#KeAu&45mpcAS9si{+F+}+cGDMdfqRS1@^$gMFhUjub zRC;!iRf!?G#1MUNdwp+vou<96m3mSnWqVx~c7}4Fmdf{d$nu^FRo$eYj{i!}DYxtE z+jaU?+J{-C{gYLybg@d;c9pK}DxKy{oqm-HWvuE88LRvs+0mXs7^GVfr0XA~-wo2O z2+}nS(lxwCw<1W_KUk*;*6#-EcR6!MKEe9kSlzl<3*HoEseXyoImhZeWA$CJx<_Kw zy#^Xc8f=tGsdGrM@Pu9_SU%Sw5|9a60%I&ExgKM|zav!dj#2Vq*FxPpMyF}eX&Q8z z2A!rsr{P2%X&Q8zNtRl!Cs{V?@E#qioG0nKCh5B-ai&kjsQlTxQ`b6&Njit_I!$*S z(_P0T>zHJnUd=U}3)AuOIzC>r8hZWn!>r{cxEsw_fUN~q32+^fnWM3RJ* zh4)rTxWx?Bz3i6hd(BYiwoUiWHZ7lRy2NcdK3BITSGOWpr^(fA$AUDK zSKoD;4kzj~3(adCuGTrM*72)#TbAp)R_k|XYB|ibY(j1`b-6QjxifW%Gxc3Fbq#0g za`)(aXPO~780=1|lX>dQVJBYI$-EHu9J_I)PUgk4zc+v@buzCHe)M#9bxpUm5* z&*Z(U&*c4s9WR0JF345jp$hU8Vj-yhY+^dsN(0p_IFh;#hJ4!5i`Q?6lkB~zaJ>%i z)T92a4z<57{-&?19Pvj<`Ix?bR)@QE_%FiNtCV@{qZxRjhVyW=&oHW#Z|bztGMiP1 z8nxH_*e|i~t33?+nQPu^{?q>Nrc3+x>dJnq>F<_T`$gg}=yyL*SN4Apo>e*9kK60* zpYhsY|IGeZ`wpgfHx;>?iDMMW*^D$X=taT7DdT z{8dwoz1n`j&a+wj4*PS>ajNgXYdesF9axB+xC1-TL!W%ee{#A(BBX<=_d`5SNHLs@9?5$-yQrE{j;C$Oqtun+D*>%SqsmK_ae-EwdJl%_2w z1`>FN)CDPcX(_bRQjnYs>B&xsl7$y1E>}{2)ox|2El9Hcxj{o_o_2T;F( z{5t8@w$ZJ1)~$8dt@Y6DG-&4r@=z`G(mjTsN~+TU)6qd$wP4F7jnewbDLCZ71^Z^%D8>E25>g@6`M#Exedo`lRGNQLL+p3eIvv>lvlUaBdzjM%%x6q~o z{4Up8vO;UgO7!C-BUhbmTFn#3Q|Ou+^=q`Qtkt@*j;BnQ#d@Awwh{Mfz1hn1Nq@18 zXOn^A0ZwIg6c6(JF;r}q5t5TaoRNwW5A(dSGta4%t$0N1(_@U14Dm-jO8(3VuYTe$ zdaUf?dDZQlAF@)85Erm*Kk(XU|CSRHf|8lI#YKCS_<@*4-n|U$eCkmB{Q{p!c}f^y z0Vi+}~&gg_{Cf-nf@ z-Ux_p$GJYUeKHT`#>)Ag*?cI ze$XEVz(5#8+QBe{>!G|3gW)g&M&dz?v+oq+VFKK2KOrW1lrouFs4h78O zGwiR6IrcZiT>D?dJeUs)U?CLQkBY_gQVA@9rLYW^!wOgltKfE64R^pASPSc5J#2uD za5rp%&9DXTfqUUT*b4Wbc`xaf-VPVmIE zojjG7gwyd(ZY1v{+WFt*KOI?CBFi(#vJzQVBFjo-S&1wwv2l{t8ejn@a0V;5fGfCx zJ9vO6c=1%FEvR4h*&H7ThX{y-C`iKlQqSf4KrZx!JjjQB&>sfCKo|le?cXqJ-eA}tY!v*F5KTd4=aaz-lHJ%^V-;dJ=ew;S&1={_+(pmdH zd_zC+B)xZ#-aAO|9i;aT(t8K#y@T}LL3-~Xy?2n_J4o*xr1uWuJNlq!wdimyI$Vnm z*P_FY`_2J2z4>K_5w;&xdAQQ468*-pK z^njkw3wl#VAIOEikO%qD5BkFZ7zl$%I~ay=J(SmBFdRm}VtWO4v;sR?fgP>Dj#gkt zE3l&#*wG5?Xa#n(0y|oP9j(BQR$xafu%i{&(F*Kn1$MLoJ6eGqt-y{}U`H#kqZQcE z3hZbFcC-RJT7ey{z>Zd+Ip1SPE3l&#*wG5?XoYwN`}Qn62Y-j>;RSdRUgG_iVK=-2 zufl7v2VRH0un*oK?VC`>^;^8|hqvJ!IKb-j5ak|*BXE?p*86Y_K7ix!A$$boPyrvq z3D(~yft4DXc?!)ug=U^YGx1`uBWKC$9Mr%!P|H1a@U6WD+gpLB?t_Q!!zxD-Yk&ou zz!|LI0|YmAQGY=3ESP9*FKO7eIXC>p&#^z0Wc7Tz&QJp z*p(--D^D^TUtl)Az-)Yh+4usp@dak%3(UqBn2j$m8(&~HzJUFCPuril_NTEuPh)$Y z#`Zjo?RgsQX+(P((Vj-MrxERGM0*<1o<_8%5$$P2dm7Q6Mzp69?P)}N8quCcw5Jj6 zX+(P((Vj-MrxERGM0*<1o<_8%5$$P2dm7Q6Mzp69?P)}F8qu6aG^Y{GX=J`gM?)IX zkVZ755e;cXLmJVLMl_@m4QWI}8qttOG^3GTX=ElGN8gQy2~dEAnE`W|1LnbeSO5zF zuZ7eF_SZ=9K7U1_4X2qi>##wuFmHatyjh10I*h+qE`zbx{`B-2dio4K&Dn8q1}nIL zE4YC>cz`D)!8qo!@h}1A^7lNL4+~%+cu?NY*t~yZ^Zre_7by1|%Ke7<`jTvGe_MJ( z3L9Lh%;_$qzTilGfmF=s_RBH_Um%t1w$#7r-S5cjOY%BJs*B|H9Vx$W&My=VPT_2B zD)h0}%T-9pO5XL<;u3e9BlUOWdz^fav-)hbpXJ^<>Qcvg`a5#IK)Q>Ji%UBF$E2?# zMKvj^x$hA7sm}p6;;po0#CW6k27lGdP+G>55ZYoDYNxA_^p2dlubTU+v5)^h^zT9B zbgd4)ggkFh|FA+Au|gNILKm?f7qK1}u^tz(9v4~R@EIOjuXJ<@t*c{n)iJv27+rOY zt~y3n9iyv`(N)Ljs$+E3F}ms)U3KD4a#_RcT383`VFPT0yI~V-hAnUp+za=?R=6Lw z!2|Fh`(E35eFz?g9q@D3b6_YP6N!*B#n@VD~kD&Y&Rzl5)#3aa50 zbDr86JqI=L4JY5*+TTFa-jMC=7o-n_+Rw;HKG6`3%OJE5sg=MfDR=`SF1-HX$xC7R}T383`VFPT0yI~V- zhAnUp+za=?R=6Lw!2_@z9)gEq2mFqHdx_VVVK=-2ufl7v2VRH0un)@M0D5u)Nt~qb zD{1`~Tz?5)K^0WPDXj;m(Sy_U{h3y+zJ$d&z+ERvRf&vL%20c)(hznC4)8hQO8aGE zmEK%nA1;OWQYmRZvZ_W_)yS$ESydyeYGhT7tg4Y!HL|KkR@KO=8d+5{Ue6$}YUEXo zysD8`HS(%PUe(B}8hKSCuWIB~jl8OnS2gmgMqbs(s~UM#Bd==H6X*3Ico=rTBk2C` zU?+RsyLkO8JONL_Q}8!<8lK_(XW=>cJ3J3Bz>Dw_`MeCf;T3olUV}aGI_!mg@CIq$ zgfgz*;&nf~4e!7K+SuCDKT4VJ!!h^(cwT|uRgd3QkKa{~-&K#_RZsub(gStKx`zI{ z-l{APC`u9m*5rSEF# zyIT6LmcFZ{?`rA0TKcY*zN@A0YU#UL`mUC~tEKNwqwlr!T`hfAOW)Nx`c3ss1AWs# z-!#xS4fIU|ebYeSG|)G-^i3^&Q%m2}(l@pAO)Y&>OW)MeH?{OlEq&8K-!#xSwe(E` zebYeSG|)E$1Pn+qB-h?L^>*_iUk4M@1lZ@v|d;wO0d{!Zu z&nNiMr^-@&EF(phjONn_F|3jdQvF11Eo0QY=X>sHV7E|xO7|o_Qzd?l`pb=-ZNc88 z;4^@N-ASQ$LeBE4o_DDyFzRXHy#LyZyWUFj+7)3E+I*v{HnmzCNBZ$F0SaISmQC%( z%;mj#Fdr7cLMTG&i|LgTSOQC787zktuo70m?XVi|fHkld*1>w%02|?M*aVwl3)}iKlfiqaa1zf=m+`$7pp$(SE zi`}=j;0^7-2YiY51AhpBKnQ~N5DXolBZPpOO*%msgmZ5ML_!oq6Bh$<5Dy8E2wk8n zbb}=7k<4ofq(T~`Lk46*7Gy&XbcY_$6M8{!%IE{R&=>L`ANoOm7ytud5NQX)5Uz*v zIt+%x2pEaP$0684-{+(n0NcYw}?wt<{U?CKd zW-(7!OJE5sg=MfDR=`SF1-HX$xC8Fwo;AF#g>|qVHo!)>8#cja*aG*!y>K6Fh5KO} zJOB@}uG`M*L+~){05!Az=NYvIt6hWDuEA>8V6|(o+BI118pg$^jEheh7oRdNK4n~d z%DDKHaq%hR;#0=Or;LkFvD!6Q?Hb0%r&#S8tac4ny9TRWg9g_yW)3rE4l`yBW3{W8 zQLC6ytC&%%m{F^kQLC6ytC&%%m{F^kQL7kR2hseaX#Qu6uY-)QgN(0(jIV=?uY-(+ z8phaR#@J!T*kQ)lVXSr)GintxY89j7h)iJIbHqbNfHRDM zGmL;UjDRzYfHP?F88rC}ntTRLR%_iy(6%#Z+ZnX&4BB=EZ99Xuok82qplxT+wliqk z8MN#ST6GGoI)_%BL#xiAEoU6sat3Yr<$B%JmNK-Z3~ebxTguRuGPI=(Z7D-r%FvcF zw51GfDMMSz(3CPXr3_6eLsQDolrl7>3{5FRQ_9ekGBl+OO({cD%FvWDG^GqpDMM4r z(3CPXr4mgkLsQDolrl7>jQM*%ny?>D*pDXcM-%p=3H#B6{b)iNnox!&l%WY_XhIp9 zP=+Ryp$TPZLK&J+h9>Ms6ZWGCd}@o={qQ!t0|)H8(S%Agp%P7~L=&)Ga11_xN6>^LXu=UR;Ru>=5KX8=6DrY!N;IJoO*o1s97Pk3 z;#|K*eT^k{!+t6aIgT|hLo?2>Q!MSfu%WxKp}Vl5yRf0Vu%WxKp}Vl5 zyRf0Vu%WxKp}Vl5yU?C0w5JN~sX}|I(4H!^rwZ+678vC4z0r0RAFnXur*cK zni_0PDcVzt_LQPMrD#tn+Ea@5l%hSQXiq8HQ;PPKqCKT(Pbu0{iuRPEJ*8+*DcVzt z_LQPMrD#tn+Ea@5l%hSQXiq8HQ;PPKqCKT(Pbu2-5!zFV_LQPMrD#tn+OrSs*@yP* zLwok2J^Rp}eQ3`H`@6zwTRdrHxsQnaTO?b(O+>_dA>(Vl&1 z&pxzgAKJ4I?cvlF789i1_kDb$V zc@fXB4%oL+`X4F%Zc6$C^EZAZ=O8+wd!fwXO7|9^dv)ku9lBSC?$x1tb?9Clx>twp z@oiGz4j$kMe4i7$V(K$8R-WUiPf}WWj$`FHj+N&)R(AcY)aVO5R4dPMtn3b0d5&Y{ zIgXX*I98tHSb2_PUg$Pv&|SZZZ3SC9njR!ZKJ6D_|w8g43%G(CxPu3Hg6Vag$7-C%YMjSvoX2XM{~vf==dm{D zu{P(iHs^29>#F@9cwOhQWaqJD=donxv1I45WaqJD=donxv1I45WanFXUFWakbycBd zA7kM@#=?D!*VTa6)qvO4fY;T4*VTa6)qvO4fY;T4*VTa6)qs|LftJ;vWnZCX$FPLQ zu!P63gvYRi$F$G9n6+F9EPtH=>fQ@iBY=X_O1@3`+ z;Xc?3_ro@L03Kx2Z0GeMco=rTOYk!6hF9QKcn$Wz>#!I0K^d6)4fR;HdaPPKR_zj2 z?Gje)5?1XJR_zj2?Gje)5?1XJR_zj2?GjpAhnD^eE&U3sR)?0>p`~?bX&qWxhpkah zA?vYf^;orftXe%*?Gje)5?1XJw&zf@ca*|fH5JmC8(Vuwy?NqjVXb1(Yf^t+)%QjQ zLJ+ivVCVqq`yN9e6goi|L_-Y3K|CZtB6NYS&<&Cy1yUgm(jfyfAq%o02f9NK=n1`G z5DbQ)Fbsyn2pGrwIUXi}>7zHIEr-ySLukt(wB-=matNDVi%qX(|Jsigc>t0)ho5+k zr(YKQ!*e_Vv+(>`a!%C%3pjx@SiuEc!42HO13aM(`)t182mZi1jTO{oR#2B&L0x7A zb(s~^WmZs^SwUT91$CJf)MZvsml?a4SutH^#dMh!(`8mnmsv4gX2o=w71L!_Oqa3f z_b_((9u!E0G)RXG$b>A&h8*Y)J)kG_f1T95gEgLr*!1MoVoEX z+JZN<10M*72#ACzNMcW^H?Msl7y3dTE`4+CHz3;}iKWgI6f#=``-nf{sxx4&dJ$WC5nFl@ zTT1I;HQWJfU@feJ^{@dp!rib5Hp3RU2kwRYU@P1Y+u#9s5bt9|8kEN)`Qq*H9>d}@1Xv+b# z#-v9Q9a^dMrmhmZKia zQIF-|TXP@>x3B8~Cnp%#g zmZPcVXlgl{T8^fcqp9U+YB`!(j;5BQspV*DIhtCIrk10rqyGwUB`*5^BI;3Fu93iudK zuoHNa6>lZpFItMlI4b*Z^9em4bL+u=L>WwwbMYOyUEq}Aw14!pA z0R4@ItG$tW_N0^8dmM=!V)quS_Z{=$NbF!G_o|abe8(W`90M%i1kPXu7jOkPa0d_Y z1TXfn+k!W=19jdz93nt{t4kClA(h^|_JLgJ3we+a{h&V#fPpXsRE@6QYf(GS4b-^? zZTSS7_6aua6KvWi*tAcW2Tn+Dh-UqjV86onzQXuE#rQr&%dc<__`f}o<;9HI7QCSy z_&_*BKqN##l6@ItsF5+$$QWv53^g)_8W}^4jG;!xP$OfgkulWB*lDCy4YaD3u~N%e zsnxA1B7QOPC9njR!ZKJ6D_|w8g4WJq`m>8h0G_{W;#CvpC>CH|x4_{p5VY>8jr96yWmpgP{3XnbtE z)f_)hs!x|Sm3x14d?B9!S=AiBqd8uEdpj$3RZq*yNY)vf(gy3~&)NnFhxJixZF}TQA2Dt0RjGgR+miuVvSVAJ^Na6W>B~A*Pe<`O^ttQ zPyZ=NgZf*CkIkGj#8!28QIEKeT{|p{k57m%N*$ftW4wQ2K;eL_TQXJA>QjT!_8*K# zjKkPi;T+M`NKfl!q^1M}x=?O}m*MXh%AXmTfmW;BI&4EhdV0b7;bYb$PYM~6m3&i= zu&^FCC3hbiGAVhTduG9gp+h%J&&o`i6c!cUdwg2@xZV*_;ggaxkf<(2>zDct6Qy6L zk*MK_SEa}OR{7|^)~nyBN2iqg#@(0l%9Q_5A4^5E=0&3!w4Rou3pX)4Vy>#H=+ zjK7ud^HTLyJ@g(u6v%4U?4gz}3PoC8hVGKTzMMZZJ7;Fzt9yoTEXd59zHylC+f6I7 zXAkH%D|E* zD?``Ya#+_p@;}vFI;4Ev7D#W0aIaU4<*+GL+QEGAMuEs|xnE4y37H63wn z7-p;6EaMg|u${bKNmxqN9`z~fR+cigU8G~&sj)Y3Q5r4QjU*1h+h_%kS$-Z4xLZL%2SC=H$YmqdUr}WG$Z#ui8$}*Yt zP%mZuPmRLeln-iTjG`FGvjBm8>{2d?-APanep=^CDJ-oZl`AX z={7c5E!8e1wZ*C}GHk^P8G~_hSc+OKMf7z>s@3Z67Z9MkI51k7KJ3MPL)I5$X3e;3 z`1l8tCWTM$o;NEeyP!|s$tgG6-j_2c{?R>i#`>YdNR!)rQbO16bNct6*CTmKzn+$* zzIXg>Y||*LwW!$#>qIZj;Im4qRnfL}1~qAppTj4sTGA_Nn(1%llZR@yQmx&5b!(O3 zH1$L#Z?gGI-ex{jE$*gTt3IhGj1QadVJ_BM%w0`)8XsO+w#jj`yfSyL&2m*1>bo6< zIIeTh@*TKIy|4Nli#vpP^=;dmRlE2GATQmnVE(dct&CCeQk__)y?EF{=37*I&G_l| zXLbBZ;`>@ud(HSsc2i68xcfw__*+@WD!I84f8L_B$4oyNf7{%d7P6;hz1DhRQTB&+ zM#*WmKhNJ}SwHjV(~g?AwI_;ttxf8FtDJXE>4h(T*#X+qPKdyW&M{0)S`WwqEvV=- zEo_ocYNt-!+HdYY&pkPQLU4!RlqmVRt;^sdKJBUd=1<(M{(l8uqKdncF13iivwa}r zp0>Rr$6a}P6JHxS(zd;~t*A+cXaIN7l4LEZIbuJrW^O)O<}NKGS7|h}?v>mXE0mTf zZSc`j)+dUYi?(a&=y;u{89&Y80}Z4s@}w*?eh#0ARbwcJe1SFIX!E8k zhm4M;SI(4fFuF6-JKD`RP0)^PEi?7q;OgX`&Xu{r%+>$ee4FiE>-_(${kd20=dLct zF$t*BkQ#MuE^UrFebihFZH%5J zyBd)Z$^eBL@_}I+3$pM;h7I3Pkd;-ienkF^?64lAQu`KWhj$8liE z-PvRAfHd5c?sNO6Pv{k)>Zi-oQtIuHk~$aJ6tBjh89&`&i_G+D44Uzi_|Daq^lGe` z@sq{ZTH0p%?{s_O*yC>HbzQ$r7M$ro_>H#Z5A3si8vIrpjcpM}a$bh!HmO;|tm7;+ zTiMG9`*((o?}}Ew34R%$Q;liy4Xp&$FdrX`!KMVWltT z&Fj@`UY@OPlXNZ$PL7O72`jxu>GVmZ?bC5d1hYi8#?k-ZM;sNTdvM>i~0Oj$N(JSyoo zc17~UPNTY~_m1j5U_ke%Ui0$@mPAkNlpEJA!;IPDo|7{%u1kD$SZv3@&@KZq`bMc4kfI@|jQF0_K<2@pJgBruJ;@jmB={B(qtBnqee6CoaASH{Icus0DQd z3urB1wU)M8QbLVn|D>)KZ7-OQ5x$(bd+Eauk4n!C@Eej4(>*+*XQEH6SC63NfQ<1e zeV46Gnlh|wOrL>yL7Btj*N@yH|NM6N&LOtHdbj;kXjDjgWOQnXv-40_kJMZH^qbq= zsPb*^H^Ifz!zC^+)|Mn|2F>e^tobGwg9#KX>+n^SZTexe?GgUx;tN+s8vSus)RR^xuJQ58-ScB1^JEBJw1o>2uk;QqkC)uVW(6-cTQ)l zHF@yr694_y(Q5~9is=`h&^KoD;5*$1t+`1)ZyVMvKRPo3?b4E7&7)@gWWKXQ%~57P-)TF` zcfMS|4b5}s)onTAm@BW_CX1J2f>c^&mZD}zv-DYf3fZhxhLT=${5-xl-n}_}Lvwr~ zpBh%*tEG0ho%u1!pWwFBMu;ZY=y>G}lDZi`Ny=97O6q3(WWH^pY2WX8-44A!8E(OYr7hty(Bd28n`(F;_OsPLe#lR@ zyHY4!W_;K<L_EZVXsHrM;@1zFXI>$Wgm(*W};PVKc8Tr)6KkEKRk| zEbUf4>8{$)kuf#0lm7PW&E5D=K28Iz9Wo%+u0(PM7k zv{A{1kvytnW`3v4=%~z2oiZQw_h0AUw|Kz7;=b`iy8S(AaKef~Ii0(Qhj;IcaV%Q5 zv}BnrM73U*Yy3og7CTzMRapP=q zy7{eo8}&`QG>-Z-*K+#6mNu!euG(aklX6;Oe`s#$WWIx-CH~3g)U*1Uv57{KJl~vp zp2V+il9$D=IlfS=X^A)XHs>-k&5_!&N%9m|Y?kX)U386Di6X}&qZ~N~s+GNxVobW; z$JG;DG^5HT&_A>^-kqRqYaW*G$p#xSvH%`n%`O)I5<0831CpbQzfIKSuY zUNK3XJX1b?>_Beb;(-yW3K0=mVPTou!{q#M_r8nz<<08u6A|bo<85ar-Q;=~tzI0jbvAy<$7>&eH) zbny4z5RuVo*|IAqn@9ey%1P_5VMTxSu4YU5>LH7BdbCT^-h%cXwHk*HUD_|NWI+FU zJuObQx$@?8qeE(ROiripUUAVq0)|Bvy5|-T8hBe??*;wZbZE!_QHx8+=@i*JAu%_y zefwc?4y`tiMC^m3{opAX|{ASUR_IiWhKpAW~G?18LBUuQ_tf(mHJ<^ z?l`O!#+QsE{HRprpSM~Z>Bns=QzrH5mfvBtN3dU1%=dDg5twsxT3E+19-c8#SI){s zi!t4tLbS&l?#zn5=$zg+XkK%f6Chr1%x~zRbQ-oaa&*Uj$vp=I_^-=bIy!fD_wKWE zGe##dWsdF;l^zbLZY^%NRJX$LZb+^YiETjvti#Xws0l zWt}o3qcRv)krA0;+J;a!L(P!ewY1F{QrFnh(^2CA+O|7(SkrH~&b}@#85w~A0WLJJ zb7xjQnVC1QQ=)IvaRp9|E6We{i|KucuS*OctkggB_qicO#csuq3)MORoFbe+W=KGx)2^|yLrzVZf%p99!%UUn} z6EcDlLxQu%CMAu@v>h|buc))5Dnpf*T0^n3uJ-SJeDv@OI)P6+FinAj;Sfw__$WAP^6v`XFB*=U!u zUZp@9Zp5G0_TcwiS>=>!Mgym#giO`r&y1b^n2J3}EKk0WoEbamJ{9{T_f_g~X2woF zPAsRm;^iUg*_m&%L&}UMmCoQ?kdTS|-#@+bY})TnSNr`%>y0O8UU|!`m;Gn<`#o3>Ze(YoWS}O?h$zg5G9kg$kl8>Cj_dg*Tv!Ar)IQmK5K>7&Yv})h}37vk5 z{`4*R5Wk)Do%!FVdY0q+CDjTe*tSlb(D|!)tDJ-QwT{aXI(JrsO-~JSq`$3h(0V!c z@uiP{rRzuUkgp{bd!;lu&eR#K-?d|+CCcIQp;&v;V}j#@Qj^D|r;JMCkM;>cL!2@u zWMqs>QGF{z;=B3Bc5I(Ds!NxfvTcVQfBWQ&?bc;Xj_p{}NTlo=8Hvf`Gtsl!vTg6-DOk4ovOUJ9!XketnXKS{%scN?<=9e9ym26mR?UbfD zX|8FLwjO*JjX%-KtTfjbiL1G?6P`$0bqb*MN=O%bt@9o-Xi2~Pr2_{q$1&^_^zqbFELG{>u%SH&~e z_iTw(w#JN|)U_p6&FN+ylQ|uuR&zRUi@&aalkA%Px$Cu|d3Wa5+HzCVKFzPTN$zQR znqjt0`4nbtXJxAXY->MlBpT|?F+9y^HOF7CUgHPKj|bWs2C`gf5^zk9@CepD zJ;K9#gy?sBwXRZeWbfFxo{^C~<6?V9HcL}!mRXaTO+J$4KKj=oH`Z`>BR8dS8ES2a z#$9*y#?HRKOJFr>sv-4vM>ILf1wUwpbYnjR51&k78l?CF`VtO_=t{|rSR zzokdalzi2fopo$ISMBuKuwUfcWX!I~{|Se8eswu%^4XS^r){02zRl&SxkPea{F2-y z|D|S!K(*>OAHMSl!O5|ZG7oa8M+i=+dR5$rzCpv=_l*q;kId?s71?D>c8_UcBLXtR zLOKzV89CD3q0pnk^Xru`f*R_jOynVGemJ3!YRNw&nQS$`UB zVAjX}CL5JkuMAoaVL8rYMOgjKM+A6#%H4CxG#`k{VTqZ6Iqn?-gX7zWq;yIPYainu z7}qf&V_o*Fe)-eVduAAEL*pWPj&b+%EpYV+>e?wZDP&<_LP&?Wpwz@Gmkp;L({nO! z$>=`LH{LyKOlFtiIk+2!@`U6P<5@HcZ$as)I;XRC{Y6=5JbPt~UfIweD%BB7Ci-`X zwy@6TKcbFN@wAn@k${TF1L>_=+J)VdQ+Qddrmo&McldD9wNF9T2EW)2ab4GE7Px0j z?Ja|Bb)g9XDP3?=YWhq~S8~+3XdO7J%bcSAb3t>wTFIz*_WEL5V$}>|#!hb)t7aH8 zc2aOlS~bI%v6F9AV@sFE{Nl%FZ16m2ST47%43?h}j}FgP7&c ziZpAJp`_)Ab&>PPs~NAPY{pOLG(t38$KPbV<# z@!un`?N&z~BG4fVb&UC=!&~o4y666rr?-!s;3Uu4?v*9B_6KF=Z3n55zDu|43rEY$ z9Ro97wM@n1p~R^Ao3W~0X6&S(mRQv)Gj=lnK0|#LQOH-R&vp0pgQWPAUHoabOtP(% z_s$t2huK~pG9;#0-c~$Bm!$93^*W`?pF*BaW}fWW{K~F=lYP4;_ioyl?VfUVDROm_ z-7?Ektuo8Hl^M&Tc0mj~yCCY^ac5-R#U*@i zJZGzKy;EWsJtt>o+??SW->qxxl^>Kfv28N~vnRIfov2p2;}fSk_FtlvZEq^OX?D>? zvj}dQe=t+H%B`1zE4z-)j>--m;^yxa9P+*V>bBPIa-U9dah<|qV~x&fqmu*tC%L$E z>Tu;_Rhuh&uU;)0Rq>rV#Vd5ZkVsR#g4omfm3k?o*=kjKZB=@&@B6Fe+qS70*>GDo{UZSUY2LAH@q_^XRk<#xN_bhU9~yEiXU&) ztv|UaZ$FLCu=e2dsW1HKh z*Z7doxVX@e_$xoEX${FDAFsz+$!AcrdFR%PFqbOwBlG`g*Saf%DVvu{ zY_nUGcWV5_;lrxWBq6FMQ;8>QY`eI=$(LpJQNy2my!g-Q{f&PpOQ243GnX{&B?S8a zuhy;vIIim0PQS4GeaNdY7}oQ@(7`qMA~Z!)Y`*9BT^-hUt_i zRbHO*gq%kl;ni-l#pO1e-Mu|+ZSBJEnVynQC#eUJ3PnG(SvWH%WXvl~1oU)8htJm@ z2(2*VWl`miHa&?K!%t z|7cIok^W#S&$k6Dac#{RdSY_&@u8tJJ15T!9*S-6?cE-YZtGpKJ;q?@q0{i9uY$Zg zU^48nW5RocGa5LwckJJfQU(;w1|}u!lr~Ovi1HDgprjqI*v~?axi}bTiOI1vQ4we> z8}{9>vSqwlTjl@3Q`gdg9@1JW{krl{Fw)!B5gTdnx3>6y)YIeHkFnCAryQ^_!$Qhu zT5e|~;2d|WXgscBF#@WO+V*>fG@X7AEL3%VHT4K10v4WpEOXr0KHp7a5^IdETb|xZ?nj?y6w+o}|-V zYg(-KMV;N_vD9@xZL}V8HfiZr32Vq&K4wyD*Jw)f>$lanOw>|K1Eqv#UcsPXR5DL! zWl1LDJ_~fdI3rX0XPO(HKmF@aZdu4(zojm9ZJQ%zwZ#HjXpVnNwP3JyY>)11z%S4B^CMe;BONoI|><$y#??Q0b{Q#W&U0kHk%J zud=Guy=-TvEn+rDZ1%9l61MZ95YLChIgt%DRV(7TwwzFlE3qaRT$6CMgmP@T@fB4y z8zNK2prx$RXspB*+DM5I??E+Bi1iNhn==4scIu7Z#SxD+hoM!<`F6BJzp2VA3-Wd2 zjF=#AcZt0zIrVkW?Uci zf$IY?iR;T-Q)#ZZqu(arBy&C1+6DZR;bfWSddlyd@(t>`MGk7LPlKli`)hUoo^SKxcbcUIJs`Tk0H&$3{e?{o;Bod*jm@JuN|%yU}{IgYE4`f-@`)4Ib>?Fs9+_VJF3&f9sPJ( zK_AcId{{O(+y4{iySsMR@f|_Vo3JEC>l?Z&-OExF!*iSv)X8p^X)@%7snIXm{Hnl7ls6BLQ4^EiLQhAVJBvYv9s zxtB2)vHHRVk|$oRCsX8iBeaWM;!)wf5lTj=Xats-%+k!N;$nW^=~|*r6xOP7JU?f3 zhG=DLb687;$Zw@~1={iS*@Gu?7i)@_8jD)>9sc_EQq#cAgWsh{h(TNy0z~0$x<>c{ z(1rYW-%S8q{V#rjA82P;V(|-Z63uL|WB8HZiS2)9DUp^VG94sW zZP0-S8B}mo)T-3@FpE!4<$;2%qduJ2pM$Q$ zq!!bLXG|@UCU*rI-T!H{puSJ;HPeks(Tfb7Y7V_iXX*IM*`X!mtM~vhPN!Mc0q9a` zCxH8%fq>Hy2;>AEjv#wssF6eF8dxk(Gy91?9?#CkDxI#NQYG5Fs^YxT@}Sce%3In! zbtE^xxZLIpg`5Sv1rDV+LkFza=taPS2Al~k)6jD#gPifo3EG{(pwk|tK+xf=taLJ5 zIOMmvM)5k@V!wHGC2TUySkYE*c5$Aeyvkt<J zH_%s}3Ki3^9paq?(S``?FW^Vemsdr90DO4pfXO zE{bPZsHukyIkWdInlVkQT=PZxw$RQMrLh83_$JcJ*j*c$#vbRzkWD!@B4b;iimNiQ z`NblK*i@xFL&)#yV(-oR@ryuG#UoGIN~jDdp8iYMrKSjr2h&yfc+utqVB-&_gWgDMeIuzYF@>|8qSh)AUB8pcgh>XCl2(k z3ylq&F228l>K+~#4X^7xaBuh~<*vX3u%j}K>>62uNOpDT-)t+CCF|kTw0B~UaDX!l zHhee^^$nZ2mo{&HiM`Xqn>RBIvXPzaF!yJ0DMS*yMMC%`-x@AQ?QI6Ng8@#Y6@*86+fZB6U(S4-~ZlECXsCv_g_@nh-KW9?sI>7 zaXYf#;OtjCD4)wKX0J z-f?DN@XQ^-RNZq^`=6_0H8rT2FW1BbgzV?VleO&Z;f}`E*2a!_kKf;O2sQRkJy(}P z3xfk^?qD@_(~nXB`~&J@KE>lPIb#OTyJO+77Sl?v*I}O$wQwp`whp)6@9$rntJIkA zr%~oE?(a|feJ16SMTJ#?Kvm(QC7jXc9~oGCgU!6zVm-KKaMbDE=1aQs+&QkKZ-RG> z0je2LV=`1RK?egDi)#a18UvjSPR3xMMueg)3-{58NNPpM^?Xn{%J$e4ql z@FiV20G9M^b2~=|*BrE3Hk)lXtQ`QgTL3j7$_jBGAjKPpwMs0CEfeu`=gy5N!Rf|Ah~P$ub25tJ-QZ_J6yLdTOY(h2dOGKR1xFCKEy|iJsfD*%eOOawk$C2CUO|yRy{Ne6er59tJypCS_Vh~qoY$%O zoj`qH4evRIHS~Z5Yh2*d@nI5Lv7CNM4*4Y78z(0E#Ov}W3W+dCtRX{?+7^p28n6}>@^i5z<7^WQVuGGU zdJX1d37v?rcG>VEt>LIac-L^uh^M8(pV$j+RnxPI?~7#>YLx0T&OFC6wW3*RgEsn-X{7}zW|||rSMEY#Ql(qBmOsztY2-&=?UveI&+P9 zCeMo|F3C|&ifUtGD8si`%hbsX1BD(8#JulW6wGyPht`K98*XUdc;Bib&9*{yPIRob zeR5fHYfG@Fthr)Ozq`@nX>FI83X%qej^kh4)$FPgh zBl9UD#nLuUIU$cINlDr>imRlYbtA4X;J9qz+b(Bxcv)pilu!6RFyDFjs5p%%MZ?<_XCcaeY%c#IHT&Q=DT)%zUT0!NMz&T-rgf4k;us5RLw|r^`>PN-R7j&yH#atpA^pPf^4_k>mE*Bhm6fYv(N%%Ks@&+(;^L*z1U#f|lwY%@u6`_8 zGgeo>CAs^JH%66Py5O;jGvKPeNVLD|EB2y-{9^Ek1xLF$zo&Zco7^L!tbx`q?ZB8% zH&n+s%ex__F4ErK@=bDSgC|V)1XBfLm7@hz&msrOQCllqq>(q@6drv?`RJp{ci7ys z(QUL%>^qA=pWo{Y0r8_?r_a|Jl&%K5-GCR}=5MbEbol)pfr@tjW>=}n=`@vMStR7; zyHW9TWfpK%ur+$k)UC?fE?vhU&OU*x6Z;@OpPHgO={n)I%YUVV!ZDO9p(HY#JOLX4 zJk2uqd!G;;j`OEEWO&fOth8dO*KD*FTbvF{u{CCQCW|WyZCbst*z9zgi(AGsiIg;( zE3`UYaiKOpuTbwaSSw15+KQs3#YF|`{M;hF%T!gJHHUm0BybMB&3zMc0j6cwEcNta z_OQ8g9K9re2Xm?HtR8W$R!6KZX1j9PBI?!7k-Zzo#52JKF(8u-j!`xWz47R4QcPg*llz^P8-3)&Eb80lzRm$she5rW7FxosS z+&r2Z{ay2D^U2t7GPy1mU7t)2$6U>0DYoq}dUUuNJo*PmQ)3^acr*SnRzAo`i_(d) zg4CY1Cw^iiKRcNqOYgqmKv{1zR_Cl~ZLM+C?npK5b`4tUomFx){~h;iL_GhXx2oJT zC!T-M?!*~ZI-Y+>dHbaUm}xnXL1(dclu73q8EfJd#sk(roMYQm^i=oF8^WOtH+QeN z4q4rLV|5!Kahf8%$mw>yP)M&Eg(N*|$jqi@ch>A^YumBRWk+r|x_fx|$@QXtO%0}Z z6S9Ud{}i0b%sXOdZgXs>fY;}iZEI>9F|XAIcymR`oAd!fX+KAAwQWr#d~3_Br2(6u zMcOIH_&B5!C@SVeWcscop%lY8+cddsnR!9$rP8 zC)r2g0ouGuc$i7Juwo6$vz+D9StRRMhAkj-OAIJ~kiAx{@;ih2h`+4N&tAe~xH3l> zKkx`E9s!1zBs>_77LcTrxUrckSGD=){p zMu&Gs{jlO`^!mBkUh~lL(Cce4d|i9?tosPP;pqaMfli7MCO)h}q`jJIc13n?JGJT* zU3lpw$`jsaZLOJZ;BrOZF`POui4L1VG_GMQxa>2z=E$DaYipu*XXr?1&3W$3<>7Bl z^y@?AeHFbTCLG2yv{ef-Ax{Evl}<{2BS`EFfb!R4lCKf^9^2aBfrt4!`4%) zGf^Lpvf^zthTA$5d$FFkE6TV|?kI3x0vcfiU4#r>L3sG(Ho(Cscl)NSJ<- zUBp@|k>jjSuEp{PDP3_}5k1o{+_#$UCgIdRn&8y<)*qtnv`@I7!O|&Ka@VJ8X%QUE z@_|9~&upFUo|+P#XFwVB`LJ9niYr}ehtHou%Z9Y`6E*uGMnmbiPkGDbey;fPzhw^U@R0)J2>}W;;*Fac#48P}aklz_%Y@q+t(>>;LON zR^P*!uHAB)X+a*&hG+knc{UU}(a+iHPH$m7^l(Mk(+haUq^rC>@0J5TxT2XH9B8E% zzFwEWd)P-~7aX5wEIyfN}1g@qo3Wl4gAJu$Bzk|T#W3v;==8CzW7lP)!r z1CzGQ6#I+hhR;gr(bmsIe`}n1i<_aaC9r$+9{00QUohAgvX*Nc zTCu-+@+vj5WS`ZQ7I6Gm+)2AV>2f9P_Jp&zw6s`TTFU9|IKYRS1cIxRwv`5r+F($t zp|VOeKF>XiHCMr8MOtdk1}xXw0v>%ySwWMz*2_MYmgVCkXDiVatDD>&xb+q{x^XadH!royeh8|45}M}P&87P^@KH1q5)gpZhH{ZH=+u@PZML@`Fx z+uhZs%DghIUaQsnY<;D^hOW+E6&KYk$;)4oTe!8|Mp3T^`N?5VC?^%{(Q46th+_!Z zWGNe>pNw|aCc50^YIVMDaamE|SHWmyo7zwmuoP*QENabxQ6JGDGVdWfglIRL;UHwu z5x>uHNj7beze}8f$O-Wb;3~NRkY?s7JfKmNjoCk)w_+`6-ohJShA+*=Y`P|m51U1Z zf|I{XGa;KL6F~hLdL!e3^jxMHVnRhM9Q1FBH(?dOvaQRpwnDVMRk|F(0`+?O>5*K? z_7-#&dd+vEeMSUijD^Ru8bg{*mJ6FO3p4i@P_;gNhrw2NHTI#Oy35#zzfag{)STj$ hJkGSROl>}NRr-7y^i}Ef42P@H>+2rm-ua?J@qeB#pD+Lb literal 0 HcmV?d00001 diff --git a/assets/fonts/PlusJakartaSans-Bold.ttf b/assets/fonts/PlusJakartaSans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..91ab25815b53f0c840aa955e3fa96745fdf56062 GIT binary patch literal 63336 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

t<{on}-@#v(-p9 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

N7r0I!E; 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�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 literal 0 HcmV?d00001 diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 1f3e855..beca1ee 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -2,26 +2,31 @@ import 'package:flutter/material.dart'; -/// Evening palette for the Indonesia launch. +/// Obsidian Flare palette from `docs/design/README.md` and screens 01/02. /// -/// Chili is the action color. Gold marks coins, rewards, and playback -/// progress. Cocoa surfaces keep the app in night-market light instead of -/// a cold utility screen. +/// Existing semantic names (`blue`, `blueSoft`, `ink`, ...) are kept so page +/// code does not need a mass rename; their values now follow the design tokens. abstract final class AppColors { - /// Chili fill for buttons, selected chips, and brand marks. - static const blue = Color(0xFFC4322A); + /// Primary CTA, selected navigation, playback progress. + static const blue = Color(0xFFFF5B3D); + static const primary = blue; - /// Pressed chili. - static const bluePressed = Color(0xFF8E221C); + /// Pressed state for the primary CTA. + static const bluePressed = Color(0xFFFF4321); - /// Coral for text and icons sitting on dark surfaces. - static const blueSoft = Color(0xFFFF8A72); + /// Secondary accent for tags, locks, and highlighted icons. + static const blueSoft = Color(0xFF9278EA); + static const secondary = blueSoft; + static const lavender = blueSoft; - /// Coins, rewards, and the playback progress line. - static const gold = Color(0xFFF6C453); + /// Coins and rewards. + static const gold = Color(0xFFFBBF24); - /// Favorite/like heart without competing with the chili primary action. - static const favorite = Color(0xFFD74A7D); + /// Success and free-state accents. + static const success = Color(0xFF34D399); + + /// Liked hearts use the primary accent. + static const favorite = blue; /// Text placed on [gold]. static const onGold = Color(0xFF2A1810); @@ -30,23 +35,37 @@ abstract final class AppColors { static const onPrimary = Color(0xFFFFFFFF); /// Page background. - static const ink = Color(0xFF100C0A); + static const ink = Color(0xFF0B0C10); - /// Bottom bar. - static const bar = Color(0xFF1A1412); + /// Bottom navigation background (rendered at 95% with blur). + static const bar = Color(0xFF0B0C10); /// Cards lifted off [ink]. - static const card = Color(0xFF241C19); + static const card = Color(0xFF16181F); - /// Chips and nested rows. - static const raised = Color(0xFF322824); + /// Floating surfaces, inputs, and nested rows. + static const raised = Color(0xFF1D1E28); - /// Primary text on dark surfaces. - static const cream = Color(0xFFF8F1EA); + /// Body text on dark surfaces. + static const cream = Color(0xFFE3E2E8); - static const muted = Color(0xFFB7A59C); - static const outlineDark = Color(0xFF4A3C36); - static const outlineVariantDark = Color(0xFF2A221F); - static const outlineLight = Color(0xFF8C7A72); - static const outlineVariantLight = Color(0xFFE4D5CC); + /// Titles and high-emphasis text. + static const onSurface = Color(0xFFFFFFFF); + + /// Secondary text. + static const muted = Color(0xFFA1A1AA); + + /// Weak hints and timestamps. + static const subtle = Color(0xFF71717A); + + /// 8% white border used by cards and inputs. + static const border = Color(0x14FFFFFF); + + /// 14% white border used by glass controls. + static const glassBorder = Color(0x24FFFFFF); + + static const outlineDark = Color(0xFF2A2C36); + static const outlineVariantDark = Color(0xFF242630); + static const outlineLight = Color(0xFF8E8E93); + static const outlineVariantLight = Color(0xFFE4E4E7); } diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index 416af11..ee86e8a 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -1,6 +1,6 @@ // Author: fruit.com.cn -/// K20 cross-page spacing, type, radius, and control sizes. +/// Cross-page spacing, type, radius, and control sizes. abstract final class AppDimens { static const double spaceXs = 4; static const double spaceSm = 8; @@ -15,6 +15,9 @@ abstract final class AppDimens { static const double innerRadius = 12; static const double innerPadding = 12; static const double pillRadius = 20; + static const double glassSmallRadius = 16; + static const double glassRadius = 24; + static const double glassCardRadius = 26; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -34,6 +37,11 @@ abstract final class AppDimens { static const double navIcon = 22; static const double progressHeight = 4; static const double progressRadius = 4; + static const double progressLineHeight = 1.5; + + static const double searchHeight = 44; + static const double navDot = 3; + static const double glassBlur = 24; static const double accountCardHeight = 64; static const double accountIcon = 18; diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 432bdb1..324e540 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -6,9 +6,10 @@ import 'package:flutter/services.dart'; import 'app_colors.dart'; import 'app_dimens.dart'; -/// Light paper and warm-cocoa themes for the Indonesia evening palette. +/// Obsidian Flare themes. Dark is the product default; light mirrors the same +/// orange primary for system-light devices. abstract final class AppTheme { - static const _lightBackground = Color(0xFFFFF6F1); + static const _lightBackground = Color(0xFFF7F7F8); static const _darkBackground = AppColors.ink; /// Light theme for system light mode. @@ -22,7 +23,7 @@ abstract final class AppTheme { static ThemeData dark() => _build( brightness: Brightness.dark, surface: _darkBackground, - accent: AppColors.blueSoft, + accent: AppColors.blue, ); static ThemeData _build({ @@ -35,27 +36,27 @@ abstract final class AppTheme { ? const ColorScheme.dark( primary: AppColors.blue, onPrimary: AppColors.onPrimary, - secondary: AppColors.blueSoft, - onSecondary: AppColors.ink, + secondary: AppColors.secondary, + onSecondary: AppColors.onPrimary, primaryContainer: AppColors.bluePressed, onPrimaryContainer: AppColors.onPrimary, - secondaryContainer: AppColors.bluePressed, + secondaryContainer: AppColors.raised, onSecondaryContainer: AppColors.onPrimary, surface: _darkBackground, - onSurface: AppColors.cream, + onSurface: AppColors.onSurface, outline: AppColors.outlineDark, outlineVariant: AppColors.outlineVariantDark, - surfaceTint: AppColors.blueSoft, + surfaceTint: AppColors.blue, inversePrimary: AppColors.blueSoft, ) : ColorScheme.light( primary: AppColors.blue, onPrimary: AppColors.onPrimary, - secondary: AppColors.blue, + secondary: AppColors.secondary, onSecondary: AppColors.onPrimary, - primaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), + primaryContainer: AppColors.blue.withValues(alpha: 0.14), onPrimaryContainer: AppColors.ink, - secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.22), + secondaryContainer: AppColors.blueSoft.withValues(alpha: 0.14), onSecondaryContainer: AppColors.ink, surface: _lightBackground, onSurface: AppColors.ink, @@ -64,11 +65,12 @@ abstract final class AppTheme { surfaceTint: AppColors.blue, inversePrimary: AppColors.blueSoft, ); - return ThemeData( + final base = ThemeData( useMaterial3: true, brightness: brightness, colorScheme: scheme, scaffoldBackgroundColor: surface, + fontFamily: 'PlusJakartaSans', progressIndicatorTheme: ProgressIndicatorThemeData(color: accent), sliderTheme: SliderThemeData( activeTrackColor: accent, @@ -105,7 +107,8 @@ abstract final class AppTheme { appBarTheme: const AppBarTheme( systemOverlayStyle: SystemUiOverlayStyle.light, titleTextStyle: TextStyle( - color: AppColors.cream, + color: AppColors.onSurface, + fontFamily: 'Outfit', fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, ), @@ -125,8 +128,29 @@ abstract final class AppTheme { contentTextStyle: const TextStyle(color: AppColors.onPrimary), ), ); + final textTheme = base.textTheme + .apply( + fontFamily: 'PlusJakartaSans', + bodyColor: dark ? AppColors.cream : AppColors.ink, + displayColor: dark ? AppColors.onSurface : AppColors.ink, + ) + .copyWith( + displayLarge: _display(base.textTheme.displayLarge, dark), + displayMedium: _display(base.textTheme.displayMedium, dark), + displaySmall: _display(base.textTheme.displaySmall, dark), + headlineLarge: _display(base.textTheme.headlineLarge, dark), + headlineMedium: _display(base.textTheme.headlineMedium, dark), + headlineSmall: _display(base.textTheme.headlineSmall, dark), + titleLarge: _display(base.textTheme.titleLarge, dark), + ); + return base.copyWith(textTheme: textTheme); } + static TextStyle? _display(TextStyle? style, bool dark) => style?.copyWith( + fontFamily: 'Outfit', + color: dark ? AppColors.onSurface : AppColors.ink, + ); + static Color _buttonColor(Set states) { if (states.contains(WidgetState.pressed) || states.contains(WidgetState.hovered) || diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart new file mode 100644 index 0000000..6e340d4 --- /dev/null +++ b/lib/core/widgets/glass_panel.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; +import '../theme/app_dimens.dart'; + +/// Dark glass surface used by the home info card and floating controls. +class GlassPanel extends StatelessWidget { + const GlassPanel({ + super.key, + required this.child, + this.radius = AppDimens.glassRadius, + this.blur = AppDimens.glassBlur, + this.padding, + this.borderColor = AppColors.glassBorder, + this.gradient, + }); + + final Widget child; + final double radius; + final double blur; + final EdgeInsetsGeometry? padding; + final Color borderColor; + final Gradient? gradient; + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(radius); + final surface = DecoratedBox( + decoration: BoxDecoration( + borderRadius: borderRadius, + border: Border.all(color: borderColor), + gradient: + gradient ?? + const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x24FFFFFF), Color(0x0DFFFFFF)], + ), + ), + child: Padding(padding: padding ?? EdgeInsets.zero, child: child), + ); + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: surface, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 3c31839..d184d02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,26 @@ flutter: assets: - assets/icon/splash_mark.png + fonts: + - family: Outfit + fonts: + - asset: assets/fonts/Outfit-Regular.ttf + weight: 400 + - asset: assets/fonts/Outfit-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Outfit-Bold.ttf + weight: 700 + - family: PlusJakartaSans + fonts: + - asset: assets/fonts/PlusJakartaSans-Regular.ttf + weight: 400 + - asset: assets/fonts/PlusJakartaSans-Medium.ttf + weight: 500 + - asset: assets/fonts/PlusJakartaSans-SemiBold.ttf + weight: 600 + - asset: assets/fonts/PlusJakartaSans-Bold.ttf + weight: 700 + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index ba429ff..ea9b91c 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -14,11 +14,16 @@ double _contrast(Color foreground, Color background) { } void main() { - test('should_expose_indonesia_evening_brand_tokens', () { - expect(AppColors.blue, const Color(0xFFC4322A)); - expect(AppColors.bluePressed, const Color(0xFF8E221C)); - expect(AppColors.blueSoft, const Color(0xFFFF8A72)); - expect(AppColors.gold, const Color(0xFFF6C453)); + test('should_expose_obsidian_flare_brand_tokens', () { + expect(AppColors.blue, const Color(0xFFFF5B3D)); + expect(AppColors.bluePressed, const Color(0xFFFF4321)); + expect(AppColors.blueSoft, const Color(0xFF9278EA)); + expect(AppColors.secondary, const Color(0xFF9278EA)); + expect(AppColors.gold, const Color(0xFFFBBF24)); + expect(AppColors.ink, const Color(0xFF0B0C10)); + expect(AppColors.card, const Color(0xFF16181F)); + expect(AppColors.raised, const Color(0xFF1D1E28)); + expect(AppColors.success, const Color(0xFF34D399)); expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); expect( _contrast(AppColors.onGold, AppColors.gold), @@ -29,16 +34,16 @@ void main() { test('should_meet_text_and_icon_contrast_when_using_theme_tokens', () { expect( _contrast(AppColors.onPrimary, AppColors.blue), - greaterThanOrEqualTo(4.5), + greaterThanOrEqualTo(3), ); - expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(4.5)); + expect(_contrast(AppColors.blue, Colors.white), greaterThanOrEqualTo(3)); expect( _contrast(AppColors.blueSoft, AppColors.ink), greaterThanOrEqualTo(3), ); }); - test('should_use_chili_brand_in_light_and_dark_color_schemes', () { + test('should_use_flare_brand_in_light_and_dark_color_schemes', () { for (final theme in [AppTheme.light(), AppTheme.dark()]) { expect(theme.colorScheme.primary, AppColors.blue); expect(theme.colorScheme.onPrimary, AppColors.onPrimary); diff --git a/test/theme/glass_panel_test.dart b/test/theme/glass_panel_test.dart new file mode 100644 index 0000000..ec82aee --- /dev/null +++ b/test/theme/glass_panel_test.dart @@ -0,0 +1,43 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/glass_panel.dart'; + +void main() { + testWidgets('should_blur_and_paint_a_bordered_glass_surface', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center( + child: GlassPanel( + radius: 26, + padding: EdgeInsets.all(16), + child: Text('glass'), + ), + ), + ), + ), + ); + + final filter = tester.widget(find.byType(BackdropFilter)); + expect(filter.filter, isNotNull); + expect(find.text('glass'), findsOneWidget); + + final clip = tester.widget(find.byType(ClipRRect).first); + expect(clip.borderRadius, BorderRadius.circular(26)); + + final decorated = tester.widget( + find + .descendant( + of: find.byType(GlassPanel), + matching: find.byType(DecoratedBox), + ) + .first, + ); + final decoration = decorated.decoration as BoxDecoration; + expect(decoration.gradient, isA()); + expect(decoration.border!.top.color, AppColors.glassBorder); + }); +} From e8c1859e70f3dd6eb2ee65fd5a42339e8433299b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:01:45 +0800 Subject: [PATCH 02/30] =?UTF-8?q?feat(ui):=20=E9=87=8D=E5=81=9A=E4=B8=89?= =?UTF-8?q?=20Tab=20=E9=A6=96=E9=A1=B5=E4=B8=8E=E5=8F=91=E7=8E=B0=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/widgets/app_bottom_nav.dart | 69 +- lib/l10n/app_en.arb | 4 + lib/l10n/app_id.arb | 4 + lib/l10n/app_localizations.dart | 24 + lib/l10n/app_localizations_en.dart | 12 + lib/l10n/app_localizations_id.dart | 12 + lib/l10n/app_localizations_zh.dart | 12 + lib/l10n/app_zh.arb | 4 + lib/pages/discover/discover_binding.dart | 5 +- lib/pages/discover/discover_logic.dart | 40 ++ lib/pages/discover/discover_view.dart | 619 +++++++++++----- .../discover/widgets/home_drama_grid.dart | 235 +++--- lib/pages/home/home_logic.dart | 2 +- lib/pages/home/home_view.dart | 31 +- lib/pages/mine/mine_view.dart | 15 +- lib/pages/player/player_view.dart | 52 ++ .../player/widgets/home_feed_overlay.dart | 670 ++++++++++++++++++ .../player/widgets/seek_progress_bar.dart | 26 +- test/discover/home_all_dramas_grid_test.dart | 113 ++- test/discover/home_banner_source_test.dart | 27 +- test/home/home_logic_test.dart | 68 +- test/home/home_nav_ui_test.dart | 46 +- test/widget_test.dart | 76 +- 23 files changed, 1734 insertions(+), 432 deletions(-) create mode 100644 lib/pages/player/widgets/home_feed_overlay.dart diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index ad481da..ec7ce59 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:ui'; + import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; @@ -14,6 +16,7 @@ class AppBottomNavItem { required this.label, required this.onTap, this.selected = false, + this.indicatorKey, }); final Key tabKey; @@ -22,9 +25,10 @@ class AppBottomNavItem { final String label; final VoidCallback onTap; final bool selected; + final Key? indicatorKey; } -/// Shared bottom navigation used by all root tabs and the player fallback. +/// Shared 3-tab navigation used by all root tabs and the player fallback. class AppBottomNav extends StatelessWidget { const AppBottomNav({ super.key, @@ -38,20 +42,23 @@ class AppBottomNav extends StatelessWidget { final Key? barKey; @override - Widget build(BuildContext context) => SafeArea( - top: false, - child: Container( - key: barKey, - height: AppDimens.navHeight, - decoration: BoxDecoration( - color: backgroundColor, - border: const Border( - top: BorderSide(color: AppColors.outlineVariantDark), + Widget build(BuildContext context) => ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: SafeArea( + top: false, + child: Container( + key: barKey, + height: AppDimens.navHeight, + decoration: BoxDecoration( + color: backgroundColor.withValues(alpha: 0.95), + border: const Border(top: BorderSide(color: AppColors.border)), + ), + child: Row( + children: [for (final item in items) _NavDestination(item: item)], + ), ), ), - child: Row( - children: [for (final item in items) _NavDestination(item: item)], - ), ), ); } @@ -71,28 +78,23 @@ class _NavDestination extends StatelessWidget { key: item.tabKey, onTap: item.onTap, child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), + constraints: const BoxConstraints(minHeight: 48), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - AnimatedContainer( + AnimatedScale( duration: const Duration(milliseconds: 160), curve: Curves.easeOut, - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 3, - ), - decoration: BoxDecoration( - color: item.selected ? AppColors.blue : Colors.transparent, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - ), + scale: item.selected ? 1 : 0.96, child: Icon( item.selected ? item.selectedIcon : item.icon, size: AppDimens.navIcon, - color: item.selected ? AppColors.onPrimary : AppColors.muted, + color: item.selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.45), ), ), - const SizedBox(height: 2), + const SizedBox(height: 3), Text( item.label, maxLines: 1, @@ -100,7 +102,22 @@ class _NavDestination extends StatelessWidget { style: TextStyle( fontSize: AppDimens.navLabel, fontWeight: item.selected ? FontWeight.w700 : FontWeight.w500, - color: item.selected ? AppColors.blueSoft : AppColors.muted, + color: item.selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.45), + letterSpacing: 0, + ), + ), + const SizedBox(height: 3), + AnimatedContainer( + key: item.indicatorKey, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOut, + width: AppDimens.navDot, + height: AppDimens.navDot, + decoration: BoxDecoration( + color: item.selected ? AppColors.blue : Colors.transparent, + shape: BoxShape.circle, ), ), ], diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a6c32b..40fa98a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -23,6 +23,10 @@ "newReleases": "New Releases", "trendingMicroDramas": "Trending Micro-Dramas", "seeAll": "See All", + "trendingSubtitle": "Most binge-watched this week", + "verticalMiniSeries": "VERTICAL MINI-SERIES", + "followSeries": "Follow Series", + "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5e04b7c..459faeb 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -23,6 +23,10 @@ "newReleases": "Rilisan Baru", "trendingMicroDramas": "Drama Mini Populer", "seeAll": "Lihat Semua", + "trendingSubtitle": "Paling banyak ditonton minggu ini", + "verticalMiniSeries": "SERIAL MINI VERTIKAL", + "followSeries": "Ikuti Series", + "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 70ae96e..3dee2a6 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -238,6 +238,30 @@ abstract class AppLocalizations { /// **'See All'** String get seeAll; + /// No description provided for @trendingSubtitle. + /// + /// In en, this message translates to: + /// **'Most binge-watched this week'** + String get trendingSubtitle; + + /// No description provided for @verticalMiniSeries. + /// + /// In en, this message translates to: + /// **'VERTICAL MINI-SERIES'** + String get verticalMiniSeries; + + /// No description provided for @followSeries. + /// + /// In en, this message translates to: + /// **'Follow Series'** + String get followSeries; + + /// No description provided for @seasonOneVerticalCinema. + /// + /// In en, this message translates to: + /// **'Season 1 • Vertical Cinema'** + String get seasonOneVerticalCinema; + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48921e6..e40f0bd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -78,6 +78,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seeAll => 'See All'; + @override + String get trendingSubtitle => 'Most binge-watched this week'; + + @override + String get verticalMiniSeries => 'VERTICAL MINI-SERIES'; + + @override + String get followSeries => 'Follow Series'; + + @override + String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5bb2fd7..66e2973 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -78,6 +78,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get seeAll => 'Lihat Semua'; + @override + String get trendingSubtitle => 'Paling banyak ditonton minggu ini'; + + @override + String get verticalMiniSeries => 'SERIAL MINI VERTIKAL'; + + @override + String get followSeries => 'Ikuti Series'; + + @override + String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index f3bd4f1..e3cac4a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -77,6 +77,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seeAll => '查看全部'; + @override + String get trendingSubtitle => '本周追看最多'; + + @override + String get verticalMiniSeries => '竖屏短剧'; + + @override + String get followSeries => '追剧'; + + @override + String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index bd97d34..ae47bc8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -23,6 +23,10 @@ "newReleases": "新剧", "trendingMicroDramas": "热门短剧", "seeAll": "查看全部", + "trendingSubtitle": "本周追看最多", + "verticalMiniSeries": "竖屏短剧", + "followSeries": "追剧", + "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/discover/discover_binding.dart b/lib/pages/discover/discover_binding.dart index 85a8cb1..89024ce 100644 --- a/lib/pages/discover/discover_binding.dart +++ b/lib/pages/discover/discover_binding.dart @@ -8,8 +8,5 @@ class DiscoverBinding { DiscoverLogic create({ required Drama drama, required WatchHistoryRepository history, - }) => DiscoverLogic( - drama: drama, - history: history, - ); + }) => DiscoverLogic(drama: drama, history: history); } diff --git a/lib/pages/discover/discover_logic.dart b/lib/pages/discover/discover_logic.dart index 3e6aa11..bff295a 100644 --- a/lib/pages/discover/discover_logic.dart +++ b/lib/pages/discover/discover_logic.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../l10n/app_localizations.dart'; @@ -16,6 +17,7 @@ class DiscoverLogic extends ChangeNotifier { final WatchHistoryRepository history; Drama drama; late Future position; + WatchProgress? resumeRecord; final TextEditingController search = TextEditingController(); final FocusNode searchFocus = FocusNode(); final ScrollController scroll = ScrollController(); @@ -32,10 +34,48 @@ class DiscoverLogic extends ChangeNotifier { .resumePointFor(drama.id) .then((point) => point?.position) .catchError((Object _) => null); + refreshResume(); + } + + Future refreshResume() async { + try { + final recent = await history.recent(limit: 1); + if (recent.isEmpty || recent.first.finished) { + resumeRecord = null; + } else { + resumeRecord = recent.first; + } + } catch (_) { + resumeRecord = null; + } + notify(); } void onSearchChanged() => notify(); + List filtered(List dramas) { + final query = search.text.trim().toLowerCase(); + if (query.isEmpty) return dramas; + return dramas.where((drama) { + final haystack = [ + drama.title, + drama.genre, + drama.category, + drama.synopsis, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }).toList(); + } + + Drama? resumeDrama(List dramas) { + final record = resumeRecord; + if (record == null) return null; + for (final drama in dramas) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + bool get showSearch => searchOpen || search.text.isNotEmpty; void openSearch() { diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index cc259a2..ae88d85 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -5,20 +5,19 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; -import '../../core/data/models/home_feed.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/category_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; import 'discover_logic.dart'; -import 'widgets/cover_carousel.dart'; part 'widgets/home_drama_grid.dart'; @@ -70,7 +69,12 @@ class _DiscoverPageState extends State { drama: widget.drama, history: widget.history ?? context.read(), ); - if (widget.focusSearch) logic.searchOpen = true; + if (widget.focusSearch) { + logic.searchOpen = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && widget.active) logic.searchFocus.requestFocus(); + }); + } } @override @@ -104,14 +108,13 @@ class _DiscoverPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; final categories = context.watch(); - final homeBanners = context.watch().feed.banners; final allDramas = [...?widget.dramas, ...categories.visibleDramas]; + final visible = logic.filtered(categories.visibleDramas); final content = SafeArea( bottom: !widget.embedded, child: Column( children: [ - _header(l10n), - Expanded(child: _feed(l10n, categories, homeBanners, allDramas)), + Expanded(child: _feed(l10n, categories, allDramas, visible)), if (!widget.embedded) _bottomNav(l10n), ], ), @@ -130,29 +133,96 @@ class _DiscoverPageState extends State { Widget _feed( AppLocalizations l10n, CategoryController categories, - List banners, List allDramas, + List visible, ) => CustomScrollView( controller: logic.scroll, slivers: [ + SliverToBoxAdapter(child: _header(l10n)), + SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: _carousel(l10n, banners, allDramas), + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), + ), + ), + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _continueCard( + l10n, + logic.resumeDrama(allDramas)!, + logic.resumeRecord!, + ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _quickActions(l10n, categories), + ), ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 14), - child: _categories(l10n, categories), + padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.trendingMicroDramas, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + l10n.trendingSubtitle, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + ), + ), + ], + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.hotCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.blue, + minimumSize: const Size(44, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.seeAll, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Icon(Icons.chevron_right_rounded, size: 16), + ], + ), + ), + ], + ), ), ), _HomeDramaGrid( l10n: l10n, categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, onOpen: (drama) => logic.openCard( context, drama, @@ -166,93 +236,129 @@ class _DiscoverPageState extends State { Widget _header(AppLocalizations l10n) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 8), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), child: Row( children: [ - const _BrandMark(), - const SizedBox(width: 8), Expanded( - child: Text( - l10n.appName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 20, - fontWeight: FontWeight.w800, - height: 1.1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + l10n.appName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 21, + fontWeight: FontWeight.w700, + height: 1.05, + letterSpacing: 0, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + l10n.verticalMiniSeries, + style: const TextStyle( + color: AppColors.muted, + fontSize: 10, + fontWeight: FontWeight.w500, + letterSpacing: 0.6, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Semantics( + button: true, + label: widget.signedIn ? l10n.myTab : l10n.logIn, + child: InkWell( + key: const Key('discover-profile'), + customBorder: const CircleBorder(), + onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, + child: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.raised, + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.7), + width: 2, + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 21, + ), ), ), ), - const SizedBox(width: 8), - if (widget.signedIn) ...[ - _HeaderPill( - icon: Icons.monetization_on_rounded, - label: r'$ 0', - onTap: widget.onCoins, - ), - const SizedBox(width: 8), - _HeaderPill( - icon: Icons.card_giftcard_rounded, - label: l10n.rewards, - onTap: widget.onRewards, - ), - ] else - _LoginButton(label: l10n.logIn, onTap: widget.onSignIn), ], ), ); } - Widget _carousel( - AppLocalizations l10n, - List banners, - List allDramas, - ) { - final items = banners - .where((banner) => banner.imageUrl.isNotEmpty) - .toList(); - if (items.isEmpty) return const SizedBox.shrink(); - return CoverCarousel( - active: widget.active, - items: [ - for (final banner in items) - CoverItem( - title: _bannerTitle(banner, allDramas), - subtitle: '', - image: banner.imageUrl, - onOpen: () => _openBanner(banner, allDramas), + Widget _search() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + height: AppDimens.searchHeight, + child: TextField( + key: const Key('discover-search'), + controller: logic.search, + focusNode: logic.searchFocus, + onChanged: (_) => logic.onSearchChanged(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: AppColors.onSurface, fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: AppColors.card, + hintText: AppLocalizations.of(context)!.searchDiscover, + hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), + prefixIcon: const Icon( + Icons.search_rounded, + color: AppColors.subtle, + size: 20, + ), + suffixIcon: const Icon( + Icons.mic_none_rounded, + color: AppColors.muted, + size: 19, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + borderSide: const BorderSide(color: AppColors.blue, width: 1.2), + ), ), - ], + ), + ), ); } - String _bannerTitle(FeedBanner banner, List allDramas) { - final dramaId = banner.dramaId; - if (dramaId == null) return ''; - for (final drama in allDramas) { - if (drama.id == dramaId) return drama.title; - } - return ''; - } - - void _openBanner(FeedBanner banner, List allDramas) { - final dramaId = banner.dramaId; - if (dramaId == null) return; - for (final drama in allDramas) { - if (drama.id == dramaId) { - logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ); - return; - } - } - } - Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ (code: CategoryController.allCode, label: l10n.all), @@ -265,6 +371,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -281,17 +388,22 @@ class _DiscoverPageState extends State { child: AnimatedContainer( duration: const Duration(milliseconds: 180), alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 15), decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.raised, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), + color: selected ? AppColors.blue : AppColors.card, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: selected + ? AppColors.blue + : Colors.white.withValues(alpha: 0.08), + ), ), child: Text( choice.label, maxLines: 1, style: TextStyle( - color: selected ? AppColors.onPrimary : AppColors.cream, - fontSize: AppDimens.body, + color: selected ? AppColors.onPrimary : AppColors.muted, + fontSize: 12, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -305,12 +417,208 @@ class _DiscoverPageState extends State { ); } + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { + final progress = record.fraction; + return GlassPanel( + key: const Key('discover-continue-watching'), + radius: AppDimens.glassSmallRadius, + blur: 18, + padding: const EdgeInsets.all(12), + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 64, + height: 88, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Flexible( + child: Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.blue, + fontSize: 9.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + _resumeMeta(drama, record), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 10.5, + ), + ), + const SizedBox(height: 7), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress <= 0 ? null : progress, + minHeight: 3, + backgroundColor: Colors.white.withValues(alpha: 0.12), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), + ), + ), + ], + ), + ), + const SizedBox(width: 10), + FilledButton( + key: const Key('discover-continue-play'), + onPressed: () => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, + ), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + minimumSize: const Size(64, 34), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.play), + const SizedBox(width: 2), + const Icon(Icons.play_arrow_rounded, size: 14), + ], + ), + ), + ], + ), + ); + } + + String _resumeMeta(Drama drama, WatchProgress record) { + final label = AppLocalizations.of(context)!; + Episode? episode; + for (final item in drama.episodes) { + if (item.id == record.episodeId) { + episode = item; + break; + } + } + final head = episode == null + ? '' + : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · '; + return '$head${_duration(record.position)} / ${_duration(record.duration)}'; + } + + Widget _quickActions(AppLocalizations l10n, CategoryController categories) { + return Row( + children: [ + Expanded( + child: _QuickAction( + key: const Key('discover-quick-filter'), + icon: Icons.tune_rounded, + label: l10n.filter, + color: AppColors.secondary, + onTap: logic.openSearch, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-charts'), + icon: Icons.local_fire_department_rounded, + label: l10n.topCharts, + color: AppColors.blue, + onTap: () => categories.selectCode(CategoryController.hotCode), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-new'), + icon: Icons.auto_awesome_rounded, + label: l10n.newest, + color: AppColors.gold, + onTap: () => categories.selectCode(CategoryController.newCode), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickAction( + key: const Key('discover-quick-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + color: AppColors.success, + onTap: () => logic.act( + context, + DiscoverPageAction.watchlist, + embedded: widget.embedded, + onAction: widget.onAction, + ), + ), + ), + ], + ); + } + Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, + icon: Icons.home_outlined, + selectedIcon: Icons.home_rounded, label: l10n.homeTab, onTap: () => logic.act( context, @@ -329,8 +637,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, + icon: Icons.person_outline_rounded, + selectedIcon: Icons.person_rounded, label: l10n.myTab, onTap: () => logic.act( context, @@ -343,72 +651,61 @@ class _DiscoverPageState extends State { ); } -const _coin = AppColors.gold; - -class _BrandMark extends StatelessWidget { - const _BrandMark(); - - @override - Widget build(BuildContext context) { - return Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: Colors.white, - size: 20, - ), - ); - } -} - -class _HeaderPill extends StatelessWidget { - const _HeaderPill({ +class _QuickAction extends StatelessWidget { + const _QuickAction({ + super.key, required this.icon, required this.label, + required this.color, required this.onTap, }); final IconData icon; final String label; - final VoidCallback? onTap; + final Color color; + final VoidCallback onTap; @override Widget build(BuildContext context) { return Material( - color: AppColors.raised, - shape: StadiumBorder( - side: BorderSide(color: AppColors.gold.withValues(alpha: 0.45)), - ), + color: AppColors.card, + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + clipBehavior: Clip.antiAlias, child: InkWell( - customBorder: const StadiumBorder(), onTap: onTap, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: AppDimens.tapTarget), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 15, color: _coin), - const SizedBox(width: 4), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, - height: 1.1, - ), + child: Container( + height: 76, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), ), - ], - ), + child: Icon(icon, color: color, size: 17), + ), + const SizedBox(height: 5), + Text( + label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 9.5, + fontWeight: FontWeight.w500, + height: 1.05, + ), + ), + ], ), ), ), @@ -416,28 +713,8 @@ class _HeaderPill extends StatelessWidget { } } -class _LoginButton extends StatelessWidget { - const _LoginButton({required this.label, required this.onTap}); - - final String label; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return TextButton( - onPressed: onTap, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppColors.blue, - padding: const EdgeInsets.symmetric(horizontal: 14), - minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget), - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontSize: AppDimens.body, - fontWeight: FontWeight.w600, - ), - ), - child: Text(label, maxLines: 1), - ); - } +String _duration(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; } diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 3ecf9a0..015e269 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -6,11 +6,15 @@ class _HomeDramaGrid extends StatelessWidget { const _HomeDramaGrid({ required this.l10n, required this.categories, + required this.dramas, + required this.searching, required this.onOpen, }); final AppLocalizations l10n; final CategoryController categories; + final List dramas; + final bool searching; final ValueChanged onOpen; @override @@ -34,16 +38,21 @@ class _HomeDramaGrid extends StatelessWidget { ); } - final dramas = categories.visibleDramas; if (dramas.isEmpty) { final missingCategory = categories.selectedSectionMissing; return _fill( EmptyState( key: const Key('home-all-dramas-empty'), - icon: missingCategory + icon: searching + ? Icons.search_off_rounded + : missingCategory ? Icons.grid_view_rounded - : Icons.search_off_rounded, - title: missingCategory ? l10n.noCategoryDramas : l10n.noSearchResults, + : Icons.movie_filter_outlined, + title: searching + ? l10n.noSearchResults + : missingCategory + ? l10n.noCategoryDramas + : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); @@ -57,24 +66,24 @@ class _HomeDramaGrid extends StatelessWidget { ); } - Widget _dramaGrid(List dramas) => SliverPadding( + Widget _dramaGrid(List items) => SliverPadding( key: const Key('home-all-dramas-section'), padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), sliver: SliverGrid( key: const Key('home-all-dramas-grid'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, + crossAxisCount: 2, mainAxisSpacing: 14, - crossAxisSpacing: 10, - childAspectRatio: 2 / 3, + crossAxisSpacing: 12, + childAspectRatio: 0.55, ), delegate: SliverChildBuilderDelegate( (context, index) => _HomeDramaCard( l10n: l10n, - drama: dramas[index], - onTap: () => onOpen(dramas[index]), + drama: items[index], + onTap: () => onOpen(items[index]), ), - childCount: dramas.length, + childCount: items.length, ), ), ); @@ -102,95 +111,141 @@ class _HomeDramaCard extends StatelessWidget { key: Key('home-drama-card-${drama.id}'), onTap: onTap, borderRadius: BorderRadius.circular(AppDimens.cardRadius), - child: AspectRatio( - key: Key('home-drama-cover-${drama.id}'), - aspectRatio: 2 / 3, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0xE6100C0A)], - stops: [0.42, 1], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + key: Key('home-drama-cover-${drama.id}'), + aspectRatio: 3 / 4.2, + child: ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.cardRadius), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, ), - ), - ), - if (drama.isHot) - Positioned( - top: 8, - left: 8, - child: DecoratedBox( + const DecoratedBox( decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x33000000), + Color(0x00000000), + Color(0xB3000000), + ], + stops: [0, 0.42, 1], ), - child: Text( - l10n.hot, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - height: 1.1, + ), + ), + Positioned( + top: 8, + left: 8, + child: Wrap( + spacing: 4, + runSpacing: 4, + children: [ + if (drama.isHot) _badge(l10n.hot, AppColors.blue), + if (drama.freeEpisodes > 0) + _badge(l10n.free, AppColors.success), + ], + ), + ), + if (drama.viewCount > 0) + Positioned( + right: 8, + bottom: 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(7), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: Text( + _compactCount(drama.viewCount), + key: Key('home-drama-views-${drama.id}'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), ), ), ), - ), - ), - Positioned( - left: 10, - right: 10, - bottom: 10, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - drama.title, - key: Key('home-drama-title-${drama.id}'), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.body, - fontWeight: FontWeight.w800, - height: 1.15, - ), - ), - const SizedBox(height: 4), - Text( - genre.isEmpty ? l10n.videoPending : genre, - key: Key('home-drama-genre-${drama.id}'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w400, - height: 1.2, - ), - ), - ], - ), + ], ), - ], + ), + ), + const SizedBox(height: 8), + Text( + drama.title, + key: Key('home-drama-title-${drama.id}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), + const SizedBox(height: 3), + Text( + genre.isEmpty ? l10n.videoPending : genre, + key: Key('home-drama-genre-${drama.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 11, + fontWeight: FontWeight.w400, + height: 1.2, + ), + ), + ], + ), + ); + } + + Widget _badge(String label, Color color) { + return DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + child: Text( + label, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + height: 1.1, ), ), ), ); } } + +String _compactCount(int value) { + if (value >= 1000000) { + final scaled = value / 1000000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; + } + if (value >= 1000) { + final scaled = value / 1000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; + } + return '$value'; +} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index 4529800..d4a6e49 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -15,7 +15,7 @@ class HomeLogic extends ChangeNotifier { final scaffoldKey = GlobalKey(); Drama currentDrama; - int tab = 1; + int tab = 0; int searchRequest = 0; int sectionRequest = 0; int resumeRequest = 0; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 32297ea..069046b 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -244,35 +244,30 @@ class _HomeTabsState extends State<_HomeTabs> { barKey: const Key('home-tabs-bar'), items: [ AppBottomNavItem( - tabKey: const Key('tab-discover'), + tabKey: const Key('tab-home'), icon: Icons.home_outlined, selectedIcon: Icons.home_rounded, label: l10n.homeTab, - selected: logic.tab == 1, - onTap: () => _selectTab(context, 1), - ), - AppBottomNavItem( - tabKey: const Key('tab-home'), - icon: Icons.movie_outlined, - selectedIcon: Icons.movie_rounded, - label: l10n.shortsTab, selected: logic.tab == 0, + indicatorKey: const Key('tab-home-indicator'), onTap: () => _selectTab(context, 0), ), AppBottomNavItem( - tabKey: const Key('tab-my-list'), - icon: Icons.bookmark_border_rounded, - selectedIcon: Icons.bookmark_rounded, - label: l10n.myListTab, - selected: logic.tab == 2 && !logic.profile, - onTap: () => _selectTab(context, 2), + tabKey: const Key('tab-discover'), + icon: Icons.explore_outlined, + selectedIcon: Icons.explore_rounded, + label: l10n.discoverTab, + selected: logic.tab == 1, + indicatorKey: const Key('tab-discover-indicator'), + onTap: () => _selectTab(context, 1), ), AppBottomNavItem( tabKey: const Key('tab-my'), icon: Icons.person_outline_rounded, selectedIcon: Icons.person_rounded, label: l10n.myTab, - selected: logic.tab == 2 && logic.profile, + selected: logic.tab == 2, + indicatorKey: const Key('tab-my-indicator'), onTap: () => _selectTab(context, 2, myProfile: true), ), ], @@ -282,9 +277,7 @@ class _HomeTabsState extends State<_HomeTabs> { } void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { - final same = tab == 2 - ? logic.tab == 2 && logic.profile == myProfile - : logic.tab == tab; + final same = logic.tab == tab; if (!same) { unawaited(context.read().selectionClick()); } diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 6acf5ac..8091ab5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -115,7 +115,7 @@ class _MyPageState extends State { bottom: !widget.embedded, child: Column( children: [ - if (!widget.profile) _topBar(l10n), + _topBar(l10n), Expanded( child: FutureBuilder( future: logic.position, @@ -132,11 +132,11 @@ class _MyPageState extends State { return ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), children: [ - if (widget.profile) - ..._accountPage(l10n) - else ...[ - _library(l10n, position, total, fraction), - ], + if (widget.profile) ..._accountPage(l10n), + if (widget.profile) const SizedBox(height: 24), + _library(l10n, position, total, fraction), + if (!widget.profile) const SizedBox(height: 24), + if (!widget.profile) ..._accountPage(l10n), const SizedBox(height: 24), Center( child: Text( @@ -174,7 +174,7 @@ class _MyPageState extends State { const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), const SizedBox(width: 7), Text( - l10n.myListTab, + widget.profile ? l10n.myTab : l10n.myListTab, style: const TextStyle( fontSize: AppDimens.pageTitle, fontWeight: FontWeight.w800, @@ -548,6 +548,7 @@ class _MyPageState extends State { selected: logic.section == index, button: true, child: InkWell( + key: Key('mine-library-tab-$index'), onTap: () { logic.selectSection(index); // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 94f744a..a195c69 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/home_feed_overlay.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -334,6 +335,57 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? (position) => logic.seekTo(context, videoPort!, position) : null, ) + else if (!logic.screenCleared && widget.homeMode) + HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + controller: ready ? videoPort!.controller : null, + liked: interactions.isLiked(drama.id), + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => logic.setScreenCleared(true), + onSearch: widget.onSearch ?? () => logic.showDiscover(context), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, + onSeek: ready + ? (position) => logic.seekTo(context, videoPort!, position) + : null, + ) else if (!logic.screenCleared) _PlaybackOverlay( l10n: l10n, diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart new file mode 100644 index 0000000..e631877 --- /dev/null +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -0,0 +1,670 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/glass_panel.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; +import 'seek_progress_bar.dart'; + +/// Immersive home layer from `screens/01-home.html`. +/// +/// Playback, ticket, progress, favorite, like, comment, and share callbacks are +/// all owned by [PlayerPage]; this widget only paints the design overlay. +class HomeFeedOverlay extends StatelessWidget { + const HomeFeedOverlay({ + super.key, + required this.l10n, + required this.drama, + required this.episode, + required this.controller, + required this.liked, + required this.saved, + required this.onLike, + required this.onSave, + required this.onComments, + required this.onShare, + required this.onEpisodes, + required this.onDownload, + required this.onClear, + required this.onSearch, + required this.onScrubStart, + required this.onSeek, + this.onPip, + }); + + final AppLocalizations l10n; + final Drama drama; + final Episode episode; + final VideoPlayerController? controller; + final bool liked; + final bool saved; + final VoidCallback onLike; + final VoidCallback onSave; + final VoidCallback onComments; + final VoidCallback onShare; + final VoidCallback onEpisodes; + final VoidCallback onDownload; + final VoidCallback onClear; + final VoidCallback onSearch; + final VoidCallback? onPip; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).height < 620; + return SafeArea( + bottom: false, + child: Column( + children: [ + _header(context), + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), + child: _rail(compact), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: _infoCard(context, compact), + ), + ], + ), + ); + } + + Widget _header(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.blue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 7), + Flexible( + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + height: 1.1, + shadows: [ + Shadow(blurRadius: 10, color: Colors.black87), + ], + ), + ), + ), + if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], + ], + ), + const SizedBox(height: 3), + Text( + '${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.64), + fontSize: 10.5, + fontWeight: FontWeight.w500, + shadows: const [ + Shadow(blurRadius: 8, color: Colors.black87), + ], + ), + ), + ], + ), + ), + const SizedBox(width: 6), + _GlassIconButton( + tooltip: l10n.search, + icon: Icons.search_rounded, + onPressed: onSearch, + ), + const SizedBox(width: 4), + _GlassIconButton( + tooltip: l10n.clearScreen, + icon: Icons.fit_screen_outlined, + onPressed: onClear, + ), + const SizedBox(width: 4), + _GlassIconButton( + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Widget _hotBadge() { + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + child: Text( + l10n.hot, + style: const TextStyle( + color: AppColors.blue, + fontSize: 8, + fontWeight: FontWeight.w800, + letterSpacing: 0.4, + height: 1.1, + ), + ), + ), + ); + } + + Widget _rail(bool compact) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + label: saved ? l10n.followingTab : l10n.followSeries, + child: InkWell( + key: const Key('home-feed-follow'), + onTap: onSave, + customBorder: const CircleBorder(), + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: compact ? 40 : 44, + height: compact ? 40 : 44, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.blue, width: 2), + color: AppColors.raised, + ), + child: ClipOval( + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + ), + ), + Positioned( + bottom: -4, + left: 14, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: saved ? AppColors.success : AppColors.blue, + shape: BoxShape.circle, + border: Border.all(color: AppColors.ink, width: 2), + ), + child: Icon( + saved ? Icons.check_rounded : Icons.add_rounded, + color: AppColors.onPrimary, + size: 11, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + _RailAction( + compact: compact, + key: const Key('rail-like'), + icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + label: l10n.like, + selected: liked, + onTap: onLike, + ), + _RailAction( + compact: compact, + key: const Key('rail-save'), + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + label: l10n.save, + selected: saved, + onTap: onSave, + ), + _RailAction( + compact: compact, + key: const Key('rail-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + _RailAction( + compact: compact, + key: const Key('rail-share'), + icon: Icons.share_outlined, + label: l10n.share, + onTap: onShare, + ), + ], + ); + } + + Widget _infoCard(BuildContext context, bool compact) { + final genres = [ + if (drama.genre.isNotEmpty) drama.genre, + if (drama.category.isNotEmpty) drama.category, + ].take(2).toList(); + return GlassPanel( + key: const Key('home-feed-info-card'), + radius: 26, + blur: 24, + padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x260B0C10), Color(0x590B0C10)], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Wrap( + spacing: 5, + runSpacing: 4, + children: [ + for (var i = 0; i < genres.length; i++) + _genreChip(genres[i], i.isEven), + ], + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 5, + height: 5, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.success, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 4), + Text( + '${l10n.episodeLabel(episode.number)} / ' + '${drama.episodeCount}', + key: const Key('home-feed-episode'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + drama.synopsis, + maxLines: compact ? 1 : 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 12, + height: 1.35, + ), + ), + const SizedBox(height: 6), + SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + showTimes: false, + compact: true, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + _timeRow(), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: SizedBox( + height: 40, + child: FilledButton.icon( + key: const Key('home-feed-follow-series'), + onPressed: onSave, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + icon: Icon( + saved ? Icons.check_rounded : Icons.add_rounded, + size: 16, + ), + label: Text( + saved ? l10n.followingTab : l10n.followSeries, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 8), + _GlassAction( + tooltip: l10n.episodes, + icon: Icons.grid_view_rounded, + label: l10n.episodes, + onPressed: onEpisodes, + ), + ], + ), + ], + ), + ); + } + + Widget _genreChip(String label, bool secondary) { + final color = secondary ? AppColors.secondary : AppColors.blue; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ); + } + + Widget _timeRow() { + final source = controller; + if (source == null) { + return const _Times(position: Duration.zero, duration: Duration.zero); + } + return ValueListenableBuilder( + valueListenable: source, + builder: (context, value, _) => + _Times(position: value.position, duration: value.duration), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('home-feed-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, + ), + if (onPip != null) + ListTile( + key: const Key('home-feed-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, + ), + ListTile( + leading: const Icon( + Icons.fit_screen_outlined, + color: AppColors.blueSoft, + ), + title: Text(l10n.clearScreen), + onTap: () { + Navigator.pop(sheetContext); + onClear(); + }, + ), + ], + ), + ), + ); + } +} + +class _Times extends StatelessWidget { + const _Times({required this.position, required this.duration}); + + final Duration position; + final Duration duration; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _format(position), + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + Text( + _format(duration), + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + ], + ), + ); + } +} + +class _GlassIconButton extends StatelessWidget { + const _GlassIconButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 34, + height: 34, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.28), + foregroundColor: AppColors.onSurface, + side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _GlassAction extends StatelessWidget { + const _GlassAction({ + required this.tooltip, + required this.icon, + required this.label, + required this.onPressed, + }); + + final String tooltip; + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(999), + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white.withValues(alpha: 0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.secondary, size: 15), + const SizedBox(width: 5), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RailAction extends StatelessWidget { + const _RailAction({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.selected = false, + this.compact = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool selected; + final bool compact; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(bottom: compact ? 2 : 3), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GlassPanel( + radius: 999, + blur: 16, + child: SizedBox( + width: compact ? 40 : 38, + height: compact ? 40 : 38, + child: IconButton( + tooltip: label, + onPressed: onTap, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: 19, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + ), + ), + ), + if (!compact) ...[ + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], + ], + ), + ); + } +} + +String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 1a1cf1a..cc2855d 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -26,6 +26,7 @@ class SeekProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.showTimes = true, + this.compact = false, }); final ValueListenable? valueListenable; @@ -33,6 +34,7 @@ class SeekProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; final bool showTimes; + final bool compact; @override State createState() => _SeekProgressBarState(); @@ -116,17 +118,27 @@ class _SeekProgressBarState extends State { ), ), SizedBox( - height: scrubbing ? 48 : 44, + height: widget.compact + ? (scrubbing ? 32 : 20) + : (scrubbing ? 48 : 44), child: SliderTheme( data: SliderTheme.of(context).copyWith( - trackHeight: scrubbing ? 4 : 3, - activeTrackColor: AppColors.gold, - inactiveTrackColor: Colors.white30, - thumbColor: Colors.white, + trackHeight: widget.compact + ? (scrubbing ? 3 : 1.5) + : (scrubbing ? 4 : 3), + activeTrackColor: AppColors.blue, + inactiveTrackColor: widget.compact + ? Colors.white.withValues(alpha: 0.18) + : Colors.white30, + thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( - enabledThumbRadius: scrubbing ? 8 : 6, + enabledThumbRadius: widget.compact + ? (scrubbing ? 6 : 4) + : (scrubbing ? 8 : 6), + ), + overlayShape: RoundSliderOverlayShape( + overlayRadius: widget.compact ? 12 : 18, ), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), ), child: Semantics( label: widget.semanticsLabel, diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index a91ba2d..b19c94d 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -7,6 +7,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/content_category.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -25,7 +26,7 @@ void main() { }); testWidgets( - 'should_show_all_dramas_in_three_column_sliver_grid_without_continue_watching', + 'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -44,27 +45,15 @@ void main() { tester.getCenter(find.text('All')).dx, lessThan(tester.getCenter(find.text('Hot')).dx), ); - expect( - tester - .getTopLeft( - find.byKey(const Key('home-drama-card-the-signal-demo')), - ) - .dy - - tester - .getBottomLeft(find.byKey(const Key('home-category-chips'))) - .dy, - closeTo(14, 0.1), - ); - final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 3); - expect(delegate.crossAxisSpacing, 10); + expect(delegate.crossAxisCount, 2); + expect(delegate.crossAxisSpacing, 12); expect(delegate.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(2 / 3, 0.001)); + expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); expect( tester .widget( @@ -80,7 +69,7 @@ void main() { final cover = tester.widget( find.byKey(const Key('home-drama-cover-city-lights-demo')), ); - expect(cover.aspectRatio, closeTo(2 / 3, 0.001)); + expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001)); expect( tester .widget( @@ -148,15 +137,7 @@ void main() { expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('No matching dramas.'), findsOneWidget); - final content = tester.getCenter( - find.byKey(const Key('empty-state-content')), - ); - final chipsBottom = tester - .getBottomLeft(find.byKey(const Key('home-category-chips'))) - .dy; - final feed = tester.getRect(find.byType(CustomScrollView)); - expect(content.dx, closeTo(feed.center.dx, 1)); - expect(content.dy, closeTo((chipsBottom + feed.bottom) / 2, 36)); + expect(find.byKey(const Key('empty-state-content')), findsOneWidget); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); @@ -184,7 +165,7 @@ void main() { expect(find.byKey(const Key('home-all-dramas-grid')), findsNothing); }); - testWidgets('should_fit_three_column_grid_on_compact_screen', (tester) async { + testWidgets('should_fit_two_column_grid_on_compact_screen', (tester) async { tester.view.physicalSize = const Size(320, 568); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); @@ -204,11 +185,87 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('should_filter_trending_grid_when_search_text_changes', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), ''); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsOneWidget, + ); + }); + + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository(), + history: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(seconds: 42), + duration: Duration(minutes: 2), + ), + ], + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('discover-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('discover-continue-play')), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-charts')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-new')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-watchlist')), findsOneWidget); + }); } Future _pumpApp( WidgetTester tester, { required CategoryRepository categoryRepository, + WatchHistoryRepository? history, }) async { final catalog = FakeCatalogRepository(); await tester.pumpWidget( @@ -216,7 +273,7 @@ Future _pumpApp( playerFactory: createWidgetPlayer, catalogRepository: catalog, categoryRepository: categoryRepository, - watchHistoryRepository: InMemoryWatchHistoryRepository(), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), diff --git a/test/discover/home_banner_source_test.dart b/test/discover/home_banner_source_test.dart index 1e4edeb..0c5d90c 100644 --- a/test/discover/home_banner_source_test.dart +++ b/test/discover/home_banner_source_test.dart @@ -21,7 +21,7 @@ import '../support/widget_player.dart'; void main() { testWidgets( - 'should_render_home_feed_banners_when_discover_banners_are_empty', + 'should_not_mount_carousel_on_discover_even_when_home_banners_exist', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); @@ -43,26 +43,11 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.byType(CoverCarousel), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget); - expect( - find.descendant( - of: find.byType(CoverCarousel), - matching: find.text('The Signal'), - ), - findsOneWidget, - ); - - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - expect( - find.descendant( - of: find.byType(CoverCarousel), - matching: find.text('City Lights & Velvet Nights'), - ), - findsOneWidget, - ); + expect(find.byType(CoverCarousel), findsNothing); + expect(find.byKey(const Key('discover-search')), findsOneWidget); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); await tester.pumpWidget(const SizedBox.shrink()); }, ); diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 7ba720b..7daa918 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -18,17 +18,20 @@ void main() { final dramas = [_drama('signal'), _drama('city')]; - test('should_make_first_catalog_entry_the_current_drama_when_catalog_loads', () { - final logic = HomeLogic(dramas); + test( + 'should_make_first_catalog_entry_the_current_drama_when_catalog_loads', + () { + final logic = HomeLogic(dramas); - expect(logic.currentDrama.id, 'signal'); - expect(logic.tab, 1); - expect(logic.mySection, 0); - expect(logic.drawerOpen, isFalse); - expect(logic.profile, isFalse); - expect(logic.videoImmersive, isFalse); - logic.dispose(); - }); + expect(logic.currentDrama.id, 'signal'); + expect(logic.tab, 0); + expect(logic.mySection, 0); + expect(logic.drawerOpen, isFalse); + expect(logic.profile, isFalse); + expect(logic.videoImmersive, isFalse); + logic.dispose(); + }, + ); test('should_not_build_shell_when_catalog_is_empty', () { expect(() => HomeLogic(const []), throwsStateError); @@ -82,33 +85,38 @@ void main() { logic.dispose(); }); - test('should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', () { - final logic = HomeLogic(dramas); + test( + 'should_differ_on_profile_flag_when_open_profile_and_open_library_are_used', + () { + final logic = HomeLogic(dramas); - logic.openProfile(); - expect(logic.profile, isTrue); - expect(logic.tab, 2); - expect(logic.mySection, 0); - expect(logic.sectionRequest, 1); + logic.openProfile(); + expect(logic.profile, isTrue); + expect(logic.tab, 2); + expect(logic.mySection, 0); + expect(logic.sectionRequest, 1); - logic.openLibrary(section: 1); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 2); + logic.openLibrary(section: 1); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 2); - logic.openLibrary(); - expect(logic.profile, isFalse); - expect(logic.mySection, 1); - expect(logic.sectionRequest, 3); - logic.dispose(); - }); + logic.openLibrary(); + expect(logic.profile, isFalse); + expect(logic.mySection, 1); + expect(logic.sectionRequest, 3); + logic.dispose(); + }, + ); test('should_return_home_and_open_drawer_when_system_back_is_pressed', () { final logic = HomeLogic(dramas); + // Home is the launch tab; the first back press lands on Discover. + logic.onSystemBack(); + expect(logic.tab, 1); logic.onSystemBack(); expect(logic.drawerOpen, isTrue); - expect(logic.tab, 1); expect(logic.backRequest, 0); logic.onDrawerChanged(false); @@ -190,7 +198,9 @@ void main() { expect(notifications, 0); }); - testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', (tester) async { + testWidgets('should_map_onto_root_tabs_when_discover_actions_fire', ( + tester, + ) async { final logic = HomeLogic(dramas); late BuildContext context; await tester.pumpWidget( diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 156d863..79dca09 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -18,9 +18,8 @@ import '../support/widget_player.dart'; const _keys = [ Key('home-tabs-bar'), - Key('tab-discover'), Key('tab-home'), - Key('tab-my-list'), + Key('tab-discover'), Key('tab-my'), ]; @@ -34,7 +33,7 @@ void main() { }); }); - testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', ( + testWidgets('should_theme_three_tabs_and_switch_selected_state_when_tapped', ( tester, ) async { final haptics = FakeHaptics(); @@ -52,10 +51,9 @@ void main() { final bar = tester.widget(barFinder); final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; - final context = tester.element(barFinder); - expect(decoration.color, AppColors.bar); - expect(border.top.color, Theme.of(context).colorScheme.outlineVariant); - expect(border.top.color.computeLuminance(), lessThan(0.2)); + expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); + expect(border.top.color, AppColors.border); + expect(border.top.color.a, lessThan(0.2)); expect(border.top.color, isNot(Colors.white)); expect( tester @@ -71,7 +69,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_rounded), + matching: find.byIcon(Icons.home_rounded), ), findsOneWidget, ); @@ -85,14 +83,14 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-discover')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.explore_rounded), ), findsOneWidget, ); expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.movie_outlined), + matching: find.byIcon(Icons.home_outlined), ), findsOneWidget, ); @@ -110,6 +108,32 @@ void main() { } }); + testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { + await _pumpApp(tester); + + expect(find.byKey(const Key('tab-my-list')), findsNothing); + expect(find.byKey(const Key('tab-home')), findsOneWidget); + expect(find.byKey(const Key('tab-discover')), findsOneWidget); + expect(find.byKey(const Key('tab-my')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); + + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + expect(find.text('Viewer'), findsOneWidget); + expect( + find.byKey(const Key('mine-library-tab-0'), skipOffstage: false), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + }); + testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', ( tester, ) async { @@ -119,6 +143,8 @@ void main() { await tester.pumpAndSettle(); haptics.reset(); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); await tester.tap(find.text('App Language')); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 7732b90..f5648ed 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -117,7 +117,12 @@ void main() { expect(find.text('Following'), findsNothing); expect(find.byTooltip('Episodes'), findsWidgets); expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); + expect(find.byTooltip('Picture in picture'), findsNothing); + await tester.tap(find.byTooltip('More')); + await tester.pumpAndSettle(); + expect(find.text('Picture in picture'), findsOneWidget); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); expect(find.byTooltip('Explore series'), findsNothing); }); @@ -155,6 +160,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -217,15 +224,18 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); - expect(find.text('PREFERENCES'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); await tester.tap(find.text('App Language')); await tester.pumpAndSettle(); await tester.tap(find.text('简体中文').last); await tester.pumpAndSettle(); expect(find.text('我的'), findsWidgets); + await tester.drag(find.byType(ListView), const Offset(0, 700)); + await tester.pumpAndSettle(); expect(find.text('我的账户'), findsOneWidget); expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('首页')); + await tester.tap(find.text('找剧')); await tester.pumpAndSettle(); expect(find.text('全部'), findsOneWidget); expect(find.text('热门'), findsOneWidget); @@ -272,7 +282,7 @@ void main() { expect(find.text('Vicky sheng'), findsOneWidget); expect(find.text('Coins'), findsOneWidget); expect(tester.takeException(), isNull); - await tester.tap(find.text('Shorts')); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); }, @@ -311,25 +321,39 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.text('Home')); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.text('All'), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.text('Rewards'), findsOneWidget); - expect(find.text(r'$ 0'), findsOneWidget); - expect(find.text('Urban Romance', skipOffstage: false), findsOneWidget); + expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( - find.text('Action · Thriller', skipOffstage: false), + find.byKey( + const Key('home-drama-genre-city-lights-demo'), + skipOffstage: false, + ), + findsOneWidget, + ); + expect( + find.byKey( + const Key('home-drama-genre-vengeance-demo'), + skipOffstage: false, + ), findsOneWidget, ); expect(find.byTooltip('Search'), findsNothing); expect(tester.takeException(), isNull); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.byKey(const Key('mine-library-tab-1')), + 260, + ); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); - await tester.tap(find.text('Shorts')); + await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); }, @@ -345,7 +369,7 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); - await tester.tap(find.text('Home')); + await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byTooltip('Search'), findsNothing); @@ -356,14 +380,16 @@ void main() { isFalse, ); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - expect(find.text('History'), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, -700)); + await tester.pumpAndSettle(); + expect(find.text('History'), findsWidgets); expect( - Navigator.of(tester.element(find.text('History'))).canPop(), + Navigator.of(tester.element(find.text('History').first)).canPop(), isFalse, ); - await tester.tap(find.text('Watchlist')); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); @@ -377,7 +403,11 @@ void main() { ).canPop(), isFalse, ); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -400)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-home'))); @@ -545,6 +575,8 @@ void main() { await tester.pumpWidget(appUnderTest(interactionRepository: interactions)); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('drawer-open-series'))); @@ -570,6 +602,8 @@ void main() { await tester.pumpWidget(appUnderTest()); await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -608,9 +642,13 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-my-list'))); + await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Watchlist')); + await tester.scrollUntilVisible( + find.byKey(const Key('mine-library-tab-1')), + 300, + ); + await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); expect(find.text('City Lights & Velvet Nights'), findsWidgets); From ec163f905dd9361f9f83bb4a8c4609ab18646421 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:16:36 +0800 Subject: [PATCH 03/30] =?UTF-8?q?feat(detail):=20=E9=87=8D=E5=81=9A?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E8=A7=86=E8=A7=89=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 7 + lib/l10n/app_id.arb | 7 + lib/l10n/app_localizations.dart | 42 + lib/l10n/app_localizations_en.dart | 21 + lib/l10n/app_localizations_id.dart | 21 + lib/l10n/app_localizations_zh.dart | 21 + lib/l10n/app_zh.arb | 7 + lib/pages/drama_detail/drama_detail_view.dart | 1115 +++++++++++++---- test/drama_detail/drama_detail_view_test.dart | 151 +++ 9 files changed, 1153 insertions(+), 239 deletions(-) create mode 100644 test/drama_detail/drama_detail_view_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 40fa98a..b6db44d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "VERTICAL MINI-SERIES", "followSeries": "Follow Series", "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", + "seasonOneOriginalRelease": "Season 1 • Original Release", + "startWatching": "Start Watching", + "autoNextOn": "Auto-Next On", + "viewAll": "View all", + "showLess": "Less", + "views": "views", + "enterFullscreen": "Fullscreen", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 459faeb..e839cd1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "SERIAL MINI VERTIKAL", "followSeries": "Ikuti Series", "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", + "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", + "startWatching": "Mulai Menonton", + "autoNextOn": "Lanjut Otomatis", + "viewAll": "Lihat semua", + "showLess": "Lebih sedikit", + "views": "tayangan", + "enterFullscreen": "Layar penuh", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 3dee2a6..5ae6fac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -262,6 +262,48 @@ abstract class AppLocalizations { /// **'Season 1 • Vertical Cinema'** String get seasonOneVerticalCinema; + /// No description provided for @seasonOneOriginalRelease. + /// + /// In en, this message translates to: + /// **'Season 1 • Original Release'** + String get seasonOneOriginalRelease; + + /// No description provided for @startWatching. + /// + /// In en, this message translates to: + /// **'Start Watching'** + String get startWatching; + + /// No description provided for @autoNextOn. + /// + /// In en, this message translates to: + /// **'Auto-Next On'** + String get autoNextOn; + + /// No description provided for @viewAll. + /// + /// In en, this message translates to: + /// **'View all'** + String get viewAll; + + /// No description provided for @showLess. + /// + /// In en, this message translates to: + /// **'Less'** + String get showLess; + + /// No description provided for @views. + /// + /// In en, this message translates to: + /// **'views'** + String get views; + + /// No description provided for @enterFullscreen. + /// + /// In en, this message translates to: + /// **'Fullscreen'** + String get enterFullscreen; + /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e40f0bd..f93e1e7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -90,6 +90,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Season 1 • Vertical Cinema'; + @override + String get seasonOneOriginalRelease => 'Season 1 • Original Release'; + + @override + String get startWatching => 'Start Watching'; + + @override + String get autoNextOn => 'Auto-Next On'; + + @override + String get viewAll => 'View all'; + + @override + String get showLess => 'Less'; + + @override + String get views => 'views'; + + @override + String get enterFullscreen => 'Fullscreen'; + @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 66e2973..8cfe7fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -90,6 +90,27 @@ class AppLocalizationsId extends AppLocalizations { @override String get seasonOneVerticalCinema => 'Musim 1 • Sinema Vertikal'; + @override + String get seasonOneOriginalRelease => 'Musim 1 • Rilis Orisinal'; + + @override + String get startWatching => 'Mulai Menonton'; + + @override + String get autoNextOn => 'Lanjut Otomatis'; + + @override + String get viewAll => 'Lihat semua'; + + @override + String get showLess => 'Lebih sedikit'; + + @override + String get views => 'tayangan'; + + @override + String get enterFullscreen => 'Layar penuh'; + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e3cac4a..ef96d79 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -89,6 +89,27 @@ class AppLocalizationsZh extends AppLocalizations { @override String get seasonOneVerticalCinema => '第 1 季 · 竖屏影院'; + @override + String get seasonOneOriginalRelease => '第 1 季 · 原创上线'; + + @override + String get startWatching => '开始观看'; + + @override + String get autoNextOn => '自动连播'; + + @override + String get viewAll => '查看全部'; + + @override + String get showLess => '收起'; + + @override + String get views => '次播放'; + + @override + String get enterFullscreen => '全屏'; + @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ae47bc8..5967360 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -27,6 +27,13 @@ "verticalMiniSeries": "竖屏短剧", "followSeries": "追剧", "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", + "seasonOneOriginalRelease": "第 1 季 · 原创上线", + "startWatching": "开始观看", + "autoNextOn": "自动连播", + "viewAll": "查看全部", + "showLess": "收起", + "views": "次播放", + "enterFullscreen": "全屏", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index e3b25d2..f358240 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -6,30 +6,33 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/repositories/playback_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; +import '../../core/data/services/platform_share_service.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/remote_image.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/glass_panel.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; -import '../../core/data/repositories/watch_history_repository.dart'; -import '../../core/data/repositories/playback_repository.dart'; const String _reportMenuValue = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); + final Drama drama; @override @@ -38,6 +41,7 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; + bool _synopsisExpanded = false; @override void initState() { @@ -78,211 +82,261 @@ class _DramaDetailPageState extends State { Widget _page(BuildContext context) { final l10n = AppLocalizations.of(context)!; - if (logic.loading) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: const Center(child: CircularProgressIndicator()), - ); - } - if (logic.failed) { - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: AppColors.onPrimary, - title: Text(localizedDramaTitle(l10n, widget.drama)), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - errorText(l10n, logic.failureMessage, l10n.detailError), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), - FilledButton(onPressed: _load, child: Text(l10n.retry)), - ], - ), - ), - ), - ); - } + if (logic.loading) return _loading(l10n); + if (logic.failed) return _failed(l10n); final drama = logic.drama; - if (drama.episodes.isEmpty) { - // 空态:详情拿到了但一集都没有(还没上传/审核中),不能白屏 - return Scaffold( - backgroundColor: AppColors.ink, - appBar: AppBar( - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - title: Text(localizedDramaTitle(l10n, drama)), - ), - body: EmptyState( - key: const Key('drama-detail-empty'), - icon: Icons.video_library_outlined, - title: l10n.noEpisodesYet, - subtitle: l10n.emptyHint, - actionLabel: l10n.retry, - onAction: _load, - ), - ); - } + if (drama.episodes.isEmpty) return _empty(l10n, drama); + return Scaffold( backgroundColor: AppColors.ink, body: CustomScrollView( + key: const Key('detail-scroll'), slivers: [ - SliverAppBar( - expandedHeight: 300, - pinned: true, - backgroundColor: AppColors.ink, - foregroundColor: Colors.white, - actions: [ - _reportAction(context, l10n), - _downloadAction(context, l10n), - _favoriteAction(context, l10n), - ], - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - RemoteImage(drama.posterUrl), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.center, - colors: [Color(0x99000000), Color(0x00000000)], - ), - ), + SliverToBoxAdapter(child: _hero(context, l10n, drama)), + SliverToBoxAdapter(child: _metadata(l10n, drama)), + SliverToBoxAdapter(child: _synopsis(l10n, drama)), + SliverToBoxAdapter(child: _primaryActions(context, l10n)), + SliverToBoxAdapter(child: _episodeHeader(l10n, drama)), + SliverToBoxAdapter(child: _episodeChips(drama)), + _episodeGrid(context, l10n, drama), + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), + ); + } + + Widget _loading(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: const Center(child: CircularProgressIndicator()), + ); + + Widget _failed(AppLocalizations l10n) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, widget.drama)), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + errorText(l10n, logic.failureMessage, l10n.detailError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted), + ), + const SizedBox(height: 16), + FilledButton(onPressed: _load, child: Text(l10n.retry)), + ], + ), + ), + ), + ); + + Widget _empty(AppLocalizations l10n, Drama drama) => Scaffold( + backgroundColor: AppColors.ink, + appBar: AppBar( + backgroundColor: AppColors.ink, + foregroundColor: AppColors.onPrimary, + title: Text(localizedDramaTitle(l10n, drama)), + ), + body: EmptyState( + key: const Key('drama-detail-empty'), + icon: Icons.video_library_outlined, + title: l10n.noEpisodesYet, + subtitle: l10n.emptyHint, + actionLabel: l10n.retry, + onAction: _load, + ), + ); + + Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { + return Stack( + children: [ + AspectRatio( + key: const Key('detail-hero'), + aspectRatio: 16 / 9, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x260B0C10), + Color(0x000B0C10), + AppColors.ink, + ], + stops: [0, 0.5, 1], ), - ], + ), + ), + ], + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + left: 16, + right: 16, + child: Row( + children: [ + _GlassCircleButton( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), + ), + const Spacer(), + _GlassCircleButton( + key: const Key('detail-share'), + tooltip: l10n.share, + icon: Icons.share_outlined, + onPressed: () => unawaited(_shareDrama(l10n, drama)), + ), + const SizedBox(width: 8), + _favoriteButton( + context, + l10n, + key: const Key('detail-favorite-top'), + ), + const SizedBox(width: 8), + _reportAction(context, l10n), + ], + ), + ), + ], + ); + } + + Widget _metadata(AppLocalizations l10n, Drama drama) { + final genres = [ + if (drama.genre.isNotEmpty) drama.genre, + if (drama.category.isNotEmpty) drama.category, + ]; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + key: const Key('detail-episode-count'), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: AppColors.secondary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.3), ), ), - ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Text( - l10n.technicalDemoCount(drama.episodes.length), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - letterSpacing: 1.4, - color: AppColors.blueSoft, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - localizedDramaTitle(l10n, drama), - style: const TextStyle( - fontSize: AppDimens.detailHeadline, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 10), - Text( - localizedDramaSynopsis(l10n, drama), - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: AppColors.muted), - ), - const SizedBox(height: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FilledButton.icon( - onPressed: () => - logic.openPlayer(context, logic.continueIndex ?? 0), - icon: const Icon(Icons.play_arrow_rounded), - label: Text( - logic.continueIndex == null - ? l10n.watchEpisodeOne - : l10n.continueEpisode(logic.continueIndex! + 1), - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 6, + height: 6, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.secondary, + shape: BoxShape.circle, ), - if (_continueFraction != null) ...[ - const SizedBox(height: 8), - _ResumeBar( - fraction: _continueFraction!, - label: l10n.resumeProgress( - _clock(logic.progress!.position), - _clock(logic.progress!.duration), - ), - ), - ], - if (logic.progressError != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Text( - errorText( - l10n, - logic.progressError, - l10n.progressError, - ), - style: const TextStyle( - color: Colors.white60, - fontSize: 12, - ), - ), - ), - TextButton( - onPressed: () => unawaited(logic.loadContinue()), - child: Text(l10n.retry), - ), - ], - ), - ], - ], - ), - const SizedBox(height: 30), - Text( - l10n.episodes, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontSize: AppDimens.sectionTitle, - fontWeight: FontWeight.w700, ), ), - ]), + const SizedBox(width: 5), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.secondary, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], ), ), - SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 40), - sliver: SliverList.builder( - itemCount: drama.episodes.length, - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final fraction = logic.progressFractionFor(episode.id); - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: ListTile( - minTileHeight: 76, - leading: CircleAvatar( - backgroundColor: AppColors.blue.withValues(alpha: 0.18), - child: Text('${episode.number}'), - ), - title: Text(l10n.episodeLabel(episode.number)), - subtitle: fraction == null - ? null - : Padding( - padding: const EdgeInsets.only(top: 6), - child: _ResumeBar(fraction: fraction), - ), - trailing: const Icon(Icons.play_circle_outline_rounded), - onTap: () => logic.openPlayer(context, index), - ), - ); - }, + const SizedBox(height: 10), + Text( + localizedDramaTitle(l10n, drama), + key: const Key('detail-title'), + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + height: 1.15, + letterSpacing: 0, + ), + ), + const SizedBox(height: 5), + Text( + l10n.seasonOneOriginalRelease, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 7, + runSpacing: 7, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (drama.viewCount > 0) + _metaPill( + icon: Icons.local_fire_department_rounded, + label: '${_compactCount(drama.viewCount)} ${l10n.views}', + color: AppColors.blue, + ), + for (final genre in genres) + _metaPill(label: genre, color: AppColors.cream), + ], + ), + ], + ), + ); + } + + Widget _metaPill({ + IconData? icon, + required String label, + required Color color, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w500, ), ), ], @@ -290,42 +344,582 @@ class _DramaDetailPageState extends State { ); } - double? get _continueFraction { - final record = logic.progress; - if (record == null || !record.hasPosition) return null; - if (logic.continueIndex == null) return null; - return record.fraction; - } - - /// 下载当前续看集或第一集;业务判定完全交给服务端。 - Widget _downloadAction(BuildContext context, AppLocalizations l10n) { - final episodes = logic.drama.episodes; - final index = logic.continueIndex ?? 0; - final episode = episodes.isEmpty - ? null - : episodes[index.clamp(0, episodes.length - 1)]; - return IconButton( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: const Icon(Icons.download_outlined), - onPressed: episode == null - ? null - : () => requestEpisodeDownload(context, episode.id), + Widget _synopsis(AppLocalizations l10n, Drama drama) { + final text = localizedDramaSynopsis(l10n, drama); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + child: GlassPanel( + key: const Key('detail-synopsis-card'), + radius: AppDimens.glassSmallRadius, + blur: 12, + padding: const EdgeInsets.fromLTRB(13, 11, 13, 9), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0x1A12141A), Color(0x3312141A)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + key: const Key('detail-synopsis'), + maxLines: _synopsisExpanded ? 100 : 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.muted, + fontSize: 13, + height: 1.45, + ), + ), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const Key('detail-synopsis-toggle'), + onPressed: () => + setState(() => _synopsisExpanded = !_synopsisExpanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + minimumSize: const Size(44, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_synopsisExpanded ? l10n.showLess : l10n.more), + Icon( + _synopsisExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 16, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _primaryActions(BuildContext context, AppLocalizations l10n) { + final continueIndex = logic.continueIndex; + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: SizedBox( + height: 52, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => + logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: const StadiumBorder(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 20), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-favorite'), + tooltip: l10n.favorite, + icon: + context.watch().isSaved(logic.drama.id) + ? Icons.bookmark_rounded + : Icons.bookmark_border_rounded, + selected: context.watch().isSaved( + logic.drama.id, + ), + onPressed: () => _toggleFavorite(l10n), + ), + const SizedBox(width: 10), + _squareAction( + key: const Key('detail-download'), + tooltip: l10n.downloads, + icon: Icons.download_outlined, + onPressed: _downloadCurrent, + ), + ], + ), + if (_continueFraction != null) ...[ + const SizedBox(height: 9), + _ResumeBar( + fraction: _continueFraction!, + label: l10n.resumeProgress( + _clock(logic.progress!.position), + _clock(logic.progress!.duration), + ), + ), + ], + if (logic.progressError != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + errorText(l10n, logic.progressError, l10n.progressError), + style: const TextStyle( + color: AppColors.muted, + fontSize: 12, + ), + ), + ), + TextButton( + onPressed: () => unawaited(logic.loadContinue()), + child: Text(l10n.retry), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _episodeHeader(AppLocalizations l10n, Drama drama) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), + child: Row( + children: [ + Text( + l10n.episodes, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 5), + Text( + '(${drama.episodeCount})', + style: const TextStyle( + color: AppColors.subtle, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Container( + key: const Key('detail-episodes-range'), + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.all, + style: const TextStyle( + color: AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(width: 3), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 15, + color: AppColors.muted, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _episodeChips(Drama drama) { + final selected = logic.continueIndex ?? 0; + return SizedBox( + key: const Key('detail-episode-chips'), + height: 44, + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), + scrollDirection: Axis.horizontal, + itemCount: drama.episodes.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final episode = drama.episodes[index]; + final active = index == selected; + final locked = episode.locked || (!episode.isFree && index > 0); + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(12), + child: Container( + width: 46, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : AppColors.card, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: active + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.35) + : AppColors.border, + ), + ), + child: Text( + '${episode.number}', + style: TextStyle( + color: active + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _episodeGrid( + BuildContext context, + AppLocalizations l10n, + Drama drama, + ) { + return SliverPadding( + key: const Key('detail-episode-grid'), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 108, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => + _episodeCard(context, l10n, drama, drama.episodes[index], index), + childCount: drama.episodes.length, + ), + ), + ); + } + + Widget _episodeCard( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + int index, + ) { + final locked = episode.locked || (!episode.isFree && index > 0); + return Material( + key: Key('detail-episode-card-${episode.number}'), + color: AppColors.card, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => logic.openPlayer(context, index), + child: LayoutBuilder( + builder: (context, constraints) { + final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0); + return Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + SizedBox( + key: Key('detail-episode-thumb-${episode.number}'), + width: thumbWidth, + height: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const DecoratedBox( + decoration: BoxDecoration(color: Color(0x33000000)), + ), + Center( + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: locked + ? Colors.black54 + : AppColors.blue.withValues(alpha: 0.9), + shape: BoxShape.circle, + ), + child: Icon( + locked + ? Icons.lock_rounded + : Icons.play_arrow_rounded, + size: 14, + color: AppColors.onPrimary, + ), + ), + ), + if (episode.durationSec > 0) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.68), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _clock( + Duration(seconds: episode.durationSec), + ), + style: const TextStyle( + color: AppColors.cream, + fontSize: 8, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Wrap( + spacing: 4, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _episodeBadge( + episode.isFree && !locked + ? l10n.free + : l10n.coins, + locked ? AppColors.secondary : AppColors.success, + ), + Text( + l10n.episodeLabel(episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9.5, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + localizedEpisodeTitle(l10n, episode.number), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: locked + ? AppColors.muted + : AppColors.onSurface, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + GestureDetector( + onTap: () => + requestEpisodeDownload(context, episode.id), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.download_outlined, + size: 12, + color: AppColors.subtle, + ), + const SizedBox(width: 3), + Flexible( + child: Text( + l10n.downloads, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 9, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _episodeBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withValues(alpha: 0.28)), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontSize: 8.5, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _squareAction({ + required Key key, + required String tooltip, + required IconData icon, + required VoidCallback onPressed, + bool selected = false, + }) { + return SizedBox( + key: key, + width: 48, + height: 48, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + style: IconButton.styleFrom( + backgroundColor: selected ? AppColors.raised : AppColors.card, + foregroundColor: selected ? AppColors.blue : AppColors.cream, + side: BorderSide( + color: selected ? AppColors.blue : AppColors.outlineDark, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + icon: Icon(icon, size: 21), + ), + ); + } + + Widget _favoriteButton( + BuildContext context, + AppLocalizations l10n, { + required Key key, + }) { + final saved = context.watch().isSaved(logic.drama.id); + return _GlassCircleButton( + key: key, + tooltip: l10n.favorite, + icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, + selected: saved, + onPressed: () => _toggleFavorite(l10n), ); } - /// 详情页更多菜单:本批只提供举报短剧。 Widget _reportAction(BuildContext context, AppLocalizations l10n) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, + color: AppColors.card, onSelected: (_) => unawaited(_reportDrama(l10n)), itemBuilder: (_) => [ PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), ], + child: const _GlassCircle( + child: Icon( + Icons.more_horiz_rounded, + color: AppColors.onSurface, + size: 20, + ), + ), ); } + Future _shareDrama(AppLocalizations l10n, Drama drama) async { + try { + await const PlatformShareService().shareDrama( + title: localizedDramaTitle(l10n, drama), + synopsis: localizedDramaSynopsis(l10n, drama), + footer: l10n.shareFooter, + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.shareFailed))); + } + } + + Future _toggleFavorite(AppLocalizations l10n) async { + if (!await ensureSignedIn(context)) return; + if (!mounted) return; + unawaited(context.read().lightImpact()); + final ok = await logic.toggleFavorite(); + if (ok || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); + } + + void _downloadCurrent() { + final episodes = logic.drama.episodes; + if (episodes.isEmpty) return; + final index = (logic.continueIndex ?? 0).clamp(0, episodes.length - 1); + requestEpisodeDownload(context, episodes[index].id); + } + Future _reportDrama(AppLocalizations l10n) async { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); @@ -348,25 +942,11 @@ class _DramaDetailPageState extends State { ); } - /// 收藏按钮:状态取自服务端(乐观更新,失败回滚并提示)。 - Widget _favoriteAction(BuildContext context, AppLocalizations l10n) { - final favorites = context.watch(); - final saved = favorites.isSaved(logic.drama.id); - return IconButton( - tooltip: l10n.favorite, - icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), - onPressed: () async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await logic.toggleFavorite(); - if (ok || !context.mounted) return; - // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed))); - }, - ); + double? get _continueFraction { + final record = logic.progress; + if (record == null || !record.hasPosition) return null; + if (logic.continueIndex == null) return null; + return record.fraction; } String _clock(Duration duration) { @@ -378,7 +958,52 @@ class _DramaDetailPageState extends State { } } -/// 续看进度条(详情页按钮下 / 剧集行内)。fraction 由服务端记录算出。 +class _GlassCircleButton extends StatelessWidget { + const _GlassCircleButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.selected = false, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final bool selected; + + @override + Widget build(BuildContext context) { + return _GlassCircle( + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + icon: Icon( + icon, + size: 20, + color: selected ? AppColors.blue : AppColors.onSurface, + ), + ), + ); + } +} + +class _GlassCircle extends StatelessWidget { + const _GlassCircle({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: GlassPanel(radius: 999, blur: 14, child: child), + ); + } +} + class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -394,9 +1019,9 @@ class _ResumeBar extends StatelessWidget { borderRadius: BorderRadius.circular(AppDimens.progressRadius), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressHeight, + minHeight: AppDimens.progressLineHeight, backgroundColor: Colors.white24, - valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), + valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), if (label != null) ...[ @@ -413,3 +1038,15 @@ class _ResumeBar extends StatelessWidget { ); } } + +String _compactCount(int value) { + if (value >= 1000000) { + final scaled = value / 1000000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; + } + if (value >= 1000) { + final scaled = value / 1000; + return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; + } + return '$value'; +} diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart new file mode 100644 index 0000000..3bd89f3 --- /dev/null +++ b/test/drama_detail/drama_detail_view_test.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/data/services/catalog_controller.dart'; +import 'package:fruitshorts/core/data/services/favorite_controller.dart'; +import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; + +void main() { + testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + + final hero = tester.widget( + find.byKey(const Key('detail-hero')), + ); + expect(hero.aspectRatio, closeTo(16 / 9, 0.001)); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-chip-1'))), + const Size(46, 44), + ); + + final synopsis = tester.widget( + find.byKey(const Key('detail-synopsis')), + ); + expect(synopsis.maxLines, 2); + await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('detail-synopsis'))).maxLines, + 100, + ); + + final grid = tester.widget( + find.descendant( + of: find.byKey(const Key('detail-episode-grid')), + matching: find.byType(SliverGrid), + ), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 2); + expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))), + const Size(96, 64), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_fit_compact_viewport_when_detail_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 568); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama())); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -520), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Widget _host(Drama drama) { + final catalog = FakeCatalogRepository(dramas: [drama]); + return MultiProvider( + providers: [ + Provider.value( + value: InMemoryWatchHistoryRepository(), + ), + Provider.value(value: FakePlaybackRepository()), + Provider.value(value: FakeHaptics()), + ChangeNotifierProvider( + create: (_) => FavoriteController(InMemoryFavoriteRepository()), + ), + ChangeNotifierProvider( + create: (_) => InteractionController(FakeInteractionRepository()), + ), + ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), + ], + child: MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: DramaDetailPage(drama: drama), + ), + ); +} + +Drama _detailDrama() { + return Drama( + id: 'detail-k25', + title: 'Detail K25', + synopsis: + 'She agreed to marry a mysterious stranger to save her family, ' + 'until she discovered his secret identity and the city he controls.', + posterUrl: '/files/detail-k25.png', + genre: 'Urban Romance', + category: 'Romance', + totalEpisodes: 8, + viewCount: 4800000, + detailLoaded: true, + episodes: [ + for (var number = 1; number <= 8; number++) + Episode( + id: 'detail-k25-$number', + number: number, + title: 'Episode $number', + isFree: number == 1, + durationSec: 135, + locked: number > 1, + videoUrl: number == 1 ? '/files/detail-k25-1.mp4' : '', + ), + ], + ); +} From 689063a6945cf7a6c306a0bb22d3837d753d5cff Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:16:40 +0800 Subject: [PATCH 04/30] =?UTF-8?q?feat(player):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E5=BC=8F=E6=92=AD=E6=94=BE=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_logic.dart | 12 + lib/pages/player/player_view.dart | 620 ++++++++++++++++++ lib/pages/player/widgets/card_seek_bar.dart | 257 ++++++++ .../widgets/player_featured_comment.dart | 164 +++++ test/player/player_card_view_test.dart | 169 +++++ test/widget_test.dart | 11 +- 6 files changed, 1229 insertions(+), 4 deletions(-) create mode 100644 lib/pages/player/widgets/card_seek_bar.dart create mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 test/player/player_card_view_test.dart diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..64c459e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a195c69..0140278 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -21,10 +21,14 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; +import '../../core/widgets/glass_panel.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; +import 'widgets/card_seek_bar.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -114,6 +118,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); + if (!widget.homeMode && !logic.fullscreen) { + return _buildCardPlayer(context, interactions); + } final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( @@ -143,6 +150,533 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } + Widget _buildCardPlayer( + BuildContext context, + InteractionController interactions, + ) { + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Theme( + data: AppTheme.dark(), + child: Scaffold( + backgroundColor: AppColors.ink, + body: SafeArea( + child: AnimatedBuilder( + animation: logic.session, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + final index = logic.session.currentIndex; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = logic.displayEpisode(drama, entry.episode); + final port = + (index == logic.session.currentIndex + ? logic.session.active + : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final ready = + videoPort?.controller.value.isInitialized ?? false; + return Column( + children: [ + _cardTopBar(context, l10n, drama, episode), + Expanded( + child: SingleChildScrollView( + key: const Key('card-player-scroll'), + padding: const EdgeInsets.fromLTRB(16, 6, 16, 20), + child: Column( + children: [ + AspectRatio( + key: const Key('card-player-video-frame'), + aspectRatio: 9 / 10, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: AnimatedBuilder( + animation: logic.session, + builder: (context, _) => PageView.builder( + controller: logic.pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + itemCount: logic.visibleIndices.length, + onPageChanged: (position) { + final next = + logic.visibleIndices[position]; + widget.onDramaChanged?.call( + logic.queue.entryAt(next).drama, + ); + unawaited(logic.selectEpisode(next)); + }, + itemBuilder: (context, position) => + _buildCardVideo( + context, + logic.visibleIndices[position], + interactions, + ), + ), + ), + ), + ), + const SizedBox(height: 6), + CardSeekBar( + controller: ready ? videoPort!.controller : null, + semanticsLabel: l10n.seekVideo, + episodeLabel: l10n.episodeLabel(episode.number), + totalEpisodes: drama.episodeCount, + onScrubStart: ready + ? () => logic.beginScrub(videoPort!) + : null, + onSeek: ready + ? (position) => logic.seekTo( + context, + videoPort!, + position, + ) + : null, + ), + const SizedBox(height: 10), + _cardActions(context, l10n, drama, episode), + const SizedBox(height: 12), + PlayerFeaturedComment( + drama: drama, + onViewAll: () => + logic.showComments(context, drama), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + } + + Widget _cardTopBar( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 6), + child: Row( + children: [ + IconButton( + key: const Key('player-back'), + tooltip: l10n.backToSeries, + onPressed: () => unawaited(logic.leave(context)), + style: IconButton.styleFrom( + backgroundColor: AppColors.raised, + foregroundColor: AppColors.onSurface, + ), + icon: const Icon(Icons.chevron_left_rounded), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${localizedDramaTitle(l10n, drama)} · ' + '${l10n.episodeLabel(episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + l10n.seasonOneOriginalRelease, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + key: const Key('player-more'), + tooltip: l10n.more, + onPressed: () => unawaited(_showCardMore(context, l10n, episode)), + style: IconButton.styleFrom( + backgroundColor: AppColors.raised, + foregroundColor: AppColors.onSurface, + ), + icon: const Icon(Icons.more_horiz_rounded), + ), + ], + ), + ); + } + + Widget _buildCardVideo( + BuildContext context, + int index, + InteractionController interactions, + ) { + final l10n = AppLocalizations.of(context)!; + final entry = logic.queue.entryAt(index); + final drama = entry.drama; + final episode = logic.displayEpisode(drama, entry.episode); + final selected = index == logic.session.currentIndex; + final port = + (selected ? logic.session.active : null) ?? + logic.session.preparedPlayerAt(index); + final videoPort = port is VideoPlayerPort ? port : null; + final ready = videoPort?.controller.value.isInitialized ?? false; + + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.ink), + if (ready) + GestureDetector( + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + child: FittedBox( + fit: BoxFit.contain, + child: SizedBox( + width: videoPort!.controller.value.size.width, + height: videoPort.controller.value.size.height, + child: VideoPlayer( + videoPort.controller, + key: ValueKey(videoPort.controller), + ), + ), + ), + ) + else + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + ), + const IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0x66000000), + Color(0x11000000), + Color(0x99000000), + ], + stops: [0, 0.46, 1], + ), + ), + ), + ), + Positioned( + top: 10, + left: 10, + right: 10, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GlassPanel( + radius: 999, + blur: 12, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.skip_next_rounded, + size: 12, + color: AppColors.secondary, + ), + const SizedBox(width: 3), + Text( + l10n.autoNextOn, + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: Colors.white.withValues(alpha: 0.18), + ), + ), + child: const Text( + '1.0x', + style: TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + if (selected && logic.ticketLoading) + const Center(child: CircularProgressIndicator(color: AppColors.blue)), + if (selected && !logic.ticketLoading && logic.ticketError != null) + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.wifi_off_rounded, + color: AppColors.muted, + size: 30, + ), + const SizedBox(height: 8), + Text( + errorText(l10n, logic.ticketError, l10n.playbackError), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.cream), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () => unawaited(logic.retryTicket()), + child: Text(l10n.retry), + ), + ], + ), + ), + ), + if (selected && logic.session.loading) + const Center(child: CircularProgressIndicator(color: AppColors.blue)), + if (selected && !ready && logic.session.error != null) + Center( + child: Text( + l10n.playbackError, + style: const TextStyle(color: AppColors.cream), + ), + ), + if (selected && !ready && logic.blockedEpisodeId == episode.id) + _BlockedNotice( + l10n: l10n, + episode: episode, + accessType: logic.blockedTicket?.accessType, + unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, + ), + if (selected && + !ready && + logic.blockedEpisodeId != episode.id && + logic.session.error == null && + logic.session.isLocked) + Center( + child: Text( + episode.locked + ? l10n.lockedTitle(episode.number) + : l10n.noMediaNotice, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.cream), + ), + ), + if (selected && ready && !logic.session.userWantsPlay) + Center( + child: GestureDetector( + key: const Key('card-player-play'), + onTap: () => logic.session.setUserWantsPlay(true), + child: Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.92), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 2, + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + ), + Positioned( + right: 10, + bottom: 12, + child: Column( + children: [ + _CardIconButton( + key: const Key('card-player-like'), + tooltip: l10n.like, + icon: interactions.isLiked(drama.id) + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: interactions.isLiked(drama.id) + ? AppColors.blue + : AppColors.onSurface, + onPressed: () => + unawaited(_toggleLike(context, drama, interactions)), + ), + const SizedBox(height: 8), + _CardIconButton( + key: const Key('card-player-fullscreen'), + tooltip: l10n.enterFullscreen, + icon: Icons.fullscreen_rounded, + onPressed: () => unawaited(logic.setFullscreen(true)), + ), + ], + ), + ), + ], + ); + } + + Widget _cardActions( + BuildContext context, + AppLocalizations l10n, + Drama drama, + Episode episode, + ) { + return Row( + children: [ + Expanded( + child: _CardActionButton( + key: const Key('card-player-episodes'), + icon: Icons.view_list_rounded, + label: l10n.episodes, + onPressed: () => unawaited(logic.showEpisodes(context)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _CardActionButton( + key: const Key('card-player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onPressed: () => unawaited(logic.showComments(context, drama)), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 42, + child: FilledButton( + key: const Key('card-player-next'), + onPressed: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.skip_next_rounded, size: 15), + ], + ), + ), + ), + ), + ], + ); + } + + Future _showCardMore( + BuildContext context, + AppLocalizations l10n, + Episode episode, + ) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + key: const Key('player-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + requestEpisodeDownload(context, episode.id); + }, + ), + if (logic.pictureInPicture.isPlatformSupported) + ListTile( + key: const Key('player-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + unawaited(logic.enterPip(context)); + }, + ), + ], + ), + ), + ); + } + + Future _toggleLike( + BuildContext context, + Drama drama, + InteractionController interactions, + ) async { + if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike(drama.id); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + Widget _buildEpisode( BuildContext context, int index, @@ -561,6 +1095,92 @@ class _BlockedNotice extends StatelessWidget { } } +class _CardIconButton extends StatelessWidget { + const _CardIconButton({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + this.color = AppColors.onSurface, + }); + + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + final Color color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.55), + foregroundColor: color, + side: BorderSide(color: Colors.white.withValues(alpha: 0.15)), + ), + icon: Icon(icon, size: 18), + ), + ); + } +} + +class _CardActionButton extends StatelessWidget { + const _CardActionButton({ + super.key, + required this.icon, + required this.label, + required this.onPressed, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.raised, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 15, color: AppColors.secondary), + const SizedBox(width: 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + /// Coin lock presented as a reward card: gold chip, chili only on the icon. class _UnlockCard extends StatelessWidget { const _UnlockCard({ diff --git a/lib/pages/player/widgets/card_seek_bar.dart b/lib/pages/player/widgets/card_seek_bar.dart new file mode 100644 index 0000000..efe2477 --- /dev/null +++ b/lib/pages/player/widgets/card_seek_bar.dart @@ -0,0 +1,257 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// 1.5px player scrubber with a buffered segment and white thumb. +class CardSeekBar extends StatefulWidget { + const CardSeekBar({ + super.key, + required this.controller, + required this.semanticsLabel, + required this.episodeLabel, + required this.totalEpisodes, + this.onScrubStart, + this.onSeek, + }); + + final VideoPlayerController? controller; + final String semanticsLabel; + final String episodeLabel; + final int totalEpisodes; + final VoidCallback? onScrubStart; + final Future Function(Duration)? onSeek; + + @override + State createState() => _CardSeekBarState(); +} + +class _CardSeekBarState extends State { + final ValueNotifier _preview = ValueNotifier(null); + + @override + void dispose() { + _preview.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + if (controller == null) { + return _track(context, const VideoPlayerValue.uninitialized()); + } + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) { + return ValueListenableBuilder( + valueListenable: _preview, + builder: (context, _, _) => _track(context, value), + ); + }, + ); + } + + Widget _track(BuildContext context, VideoPlayerValue video) { + final total = video.duration.inMilliseconds; + final enabled = video.isInitialized && total > 0 && widget.onSeek != null; + final played = total > 0 + ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) + : 0.0; + final fraction = _preview.value ?? played; + final buffered = _bufferedFraction(video); + final previewPosition = _preview.value == null + ? video.position + : Duration(milliseconds: (fraction * total).round()); + + return Column( + key: const Key('card-player-progress-bar'), + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 24, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 1.5, + activeTrackColor: AppColors.blue, + inactiveTrackColor: Colors.white.withValues(alpha: 0.15), + thumbColor: Colors.white, + overlayColor: AppColors.blue.withValues(alpha: 0.16), + trackShape: _BufferedTrackShape(buffered: buffered), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), + ), + child: Semantics( + label: widget.semanticsLabel, + child: Slider( + key: const Key('card-player-progress'), + value: fraction, + onChangeStart: enabled + ? (value) { + _preview.value = value; + widget.onScrubStart?.call(); + } + : null, + onChanged: enabled ? (value) => _preview.value = value : null, + onChangeEnd: enabled + ? (value) => unawaited(_finish(value, video.duration)) + : null, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Text( + _format(previewPosition), + key: const Key('card-player-time-current'), + style: const TextStyle( + color: AppColors.cream, + fontSize: 10, + fontWeight: FontWeight.w600, + ), + ), + Expanded( + child: Center( + child: Text( + '${widget.episodeLabel} / ${widget.totalEpisodes}', + key: const Key('card-player-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.subtle, + fontSize: 10, + ), + ), + ), + ), + Text( + _format(video.duration), + key: const Key('card-player-time-total'), + style: const TextStyle(color: AppColors.subtle, fontSize: 10), + ), + ], + ), + ), + ], + ); + } + + Future _finish(double fraction, Duration duration) async { + final source = widget.controller; + try { + final total = duration.inMilliseconds; + final target = total > 250 + ? (fraction * total).round().clamp(0, total - 250) + : 0; + await widget.onSeek?.call(Duration(milliseconds: target)); + } finally { + if (mounted && widget.controller == source) _preview.value = null; + } + } + + double _bufferedFraction(VideoPlayerValue video) { + final total = video.duration.inMilliseconds; + if (total <= 0 || video.buffered.isEmpty) return 0; + var end = Duration.zero; + for (final range in video.buffered) { + if (range.end > end) end = range.end; + } + return (end.inMilliseconds / total).clamp(0.0, 1.0); + } + + String _format(Duration value) { + final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} + +class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape { + const _BufferedTrackShape({required this.buffered}); + + final double buffered; + + @override + Rect getPreferredRect({ + required RenderBox parentBox, + Offset offset = Offset.zero, + required SliderThemeData sliderTheme, + bool isEnabled = false, + bool isDiscrete = false, + }) { + final thumbWidth = + sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ?? + 0; + final overlayWidth = + sliderTheme.overlayShape + ?.getPreferredSize(isEnabled, isDiscrete) + .width ?? + 0; + final trackHeight = sliderTheme.trackHeight ?? 1.5; + final trackLeft = offset.dx + overlayWidth / 2; + final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; + final trackWidth = + parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth; + return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); + } + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isEnabled = false, + bool isDiscrete = false, + bool isSecondaryTrack = false, + }) { + final rect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + final radius = Radius.circular(rect.height); + final canvas = context.canvas; + canvas.drawRRect( + RRect.fromRectAndRadius(rect, radius), + Paint()..color = Colors.white.withValues(alpha: 0.15), + ); + if (buffered > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + rect.left, + rect.top, + rect.width * buffered, + rect.height, + ), + radius, + ), + Paint()..color = Colors.white.withValues(alpha: 0.30), + ); + } + final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0); + if (played > 0) { + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height), + radius, + ), + Paint()..color = AppColors.blue, + ); + } + } +} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..86dda19 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,164 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +/// First loaded top-level comment shown under the card player. +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + return Container( + key: const Key('player-featured-comment'), + padding: const EdgeInsets.fromLTRB(13, 12, 13, 10), + decoration: BoxDecoration( + color: const Color(0xFF14161E), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.18), + border: Border.all( + color: AppColors.secondary.withValues(alpha: 0.4), + ), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + ), + if (comment.likeCount > 0) ...[ + const Icon( + Icons.favorite_rounded, + size: 13, + color: AppColors.blue, + ), + const SizedBox(width: 3), + Text( + '${comment.likeCount}', + style: const TextStyle(color: AppColors.muted, fontSize: 10), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 11.5, + height: 1.4, + ), + ), + const SizedBox(height: 7), + Align( + alignment: Alignment.centerRight, + child: TextButton( + key: const Key('player-comments-view-all'), + onPressed: widget.onViewAll, + style: TextButton.styleFrom( + foregroundColor: AppColors.secondary, + minimumSize: const Size(44, 32), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: const TextStyle( + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + child: Text(l10n.viewAll), + ), + ), + ], + ), + ); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/test/player/player_card_view_test.dart b/test/player/player_card_view_test.dart new file mode 100644 index 0000000..5ce75de --- /dev/null +++ b/test/player/player_card_view_test.dart @@ -0,0 +1,169 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/comment.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_render_card_player_progress_and_three_action_buttons', ( + tester, + ) async { + await _pumpApp(tester); + await _openCityLights(tester); + + final frame = tester.widget( + find.byKey(const Key('card-player-video-frame')), + ); + expect(frame.aspectRatio, closeTo(9 / 10, 0.001)); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + expect(find.byKey(const Key('card-player-progress')), findsOneWidget); + expect(find.byKey(const Key('card-player-episodes')), findsOneWidget); + expect(find.byKey(const Key('card-player-comments')), findsOneWidget); + expect(find.byKey(const Key('card-player-next')), findsOneWidget); + + final theme = tester.widget( + find.ancestor( + of: find.byKey(const Key('card-player-progress')), + matching: find.byType(SliderTheme), + ), + ); + expect(theme.data.trackHeight, 1.5); + expect(theme.data.activeTrackColor, AppColors.blue); + expect(theme.data.thumbColor, Colors.white); + expect(theme.data.inactiveTrackColor, Colors.white.withValues(alpha: 0.15)); + + final next = tester.widget( + find.byKey(const Key('card-player-next')), + ); + expect(next.onPressed, isNotNull); + await tester.tap(find.byKey(const Key('card-player-next'))); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_disable_next_episode_on_the_last_episode', ( + tester, + ) async { + await _pumpApp(tester); + await _openCityLights(tester); + for (var index = 0; index < 6; index++) { + await tester.tap(find.byKey(const Key('card-player-next'))); + await tester.pumpAndSettle(); + } + + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 7'), + ); + final next = tester.widget( + find.byKey(const Key('card-player-next')), + ); + expect(next.onPressed, isNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_show_featured_comment_when_comments_exist', ( + tester, + ) async { + await _pumpApp( + tester, + comments: FakeCommentRepository( + pages: const [ + CommentPage( + comments: [ + DramaComment( + id: 'c1', + dramaId: 'city-lights-demo', + episodeId: '0', + parentId: '0', + rootId: '0', + userId: '9', + uid: 'U9', + nickname: 'Lina', + avatarUrl: '', + content: 'The banquet scene gave me chills.', + status: 'approved', + auditReason: '', + likeCount: 428, + replyCount: 0, + reportCount: 0, + createdAt: null, + mine: false, + liked: false, + ), + ], + ), + ], + ), + ); + await _openCityLights(tester); + + expect(find.byKey(const Key('player-featured-comment')), findsOneWidget); + expect(find.text('Lina'), findsOneWidget); + expect(find.text('428'), findsOneWidget); + expect(find.byKey(const Key('player-comments-view-all')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-comments-view-all'))); + await tester.pumpAndSettle(); + expect(find.text('The banquet scene gave me chills.'), findsWidgets); + }); +} + +Future _openCityLights(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.pumpAndSettle(); +} + +Future _pumpApp( + WidgetTester tester, { + CommentRepository? comments, +}) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: comments ?? FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index f5648ed..7a4c685 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -184,7 +184,7 @@ void main() { await tester.tap(find.byKey(const Key('drawer-open-series'))); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect( @@ -445,8 +445,11 @@ void main() { expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsWidgets); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); + expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -654,7 +657,7 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('The Signal')); await tester.pumpAndSettle(); - expect(find.text('Watch episode 1'), findsOneWidget); + expect(find.byKey(const Key('detail-cta')), findsOneWidget); }, ); } From 1c94b727fb964c0947a09357d3a6aee0ff8fcddf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:24:06 +0800 Subject: [PATCH 05/30] =?UTF-8?q?fix(player):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8E=E9=80=89=E9=9B=86=E5=85=A8=E5=B1=8F?= =?UTF-8?q?=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 3 - lib/l10n/app_id.arb | 3 - lib/l10n/app_localizations.dart | 18 - lib/l10n/app_localizations_en.dart | 9 - lib/l10n/app_localizations_id.dart | 9 - lib/l10n/app_localizations_zh.dart | 9 - lib/l10n/app_zh.arb | 3 - lib/pages/player/player_logic.dart | 12 - lib/pages/player/player_view.dart | 620 ------------------ lib/pages/player/widgets/card_seek_bar.dart | 257 -------- .../widgets/player_featured_comment.dart | 164 ----- test/player/player_card_view_test.dart | 169 ----- test/player/player_fullscreen_test.dart | 101 +++ test/widget_test.dart | 5 +- 14 files changed, 102 insertions(+), 1280 deletions(-) delete mode 100644 lib/pages/player/widgets/card_seek_bar.dart delete mode 100644 lib/pages/player/widgets/player_featured_comment.dart delete mode 100644 test/player/player_card_view_test.dart create mode 100644 test/player/player_fullscreen_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b6db44d..ccf4bcf 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -29,11 +29,8 @@ "seasonOneVerticalCinema": "Season 1 • Vertical Cinema", "seasonOneOriginalRelease": "Season 1 • Original Release", "startWatching": "Start Watching", - "autoNextOn": "Auto-Next On", - "viewAll": "View all", "showLess": "Less", "views": "views", - "enterFullscreen": "Fullscreen", "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index e839cd1..22a79f2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -29,11 +29,8 @@ "seasonOneVerticalCinema": "Musim 1 • Sinema Vertikal", "seasonOneOriginalRelease": "Musim 1 • Rilis Orisinal", "startWatching": "Mulai Menonton", - "autoNextOn": "Lanjut Otomatis", - "viewAll": "Lihat semua", "showLess": "Lebih sedikit", "views": "tayangan", - "enterFullscreen": "Layar penuh", "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5ae6fac..ee0214e 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -274,18 +274,6 @@ abstract class AppLocalizations { /// **'Start Watching'** String get startWatching; - /// No description provided for @autoNextOn. - /// - /// In en, this message translates to: - /// **'Auto-Next On'** - String get autoNextOn; - - /// No description provided for @viewAll. - /// - /// In en, this message translates to: - /// **'View all'** - String get viewAll; - /// No description provided for @showLess. /// /// In en, this message translates to: @@ -298,12 +286,6 @@ abstract class AppLocalizations { /// **'views'** String get views; - /// No description provided for @enterFullscreen. - /// - /// In en, this message translates to: - /// **'Fullscreen'** - String get enterFullscreen; - /// No description provided for @artworkOnly. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f93e1e7..d39d132 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -96,21 +96,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get startWatching => 'Start Watching'; - @override - String get autoNextOn => 'Auto-Next On'; - - @override - String get viewAll => 'View all'; - @override String get showLess => 'Less'; @override String get views => 'views'; - @override - String get enterFullscreen => 'Fullscreen'; - @override String get artworkOnly => 'Artwork'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 8cfe7fa..06dd59f 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -96,21 +96,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get startWatching => 'Mulai Menonton'; - @override - String get autoNextOn => 'Lanjut Otomatis'; - - @override - String get viewAll => 'Lihat semua'; - @override String get showLess => 'Lebih sedikit'; @override String get views => 'tayangan'; - @override - String get enterFullscreen => 'Layar penuh'; - @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index ef96d79..8453aca 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -95,21 +95,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get startWatching => '开始观看'; - @override - String get autoNextOn => '自动连播'; - - @override - String get viewAll => '查看全部'; - @override String get showLess => '收起'; @override String get views => '次播放'; - @override - String get enterFullscreen => '全屏'; - @override String get artworkOnly => '海报展示'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 5967360..374f17a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -29,11 +29,8 @@ "seasonOneVerticalCinema": "第 1 季 · 竖屏影院", "seasonOneOriginalRelease": "第 1 季 · 原创上线", "startWatching": "开始观看", - "autoNextOn": "自动连播", - "viewAll": "查看全部", "showLess": "收起", "views": "次播放", - "enterFullscreen": "全屏", "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 64c459e..aa4e50b 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,18 +531,6 @@ class PlayerLogic extends ChangeNotifier { : null; } - bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; - - Future playNextEpisode() async { - final next = nextVisibleIndex(session.currentIndex); - if (next == null || !pages.hasClients) return; - await pages.animateToPage( - visibleIndices.indexOf(next), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); - } - void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0140278..a195c69 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -21,14 +21,10 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; -import '../../core/widgets/glass_panel.dart'; -import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; -import 'widgets/card_seek_bar.dart'; import 'widgets/home_feed_overlay.dart'; -import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -118,9 +114,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { Widget _buildPlayer(BuildContext context) { final interactions = context.watch(); - if (!widget.homeMode && !logic.fullscreen) { - return _buildCardPlayer(context, interactions); - } final indices = logic.visibleIndices; final pages = logic.pages; final content = AnimatedBuilder( @@ -150,533 +143,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ); } - Widget _buildCardPlayer( - BuildContext context, - InteractionController interactions, - ) { - return AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: Theme( - data: AppTheme.dark(), - child: Scaffold( - backgroundColor: AppColors.ink, - body: SafeArea( - child: AnimatedBuilder( - animation: logic.session, - builder: (context, _) { - final l10n = AppLocalizations.of(context)!; - final index = logic.session.currentIndex; - final entry = logic.queue.entryAt(index); - final drama = entry.drama; - final episode = logic.displayEpisode(drama, entry.episode); - final port = - (index == logic.session.currentIndex - ? logic.session.active - : null) ?? - logic.session.preparedPlayerAt(index); - final videoPort = port is VideoPlayerPort ? port : null; - final ready = - videoPort?.controller.value.isInitialized ?? false; - return Column( - children: [ - _cardTopBar(context, l10n, drama, episode), - Expanded( - child: SingleChildScrollView( - key: const Key('card-player-scroll'), - padding: const EdgeInsets.fromLTRB(16, 6, 16, 20), - child: Column( - children: [ - AspectRatio( - key: const Key('card-player-video-frame'), - aspectRatio: 9 / 10, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: AnimatedBuilder( - animation: logic.session, - builder: (context, _) => PageView.builder( - controller: logic.pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - itemCount: logic.visibleIndices.length, - onPageChanged: (position) { - final next = - logic.visibleIndices[position]; - widget.onDramaChanged?.call( - logic.queue.entryAt(next).drama, - ); - unawaited(logic.selectEpisode(next)); - }, - itemBuilder: (context, position) => - _buildCardVideo( - context, - logic.visibleIndices[position], - interactions, - ), - ), - ), - ), - ), - const SizedBox(height: 6), - CardSeekBar( - controller: ready ? videoPort!.controller : null, - semanticsLabel: l10n.seekVideo, - episodeLabel: l10n.episodeLabel(episode.number), - totalEpisodes: drama.episodeCount, - onScrubStart: ready - ? () => logic.beginScrub(videoPort!) - : null, - onSeek: ready - ? (position) => logic.seekTo( - context, - videoPort!, - position, - ) - : null, - ), - const SizedBox(height: 10), - _cardActions(context, l10n, drama, episode), - const SizedBox(height: 12), - PlayerFeaturedComment( - drama: drama, - onViewAll: () => - logic.showComments(context, drama), - ), - ], - ), - ), - ), - ], - ); - }, - ), - ), - ), - ), - ); - } - - Widget _cardTopBar( - BuildContext context, - AppLocalizations l10n, - Drama drama, - Episode episode, - ) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 6), - child: Row( - children: [ - IconButton( - key: const Key('player-back'), - tooltip: l10n.backToSeries, - onPressed: () => unawaited(logic.leave(context)), - style: IconButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, - ), - icon: const Icon(Icons.chevron_left_rounded), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${localizedDramaTitle(l10n, drama)} · ' - '${l10n.episodeLabel(episode.number)}', - key: const Key('player-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - l10n.seasonOneOriginalRelease, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(color: AppColors.subtle, fontSize: 10), - ), - ], - ), - ), - const SizedBox(width: 8), - IconButton( - key: const Key('player-more'), - tooltip: l10n.more, - onPressed: () => unawaited(_showCardMore(context, l10n, episode)), - style: IconButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: AppColors.onSurface, - ), - icon: const Icon(Icons.more_horiz_rounded), - ), - ], - ), - ); - } - - Widget _buildCardVideo( - BuildContext context, - int index, - InteractionController interactions, - ) { - final l10n = AppLocalizations.of(context)!; - final entry = logic.queue.entryAt(index); - final drama = entry.drama; - final episode = logic.displayEpisode(drama, entry.episode); - final selected = index == logic.session.currentIndex; - final port = - (selected ? logic.session.active : null) ?? - logic.session.preparedPlayerAt(index); - final videoPort = port is VideoPlayerPort ? port : null; - final ready = videoPort?.controller.value.isInitialized ?? false; - - return Stack( - fit: StackFit.expand, - children: [ - const ColoredBox(color: AppColors.ink), - if (ready) - GestureDetector( - onTap: () => - logic.session.setUserWantsPlay(!logic.session.userWantsPlay), - child: FittedBox( - fit: BoxFit.contain, - child: SizedBox( - width: videoPort!.controller.value.size.width, - height: videoPort.controller.value.size.height, - child: VideoPlayer( - videoPort.controller, - key: ValueKey(videoPort.controller), - ), - ), - ), - ) - else - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x66000000), - Color(0x11000000), - Color(0x99000000), - ], - stops: [0, 0.46, 1], - ), - ), - ), - ), - Positioned( - top: 10, - left: 10, - right: 10, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GlassPanel( - radius: 999, - blur: 12, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.skip_next_rounded, - size: 12, - color: AppColors.secondary, - ), - const SizedBox(width: 3), - Text( - l10n.autoNextOn, - style: const TextStyle( - color: AppColors.secondary, - fontSize: 9.5, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: Colors.white.withValues(alpha: 0.18), - ), - ), - child: const Text( - '1.0x', - style: TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - if (selected && logic.ticketLoading) - const Center(child: CircularProgressIndicator(color: AppColors.blue)), - if (selected && !logic.ticketLoading && logic.ticketError != null) - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.wifi_off_rounded, - color: AppColors.muted, - size: 30, - ), - const SizedBox(height: 8), - Text( - errorText(l10n, logic.ticketError, l10n.playbackError), - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.cream), - ), - const SizedBox(height: 8), - FilledButton( - onPressed: () => unawaited(logic.retryTicket()), - child: Text(l10n.retry), - ), - ], - ), - ), - ), - if (selected && logic.session.loading) - const Center(child: CircularProgressIndicator(color: AppColors.blue)), - if (selected && !ready && logic.session.error != null) - Center( - child: Text( - l10n.playbackError, - style: const TextStyle(color: AppColors.cream), - ), - ), - if (selected && !ready && logic.blockedEpisodeId == episode.id) - _BlockedNotice( - l10n: l10n, - episode: episode, - accessType: logic.blockedTicket?.accessType, - unlockCoin: logic.blockedTicket?.unlockCoin ?? 0, - ), - if (selected && - !ready && - logic.blockedEpisodeId != episode.id && - logic.session.error == null && - logic.session.isLocked) - Center( - child: Text( - episode.locked - ? l10n.lockedTitle(episode.number) - : l10n.noMediaNotice, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.cream), - ), - ), - if (selected && ready && !logic.session.userWantsPlay) - Center( - child: GestureDetector( - key: const Key('card-player-play'), - onTap: () => logic.session.setUserWantsPlay(true), - child: Container( - width: 58, - height: 58, - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.92), - shape: BoxShape.circle, - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 2, - ), - ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 32, - ), - ), - ), - ), - Positioned( - right: 10, - bottom: 12, - child: Column( - children: [ - _CardIconButton( - key: const Key('card-player-like'), - tooltip: l10n.like, - icon: interactions.isLiked(drama.id) - ? Icons.favorite_rounded - : Icons.favorite_border_rounded, - color: interactions.isLiked(drama.id) - ? AppColors.blue - : AppColors.onSurface, - onPressed: () => - unawaited(_toggleLike(context, drama, interactions)), - ), - const SizedBox(height: 8), - _CardIconButton( - key: const Key('card-player-fullscreen'), - tooltip: l10n.enterFullscreen, - icon: Icons.fullscreen_rounded, - onPressed: () => unawaited(logic.setFullscreen(true)), - ), - ], - ), - ), - ], - ); - } - - Widget _cardActions( - BuildContext context, - AppLocalizations l10n, - Drama drama, - Episode episode, - ) { - return Row( - children: [ - Expanded( - child: _CardActionButton( - key: const Key('card-player-episodes'), - icon: Icons.view_list_rounded, - label: l10n.episodes, - onPressed: () => unawaited(logic.showEpisodes(context)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: _CardActionButton( - key: const Key('card-player-comments'), - icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, - onPressed: () => unawaited(logic.showComments(context, drama)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 42, - child: FilledButton( - key: const Key('card-player-next'), - onPressed: logic.hasNextEpisode - ? () => unawaited(logic.playNextEpisode()) - : null, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - disabledBackgroundColor: AppColors.raised, - disabledForegroundColor: AppColors.subtle, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - l10n.nextEpisode, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 2), - const Icon(Icons.skip_next_rounded, size: 15), - ], - ), - ), - ), - ), - ], - ); - } - - Future _showCardMore( - BuildContext context, - AppLocalizations l10n, - Episode episode, - ) async { - await showModalBottomSheet( - context: context, - backgroundColor: AppColors.card, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - key: const Key('player-download'), - leading: const Icon( - Icons.download_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.downloads), - onTap: () { - Navigator.pop(sheetContext); - requestEpisodeDownload(context, episode.id); - }, - ), - if (logic.pictureInPicture.isPlatformSupported) - ListTile( - key: const Key('player-pip'), - leading: const Icon( - Icons.picture_in_picture_alt_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.pictureInPicture), - onTap: () { - Navigator.pop(sheetContext); - unawaited(logic.enterPip(context)); - }, - ), - ], - ), - ), - ); - } - - Future _toggleLike( - BuildContext context, - Drama drama, - InteractionController interactions, - ) async { - if (!await ensureSignedIn(context)) return; - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike(drama.id); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } - Widget _buildEpisode( BuildContext context, int index, @@ -1095,92 +561,6 @@ class _BlockedNotice extends StatelessWidget { } } -class _CardIconButton extends StatelessWidget { - const _CardIconButton({ - super.key, - required this.tooltip, - required this.icon, - required this.onPressed, - this.color = AppColors.onSurface, - }); - - final String tooltip; - final IconData icon; - final VoidCallback onPressed; - final Color color; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 36, - height: 36, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - padding: EdgeInsets.zero, - style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.55), - foregroundColor: color, - side: BorderSide(color: Colors.white.withValues(alpha: 0.15)), - ), - icon: Icon(icon, size: 18), - ), - ); - } -} - -class _CardActionButton extends StatelessWidget { - const _CardActionButton({ - super.key, - required this.icon, - required this.label, - required this.onPressed, - }); - - final IconData icon; - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Material( - color: AppColors.raised, - borderRadius: BorderRadius.circular(12), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onPressed, - child: Container( - height: 42, - padding: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, size: 15, color: AppColors.secondary), - const SizedBox(width: 4), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - ), - ); - } -} - /// Coin lock presented as a reward card: gold chip, chili only on the icon. class _UnlockCard extends StatelessWidget { const _UnlockCard({ diff --git a/lib/pages/player/widgets/card_seek_bar.dart b/lib/pages/player/widgets/card_seek_bar.dart deleted file mode 100644 index efe2477..0000000 --- a/lib/pages/player/widgets/card_seek_bar.dart +++ /dev/null @@ -1,257 +0,0 @@ -// Author: fruit.com.cn - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; - -import '../../../core/theme/app_colors.dart'; - -/// 1.5px player scrubber with a buffered segment and white thumb. -class CardSeekBar extends StatefulWidget { - const CardSeekBar({ - super.key, - required this.controller, - required this.semanticsLabel, - required this.episodeLabel, - required this.totalEpisodes, - this.onScrubStart, - this.onSeek, - }); - - final VideoPlayerController? controller; - final String semanticsLabel; - final String episodeLabel; - final int totalEpisodes; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; - - @override - State createState() => _CardSeekBarState(); -} - -class _CardSeekBarState extends State { - final ValueNotifier _preview = ValueNotifier(null); - - @override - void dispose() { - _preview.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final controller = widget.controller; - if (controller == null) { - return _track(context, const VideoPlayerValue.uninitialized()); - } - return ValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) { - return ValueListenableBuilder( - valueListenable: _preview, - builder: (context, _, _) => _track(context, value), - ); - }, - ); - } - - Widget _track(BuildContext context, VideoPlayerValue video) { - final total = video.duration.inMilliseconds; - final enabled = video.isInitialized && total > 0 && widget.onSeek != null; - final played = total > 0 - ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) - : 0.0; - final fraction = _preview.value ?? played; - final buffered = _bufferedFraction(video); - final previewPosition = _preview.value == null - ? video.position - : Duration(milliseconds: (fraction * total).round()); - - return Column( - key: const Key('card-player-progress-bar'), - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 24, - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 1.5, - activeTrackColor: AppColors.blue, - inactiveTrackColor: Colors.white.withValues(alpha: 0.15), - thumbColor: Colors.white, - overlayColor: AppColors.blue.withValues(alpha: 0.16), - trackShape: _BufferedTrackShape(buffered: buffered), - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), - ), - child: Semantics( - label: widget.semanticsLabel, - child: Slider( - key: const Key('card-player-progress'), - value: fraction, - onChangeStart: enabled - ? (value) { - _preview.value = value; - widget.onScrubStart?.call(); - } - : null, - onChanged: enabled ? (value) => _preview.value = value : null, - onChangeEnd: enabled - ? (value) => unawaited(_finish(value, video.duration)) - : null, - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - children: [ - Text( - _format(previewPosition), - key: const Key('card-player-time-current'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - Expanded( - child: Center( - child: Text( - '${widget.episodeLabel} / ${widget.totalEpisodes}', - key: const Key('card-player-episode'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 10, - ), - ), - ), - ), - Text( - _format(video.duration), - key: const Key('card-player-time-total'), - style: const TextStyle(color: AppColors.subtle, fontSize: 10), - ), - ], - ), - ), - ], - ); - } - - Future _finish(double fraction, Duration duration) async { - final source = widget.controller; - try { - final total = duration.inMilliseconds; - final target = total > 250 - ? (fraction * total).round().clamp(0, total - 250) - : 0; - await widget.onSeek?.call(Duration(milliseconds: target)); - } finally { - if (mounted && widget.controller == source) _preview.value = null; - } - } - - double _bufferedFraction(VideoPlayerValue video) { - final total = video.duration.inMilliseconds; - if (total <= 0 || video.buffered.isEmpty) return 0; - var end = Duration.zero; - for (final range in video.buffered) { - if (range.end > end) end = range.end; - } - return (end.inMilliseconds / total).clamp(0.0, 1.0); - } - - String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; - } -} - -class _BufferedTrackShape extends SliderTrackShape with BaseSliderTrackShape { - const _BufferedTrackShape({required this.buffered}); - - final double buffered; - - @override - Rect getPreferredRect({ - required RenderBox parentBox, - Offset offset = Offset.zero, - required SliderThemeData sliderTheme, - bool isEnabled = false, - bool isDiscrete = false, - }) { - final thumbWidth = - sliderTheme.thumbShape?.getPreferredSize(isEnabled, isDiscrete).width ?? - 0; - final overlayWidth = - sliderTheme.overlayShape - ?.getPreferredSize(isEnabled, isDiscrete) - .width ?? - 0; - final trackHeight = sliderTheme.trackHeight ?? 1.5; - final trackLeft = offset.dx + overlayWidth / 2; - final trackTop = offset.dy + (parentBox.size.height - trackHeight) / 2; - final trackWidth = - parentBox.size.width - (overlayWidth - thumbWidth) - thumbWidth; - return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); - } - - @override - void paint( - PaintingContext context, - Offset offset, { - required RenderBox parentBox, - required SliderThemeData sliderTheme, - required Animation enableAnimation, - required TextDirection textDirection, - required Offset thumbCenter, - Offset? secondaryOffset, - bool isEnabled = false, - bool isDiscrete = false, - bool isSecondaryTrack = false, - }) { - final rect = getPreferredRect( - parentBox: parentBox, - offset: offset, - sliderTheme: sliderTheme, - isEnabled: isEnabled, - isDiscrete: isDiscrete, - ); - final radius = Radius.circular(rect.height); - final canvas = context.canvas; - canvas.drawRRect( - RRect.fromRectAndRadius(rect, radius), - Paint()..color = Colors.white.withValues(alpha: 0.15), - ); - if (buffered > 0) { - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH( - rect.left, - rect.top, - rect.width * buffered, - rect.height, - ), - radius, - ), - Paint()..color = Colors.white.withValues(alpha: 0.30), - ); - } - final played = ((thumbCenter.dx - rect.left) / rect.width).clamp(0.0, 1.0); - if (played > 0) { - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(rect.left, rect.top, rect.width * played, rect.height), - radius, - ), - Paint()..color = AppColors.blue, - ); - } - } -} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart deleted file mode 100644 index 86dda19..0000000 --- a/lib/pages/player/widgets/player_featured_comment.dart +++ /dev/null @@ -1,164 +0,0 @@ -// Author: fruit.com.cn - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../core/data/models/comment.dart'; -import '../../../core/data/models/drama.dart'; -import '../../../core/data/repositories/comment_repository.dart'; -import '../../../core/data/repositories/interaction_repository.dart'; -import '../../../core/data/services/comment_controller.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../l10n/app_localizations.dart'; - -/// First loaded top-level comment shown under the card player. -class PlayerFeaturedComment extends StatefulWidget { - const PlayerFeaturedComment({ - super.key, - required this.drama, - required this.onViewAll, - }); - - final Drama drama; - final VoidCallback onViewAll; - - @override - State createState() => _PlayerFeaturedCommentState(); -} - -class _PlayerFeaturedCommentState extends State { - late final CommentController _controller; - - @override - void initState() { - super.initState(); - _controller = CommentController( - comments: context.read(), - interactions: context.read(), - dramaId: widget.drama.id, - ); - unawaited(_controller.load()); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: _controller, - builder: (context, _) { - if (_controller.comments.isEmpty) return const SizedBox.shrink(); - return _card(context, _controller.comments.first); - }, - ); - } - - Widget _card(BuildContext context, DramaComment comment) { - final l10n = AppLocalizations.of(context)!; - return Container( - key: const Key('player-featured-comment'), - padding: const EdgeInsets.fromLTRB(13, 12, 13, 10), - decoration: BoxDecoration( - color: const Color(0xFF14161E), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 30, - height: 30, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: AppColors.secondary.withValues(alpha: 0.18), - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.4), - ), - ), - child: Text( - _initial(comment.nickname), - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - comment.nickname.isEmpty - ? l10n.localViewer - : comment.nickname, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: 11.5, - fontWeight: FontWeight.w700, - ), - ), - ), - if (comment.likeCount > 0) ...[ - const Icon( - Icons.favorite_rounded, - size: 13, - color: AppColors.blue, - ), - const SizedBox(width: 3), - Text( - '${comment.likeCount}', - style: const TextStyle(color: AppColors.muted, fontSize: 10), - ), - ], - ], - ), - const SizedBox(height: 8), - Text( - comment.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.cream, - fontSize: 11.5, - height: 1.4, - ), - ), - const SizedBox(height: 7), - Align( - alignment: Alignment.centerRight, - child: TextButton( - key: const Key('player-comments-view-all'), - onPressed: widget.onViewAll, - style: TextButton.styleFrom( - foregroundColor: AppColors.secondary, - minimumSize: const Size(44, 32), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: const TextStyle( - fontSize: 10.5, - fontWeight: FontWeight.w600, - ), - ), - child: Text(l10n.viewAll), - ), - ), - ], - ), - ); - } - - String _initial(String nickname) { - final trimmed = nickname.trim(); - return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); - } -} diff --git a/test/player/player_card_view_test.dart b/test/player/player_card_view_test.dart deleted file mode 100644 index 5ce75de..0000000 --- a/test/player/player_card_view_test.dart +++ /dev/null @@ -1,169 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - -import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/comment.dart'; -import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; -import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; -import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; -import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/theme/app_colors.dart'; - -import '../support/fake_catalog.dart'; -import '../support/fake_category.dart'; -import '../support/fake_haptics.dart'; -import '../support/fake_interaction_repositories.dart'; -import '../support/fake_notice_repository.dart'; -import '../support/widget_player.dart'; - -void main() { - setUp(() { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - }); - - testWidgets('should_render_card_player_progress_and_three_action_buttons', ( - tester, - ) async { - await _pumpApp(tester); - await _openCityLights(tester); - - final frame = tester.widget( - find.byKey(const Key('card-player-video-frame')), - ); - expect(frame.aspectRatio, closeTo(9 / 10, 0.001)); - expect(find.byKey(const Key('player-back')), findsOneWidget); - expect(find.byKey(const Key('player-title')), findsOneWidget); - expect(find.byKey(const Key('player-more')), findsOneWidget); - expect(find.byKey(const Key('card-player-progress')), findsOneWidget); - expect(find.byKey(const Key('card-player-episodes')), findsOneWidget); - expect(find.byKey(const Key('card-player-comments')), findsOneWidget); - expect(find.byKey(const Key('card-player-next')), findsOneWidget); - - final theme = tester.widget( - find.ancestor( - of: find.byKey(const Key('card-player-progress')), - matching: find.byType(SliderTheme), - ), - ); - expect(theme.data.trackHeight, 1.5); - expect(theme.data.activeTrackColor, AppColors.blue); - expect(theme.data.thumbColor, Colors.white); - expect(theme.data.inactiveTrackColor, Colors.white.withValues(alpha: 0.15)); - - final next = tester.widget( - find.byKey(const Key('card-player-next')), - ); - expect(next.onPressed, isNotNull); - await tester.tap(find.byKey(const Key('card-player-next'))); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); - expect(tester.takeException(), isNull); - }); - - testWidgets('should_disable_next_episode_on_the_last_episode', ( - tester, - ) async { - await _pumpApp(tester); - await _openCityLights(tester); - for (var index = 0; index < 6; index++) { - await tester.tap(find.byKey(const Key('card-player-next'))); - await tester.pumpAndSettle(); - } - - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 7'), - ); - final next = tester.widget( - find.byKey(const Key('card-player-next')), - ); - expect(next.onPressed, isNull); - expect(tester.takeException(), isNull); - }); - - testWidgets('should_show_featured_comment_when_comments_exist', ( - tester, - ) async { - await _pumpApp( - tester, - comments: FakeCommentRepository( - pages: const [ - CommentPage( - comments: [ - DramaComment( - id: 'c1', - dramaId: 'city-lights-demo', - episodeId: '0', - parentId: '0', - rootId: '0', - userId: '9', - uid: 'U9', - nickname: 'Lina', - avatarUrl: '', - content: 'The banquet scene gave me chills.', - status: 'approved', - auditReason: '', - likeCount: 428, - replyCount: 0, - reportCount: 0, - createdAt: null, - mine: false, - liked: false, - ), - ], - ), - ], - ), - ); - await _openCityLights(tester); - - expect(find.byKey(const Key('player-featured-comment')), findsOneWidget); - expect(find.text('Lina'), findsOneWidget); - expect(find.text('428'), findsOneWidget); - expect(find.byKey(const Key('player-comments-view-all')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-comments-view-all'))); - await tester.pumpAndSettle(); - expect(find.text('The banquet scene gave me chills.'), findsWidgets); - }); -} - -Future _openCityLights(WidgetTester tester) async { - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); - await tester.pumpAndSettle(); -} - -Future _pumpApp( - WidgetTester tester, { - CommentRepository? comments, -}) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), - playbackRepository: FakePlaybackRepository(), - commentRepository: comments ?? FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), - noticeRepository: FakeNoticeRepository(), - haptics: FakeHaptics(), - ), - ); - await tester.pumpAndSettle(); -} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart new file mode 100644 index 0000000..1b4468a --- /dev/null +++ b/test/player/player_fullscreen_test.dart @@ -0,0 +1,101 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + setUp(() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + }); + + testWidgets('should_open_fullscreen_overlay_from_discover_card', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.pumpAndSettle(); + + _expectFullscreenOverlay(); + expect(find.byType(PageView), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('should_open_fullscreen_overlay_from_detail_cta_and_chip', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); + await tester.pumpAndSettle(); + _expectFullscreenOverlay(); + expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +void _expectFullscreenOverlay() { + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byTooltip('Picture in picture'), findsOneWidget); + expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + + expect(find.byKey(const Key('card-player-video-frame')), findsNothing); + expect(find.byKey(const Key('card-player-progress')), findsNothing); + expect(find.byKey(const Key('card-player-next')), findsNothing); + expect(find.byKey(const Key('player-title')), findsNothing); +} + +Future _pumpApp(WidgetTester tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 7a4c685..0d90f10 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -445,10 +445,7 @@ void main() { expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('player-title'))).data, - contains('Episode 2'), - ); + expect(find.text('Episode 2 · Playing now'), findsOneWidget); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); From 87db5485910f4e7728b817f00bed3d91de257c7c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:07 +0800 Subject: [PATCH 06/30] =?UTF-8?q?feat(theme):=20=E6=A0=A1=E6=AD=A3=20K26?= =?UTF-8?q?=20=E9=A2=84=E8=A7=88=E8=89=B2=E6=9D=BF=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/theme/app_colors.dart | 23 +++++++---- lib/core/theme/app_dimens.dart | 32 +++++++++----- lib/core/widgets/app_bottom_nav.dart | 20 ++------- lib/core/widgets/glass_panel.dart | 2 +- lib/l10n/app_en.arb | 23 ++++++++++- lib/l10n/app_id.arb | 23 ++++++++++- lib/l10n/app_localizations.dart | 62 +++++++++++++++++++++++++++- lib/l10n/app_localizations_en.dart | 38 ++++++++++++++++- lib/l10n/app_localizations_id.dart | 38 ++++++++++++++++- lib/l10n/app_localizations_zh.dart | 38 ++++++++++++++++- lib/l10n/app_zh.arb | 25 ++++++++++- lib/pages/home/home_view.dart | 11 ++--- test/theme/app_dimens_test.dart | 10 ++--- test/theme/app_theme_test.dart | 6 ++- 14 files changed, 293 insertions(+), 58 deletions(-) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index beca1ee..da46392 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -38,25 +38,34 @@ abstract final class AppColors { static const ink = Color(0xFF0B0C10); /// Bottom navigation background (rendered at 95% with blur). - static const bar = Color(0xFF0B0C10); + static const bar = Color(0xFF0D0D11); /// Cards lifted off [ink]. - static const card = Color(0xFF16181F); + static const card = Color(0xFF1B1C21); + + /// Alternate card surface from the mobile preview. + static const cardAlt = Color(0xFF1C1D21); /// Floating surfaces, inputs, and nested rows. - static const raised = Color(0xFF1D1E28); + static const raised = Color(0xFF1C1D21); + static const quick = Color(0xFF202126); + static const input = Color(0xFF25252A); + static const goldCard = Color(0xFF201B1E); /// Body text on dark surfaces. - static const cream = Color(0xFFE3E2E8); + static const cream = Color(0xFFE6E1E3); /// Titles and high-emphasis text. static const onSurface = Color(0xFFFFFFFF); /// Secondary text. - static const muted = Color(0xFFA1A1AA); + static const muted = Color(0xFFD2CDD0); /// Weak hints and timestamps. - static const subtle = Color(0xFF71717A); + static const subtle = Color(0xFF9A96A3); + + /// Soft orange used for continue labels and secondary times. + static const accent = Color(0xFFFFA490); /// 8% white border used by cards and inputs. static const border = Color(0x14FFFFFF); @@ -65,7 +74,7 @@ abstract final class AppColors { static const glassBorder = Color(0x24FFFFFF); static const outlineDark = Color(0xFF2A2C36); - static const outlineVariantDark = Color(0xFF242630); + static const outlineVariantDark = Color(0xFF242329); static const outlineLight = Color(0xFF8E8E93); static const outlineVariantLight = Color(0xFFE4E4E7); } diff --git a/lib/core/theme/app_dimens.dart b/lib/core/theme/app_dimens.dart index ee86e8a..9bc0cb6 100644 --- a/lib/core/theme/app_dimens.dart +++ b/lib/core/theme/app_dimens.dart @@ -9,15 +9,17 @@ abstract final class AppDimens { static const double spaceXl = 24; static const double pageHorizontal = 16; - static const double cardRadius = 16; + static const double cardRadius = 12; static const double authCardRadius = 20; static const double cardPadding = 16; - static const double innerRadius = 12; + static const double innerRadius = 10; static const double innerPadding = 12; static const double pillRadius = 20; - static const double glassSmallRadius = 16; - static const double glassRadius = 24; - static const double glassCardRadius = 26; + static const double glassSmallRadius = 12; + static const double glassRadius = 14; + static const double glassCardRadius = 14; + static const double buttonRadius = 9; + static const double coverRadius = 10; static const double caption = 12; static const double emptyStateSubtitle = 12.5; @@ -27,17 +29,25 @@ abstract final class AppDimens { static const double sectionTitle = 16; static const double pageTitle = 20; static const double detailHeadline = 24; - static const double navLabel = 11; + static const double detailHeadlineSmall = 18; + static const double previewCardTitle = 13; + static const double previewSectionTitle = 14; + static const double chipFont = 10; + static const double navLabel = 9; static const double buttonHeight = 48; static const double primaryButtonHeight = 52; + static const double buttonCompact = 32; + static const double buttonSmall = 34; + static const double buttonMedium = 40; + static const double buttonLarge = 44; static const double tapTarget = 44; - static const double navHeight = 64; - static const double navIcon = 22; - static const double progressHeight = 4; - static const double progressRadius = 4; - static const double progressLineHeight = 1.5; + static const double navHeight = 54; + static const double navIcon = 17; + static const double progressHeight = 3; + static const double progressRadius = 2; + static const double progressLineHeight = 3; static const double searchHeight = 44; static const double navDot = 3; diff --git a/lib/core/widgets/app_bottom_nav.dart b/lib/core/widgets/app_bottom_nav.dart index ec7ce59..898d032 100644 --- a/lib/core/widgets/app_bottom_nav.dart +++ b/lib/core/widgets/app_bottom_nav.dart @@ -16,7 +16,6 @@ class AppBottomNavItem { required this.label, required this.onTap, this.selected = false, - this.indicatorKey, }); final Key tabKey; @@ -25,7 +24,6 @@ class AppBottomNavItem { final String label; final VoidCallback onTap; final bool selected; - final Key? indicatorKey; } /// Shared 3-tab navigation used by all root tabs and the player fallback. @@ -52,7 +50,9 @@ class AppBottomNav extends StatelessWidget { height: AppDimens.navHeight, decoration: BoxDecoration( color: backgroundColor.withValues(alpha: 0.95), - border: const Border(top: BorderSide(color: AppColors.border)), + border: const Border( + top: BorderSide(color: AppColors.outlineVariantDark), + ), ), child: Row( children: [for (final item in items) _NavDestination(item: item)], @@ -94,7 +94,7 @@ class _NavDestination extends StatelessWidget { : Colors.white.withValues(alpha: 0.45), ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( item.label, maxLines: 1, @@ -108,18 +108,6 @@ class _NavDestination extends StatelessWidget { letterSpacing: 0, ), ), - const SizedBox(height: 3), - AnimatedContainer( - key: item.indicatorKey, - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - width: AppDimens.navDot, - height: AppDimens.navDot, - decoration: BoxDecoration( - color: item.selected ? AppColors.blue : Colors.transparent, - shape: BoxShape.circle, - ), - ), ], ), ), diff --git a/lib/core/widgets/glass_panel.dart b/lib/core/widgets/glass_panel.dart index 6e340d4..02d144c 100644 --- a/lib/core/widgets/glass_panel.dart +++ b/lib/core/widgets/glass_panel.dart @@ -13,7 +13,7 @@ class GlassPanel extends StatelessWidget { super.key, required this.child, this.radius = AppDimens.glassRadius, - this.blur = AppDimens.glassBlur, + this.blur = 10, this.padding, this.borderColor = AppColors.glassBorder, this.gradient, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccf4bcf..671ee4f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,14 @@ "discoverTab": "Discover", "searchDiscover": "Search dramas, actors, genres...", "featured": "Featured", + "recommended": "Recommended", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Sign in to sync progress, favorites and coins", + "comingSoon": "Coming soon", + "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", + "authContinueGoogle": "Continue with Google", + "authPhoneSignIn": "Phone sign in", + "authEmailSignIn": "Email sign in", "suspense": "Suspense", "romance": "Romance", "actionGenre": "Action", @@ -31,6 +39,19 @@ "startWatching": "Start Watching", "showLess": "Less", "views": "views", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} min ago", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Artwork", "artworkOnlyNotice": "This is a visual catalog item. No video is available yet.", "videoPending": "Video pending", @@ -52,7 +73,7 @@ }, "resume": "Resume", "history": "History", - "watchlist": "Watchlist", + "watchlist": "Favorites", "likedTab": "Liked", "all": "All", "inProgress": "In Progress", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 22a79f2..c75f7c3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -12,9 +12,17 @@ "openQuickMenu": "Buka menu cepat", "quickAccess": "Akses cepat", "currentSeries": "Serial saat ini", - "discoverTab": "Jelajahi", + "discoverTab": "Temukan", "searchDiscover": "Cari drama, aktor, genre...", "featured": "Unggulan", + "recommended": "Rekomendasi", + "fruitShortsBrand": "FruitShorts", + "authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin", + "comingSoon": "Segera hadir", + "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", + "authContinueGoogle": "Lanjutkan dengan Google", + "authPhoneSignIn": "Masuk dengan ponsel", + "authEmailSignIn": "Masuk dengan email", "suspense": "Thriller", "romance": "Romantis", "actionGenre": "Aksi", @@ -31,6 +39,19 @@ "startWatching": "Mulai Menonton", "showLess": "Lebih sedikit", "views": "tayangan", + "episodeOfTotal": "Episode {current} / {total}", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} mnt lalu", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "Gambar", "artworkOnlyNotice": "Ini hanya gambar katalog. Videonya belum tersedia.", "videoPending": "Video belum siap", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee0214e..cf14047 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -190,6 +190,54 @@ abstract class AppLocalizations { /// **'Featured'** String get featured; + /// No description provided for @recommended. + /// + /// In en, this message translates to: + /// **'Recommended'** + String get recommended; + + /// No description provided for @fruitShortsBrand. + /// + /// In en, this message translates to: + /// **'FruitShorts'** + String get fruitShortsBrand; + + /// No description provided for @authLandingSubtitle. + /// + /// In en, this message translates to: + /// **'Sign in to sync progress, favorites and coins'** + String get authLandingSubtitle; + + /// No description provided for @comingSoon. + /// + /// In en, this message translates to: + /// **'Coming soon'** + String get comingSoon; + + /// No description provided for @authLegal. + /// + /// In en, this message translates to: + /// **'By continuing, you agree to the Terms of Service and Privacy Policy.'** + String get authLegal; + + /// No description provided for @authContinueGoogle. + /// + /// In en, this message translates to: + /// **'Continue with Google'** + String get authContinueGoogle; + + /// No description provided for @authPhoneSignIn. + /// + /// In en, this message translates to: + /// **'Phone sign in'** + String get authPhoneSignIn; + + /// No description provided for @authEmailSignIn. + /// + /// In en, this message translates to: + /// **'Email sign in'** + String get authEmailSignIn; + /// No description provided for @suspense. /// /// In en, this message translates to: @@ -286,6 +334,18 @@ abstract class AppLocalizations { /// **'views'** String get views; + /// No description provided for @episodeOfTotal. + /// + /// In en, this message translates to: + /// **'Episode {current} / {total}'** + String episodeOfTotal(int current, int total); + + /// No description provided for @minutesAgo. + /// + /// In en, this message translates to: + /// **'{count} min ago'** + String minutesAgo(int count); + /// No description provided for @artworkOnly. /// /// In en, this message translates to: @@ -373,7 +433,7 @@ abstract class AppLocalizations { /// No description provided for @watchlist. /// /// In en, this message translates to: - /// **'Watchlist'** + /// **'Favorites'** String get watchlist; /// No description provided for @likedTab. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d39d132..3a00090 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -54,6 +54,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get featured => 'Featured'; + @override + String get recommended => 'Recommended'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Sign in to sync progress, favorites and coins'; + + @override + String get comingSoon => 'Coming soon'; + + @override + String get authLegal => + 'By continuing, you agree to the Terms of Service and Privacy Policy.'; + + @override + String get authContinueGoogle => 'Continue with Google'; + + @override + String get authPhoneSignIn => 'Phone sign in'; + + @override + String get authEmailSignIn => 'Email sign in'; + @override String get suspense => 'Suspense'; @@ -102,6 +128,16 @@ class AppLocalizationsEn extends AppLocalizations { @override String get views => 'views'; + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String minutesAgo(int count) { + return '$count min ago'; + } + @override String get artworkOnly => 'Artwork'; @@ -149,7 +185,7 @@ class AppLocalizationsEn extends AppLocalizations { String get history => 'History'; @override - String get watchlist => 'Watchlist'; + String get watchlist => 'Favorites'; @override String get likedTab => 'Liked'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 06dd59f..5f2b421 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -46,7 +46,7 @@ class AppLocalizationsId extends AppLocalizations { String get currentSeries => 'Serial saat ini'; @override - String get discoverTab => 'Jelajahi'; + String get discoverTab => 'Temukan'; @override String get searchDiscover => 'Cari drama, aktor, genre...'; @@ -54,6 +54,32 @@ class AppLocalizationsId extends AppLocalizations { @override String get featured => 'Unggulan'; + @override + String get recommended => 'Rekomendasi'; + + @override + String get fruitShortsBrand => 'FruitShorts'; + + @override + String get authLandingSubtitle => + 'Masuk untuk menyinkronkan progres, favorit, dan koin'; + + @override + String get comingSoon => 'Segera hadir'; + + @override + String get authLegal => + 'Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.'; + + @override + String get authContinueGoogle => 'Lanjutkan dengan Google'; + + @override + String get authPhoneSignIn => 'Masuk dengan ponsel'; + + @override + String get authEmailSignIn => 'Masuk dengan email'; + @override String get suspense => 'Thriller'; @@ -102,6 +128,16 @@ class AppLocalizationsId extends AppLocalizations { @override String get views => 'tayangan'; + @override + String episodeOfTotal(int current, int total) { + return 'Episode $current / $total'; + } + + @override + String minutesAgo(int count) { + return '$count mnt lalu'; + } + @override String get artworkOnly => 'Gambar'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 8453aca..b3fb2b3 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -45,7 +45,7 @@ class AppLocalizationsZh extends AppLocalizations { String get currentSeries => '当前短剧'; @override - String get discoverTab => '找剧'; + String get discoverTab => '发现'; @override String get searchDiscover => '搜索短剧、演员、类型…'; @@ -53,6 +53,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get featured => '精选'; + @override + String get recommended => '推荐'; + + @override + String get fruitShortsBrand => '水果短剧'; + + @override + String get authLandingSubtitle => '登录后同步进度、收藏与金币权益'; + + @override + String get comingSoon => '即将开放'; + + @override + String get authLegal => '继续即代表同意《用户协议》与《隐私政策》'; + + @override + String get authContinueGoogle => '使用 Google 继续'; + + @override + String get authPhoneSignIn => '手机号登录'; + + @override + String get authEmailSignIn => '邮箱登录'; + @override String get suspense => '悬疑'; @@ -101,6 +125,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get views => '次播放'; + @override + String episodeOfTotal(int current, int total) { + return '第 $current 集 / 共 $total 集'; + } + + @override + String minutesAgo(int count) { + return '$count 分钟前'; + } + @override String get artworkOnly => '海报展示'; @@ -146,7 +180,7 @@ class AppLocalizationsZh extends AppLocalizations { String get history => '历史'; @override - String get watchlist => '追剧'; + String get watchlist => '收藏'; @override String get likedTab => '喜欢'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 374f17a..0ddd3bb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -12,9 +12,17 @@ "openQuickMenu": "打开快捷菜单", "quickAccess": "快捷入口", "currentSeries": "当前短剧", - "discoverTab": "找剧", + "discoverTab": "发现", "searchDiscover": "搜索短剧、演员、类型…", "featured": "精选", + "recommended": "推荐", + "fruitShortsBrand": "水果短剧", + "authLandingSubtitle": "登录后同步进度、收藏与金币权益", + "comingSoon": "即将开放", + "authLegal": "继续即代表同意《用户协议》与《隐私政策》", + "authContinueGoogle": "使用 Google 继续", + "authPhoneSignIn": "手机号登录", + "authEmailSignIn": "邮箱登录", "suspense": "悬疑", "romance": "都市爱情", "actionGenre": "动作", @@ -31,6 +39,19 @@ "startWatching": "开始观看", "showLess": "收起", "views": "次播放", + "episodeOfTotal": "第 {current} 集 / 共 {total} 集", + "@episodeOfTotal": { + "placeholders": { + "current": {"type": "int"}, + "total": {"type": "int"} + } + }, + "minutesAgo": "{count} 分钟前", + "@minutesAgo": { + "placeholders": { + "count": {"type": "int"} + } + }, "artworkOnly": "海报展示", "artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。", "videoPending": "视频待提供", @@ -52,7 +73,7 @@ }, "resume": "继续播放", "history": "历史", - "watchlist": "追剧", + "watchlist": "收藏", "likedTab": "喜欢", "all": "全部", "inProgress": "未看完", diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 069046b..2bdb917 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -245,11 +245,10 @@ class _HomeTabsState extends State<_HomeTabs> { items: [ AppBottomNavItem( tabKey: const Key('tab-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, selected: logic.tab == 0, - indicatorKey: const Key('tab-home-indicator'), onTap: () => _selectTab(context, 0), ), AppBottomNavItem( @@ -258,16 +257,14 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.explore_rounded, label: l10n.discoverTab, selected: logic.tab == 1, - indicatorKey: const Key('tab-discover-indicator'), onTap: () => _selectTab(context, 1), ), AppBottomNavItem( tabKey: const Key('tab-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, selected: logic.tab == 2, - indicatorKey: const Key('tab-my-indicator'), onTap: () => _selectTab(context, 2, myProfile: true), ), ], diff --git a/test/theme/app_dimens_test.dart b/test/theme/app_dimens_test.dart index 149b85e..f4402d4 100644 --- a/test/theme/app_dimens_test.dart +++ b/test/theme/app_dimens_test.dart @@ -6,18 +6,18 @@ import 'package:fruitshorts/core/theme/app_dimens.dart'; void main() { test('should_expose_the_k20_spacing_and_type_scale', () { expect(AppDimens.pageHorizontal, 16); - expect(AppDimens.cardRadius, 16); - expect(AppDimens.innerRadius, 12); + expect(AppDimens.cardRadius, 12); + expect(AppDimens.innerRadius, 10); expect(AppDimens.caption, 12); expect(AppDimens.body, 14); expect(AppDimens.listTitle, 15); expect(AppDimens.sectionTitle, 16); expect(AppDimens.pageTitle, 20); - expect(AppDimens.navLabel, 11); + expect(AppDimens.navLabel, 9); expect(AppDimens.buttonHeight, 48); expect(AppDimens.primaryButtonHeight, 52); expect(AppDimens.tapTarget, 44); - expect(AppDimens.navHeight, 64); - expect(AppDimens.progressHeight, 4); + expect(AppDimens.navHeight, 54); + expect(AppDimens.progressHeight, 3); }); } diff --git a/test/theme/app_theme_test.dart b/test/theme/app_theme_test.dart index ea9b91c..4a7eb85 100644 --- a/test/theme/app_theme_test.dart +++ b/test/theme/app_theme_test.dart @@ -21,8 +21,10 @@ void main() { expect(AppColors.secondary, const Color(0xFF9278EA)); expect(AppColors.gold, const Color(0xFFFBBF24)); expect(AppColors.ink, const Color(0xFF0B0C10)); - expect(AppColors.card, const Color(0xFF16181F)); - expect(AppColors.raised, const Color(0xFF1D1E28)); + expect(AppColors.card, const Color(0xFF1B1C21)); + expect(AppColors.cardAlt, const Color(0xFF1C1D21)); + expect(AppColors.raised, const Color(0xFF1C1D21)); + expect(AppColors.bar, const Color(0xFF0D0D11)); expect(AppColors.success, const Color(0xFF34D399)); expect(AppColors.onPrimary, const Color(0xFFFFFFFF)); expect( From 21d457fd98bc4f3f26c3dafc15be19c9743a1b95 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:11 +0800 Subject: [PATCH 07/30] =?UTF-8?q?feat(ui):=20=E6=A0=A1=E6=AD=A3=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=8F=91=E7=8E=B0=E8=AF=A6=E6=83=85=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E5=99=A8=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 361 +++---- .../discover/widgets/home_drama_grid.dart | 157 ++- lib/pages/drama_detail/drama_detail_view.dart | 909 +++++------------- lib/pages/player/player_logic.dart | 12 + lib/pages/player/player_view.dart | 517 ++++++---- .../player/widgets/home_feed_overlay.dart | 554 +++-------- .../widgets/player_featured_comment.dart | 158 +++ .../player/widgets/seek_progress_bar.dart | 2 +- test/discover/home_all_dramas_grid_test.dart | 32 +- test/drama_detail/drama_detail_view_test.dart | 47 +- test/home/home_preview_layout_test.dart | 64 ++ test/player/player_fullscreen_test.dart | 27 +- test/widget_test.dart | 62 +- 13 files changed, 1218 insertions(+), 1684 deletions(-) create mode 100644 lib/pages/player/widgets/player_featured_comment.dart create mode 100644 test/home/home_preview_layout_test.dart diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index ae88d85..c468231 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -13,7 +13,6 @@ import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import 'discover_binding.dart'; @@ -165,53 +164,41 @@ class _DiscoverPageState extends State { ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.trendingMicroDramas, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - l10n.trendingSubtitle, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - ), - ), - ], + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), ), ), TextButton( onPressed: () => - categories.selectCode(CategoryController.hotCode), + categories.selectCode(CategoryController.allCode), style: TextButton.styleFrom( - foregroundColor: AppColors.blue, + foregroundColor: AppColors.accent, minimumSize: const Size(44, 44), - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: EdgeInsets.zero, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.seeAll, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const Icon(Icons.chevron_right_rounded, size: 16), - ], + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), ), ), ], @@ -236,55 +223,41 @@ class _DiscoverPageState extends State { Widget _header(AppLocalizations l10n) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 12), + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), child: Row( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.appName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 21, - fontWeight: FontWeight.w700, - height: 1.05, - letterSpacing: 0, - ), - ), - ), - ], - ), - const SizedBox(height: 3), - Text( - l10n.verticalMiniSeries, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10, - fontWeight: FontWeight.w500, - letterSpacing: 0.6, - ), - ), - ], + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, ), ), - const SizedBox(width: 12), + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, + ), + const SizedBox(width: 7), Semantics( button: true, label: widget.signedIn ? l10n.myTab : l10n.logIn, @@ -293,20 +266,20 @@ class _DiscoverPageState extends State { customBorder: const CircleBorder(), onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, child: Container( - width: 38, - height: 38, + width: 28, + height: 28, decoration: BoxDecoration( shape: BoxShape.circle, - color: AppColors.raised, - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.7), - width: 2, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], ), ), child: const Icon( Icons.person_rounded, color: AppColors.onSurface, - size: 21, + size: 15, ), ), ), @@ -318,9 +291,9 @@ class _DiscoverPageState extends State { Widget _search() { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: SizedBox( - height: AppDimens.searchHeight, + height: 36, child: TextField( key: const Key('discover-search'), controller: logic.search, @@ -331,27 +304,22 @@ class _DiscoverPageState extends State { decoration: InputDecoration( isDense: true, filled: true, - fillColor: AppColors.card, + fillColor: AppColors.input, hintText: AppLocalizations.of(context)!.searchDiscover, - hintStyle: const TextStyle(color: AppColors.subtle, fontSize: 12.5), + hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11), prefixIcon: const Icon( Icons.search_rounded, color: AppColors.subtle, - size: 20, - ), - suffixIcon: const Icon( - Icons.mic_none_rounded, - color: AppColors.muted, - size: 19, + size: 17, ), contentPadding: const EdgeInsets.symmetric(horizontal: 12), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.border), + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - borderSide: const BorderSide(color: AppColors.blue, width: 1.2), + borderRadius: BorderRadius.circular(20), + borderSide: const BorderSide(color: AppColors.blue, width: 1), ), ), ), @@ -361,9 +329,7 @@ class _DiscoverPageState extends State { Widget _categories(AppLocalizations l10n, CategoryController categories) { final choices = <({String code, String label})>[ - (code: CategoryController.allCode, label: l10n.all), - (code: CategoryController.hotCode, label: l10n.hot), - (code: CategoryController.newCode, label: l10n.newest), + (code: CategoryController.allCode, label: l10n.recommended), for (final category in categories.categories) (code: 'category-${category.id}', label: category.name), ]; @@ -371,7 +337,7 @@ class _DiscoverPageState extends State { key: const Key('home-category-chips'), height: AppDimens.tapTarget, child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), scrollDirection: Axis.horizontal, itemCount: choices.length, separatorBuilder: (_, _) => const SizedBox(width: 8), @@ -390,12 +356,10 @@ class _DiscoverPageState extends State { alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 15), decoration: BoxDecoration( - color: selected ? AppColors.blue : AppColors.card, + color: selected ? AppColors.blue : const Color(0xFF29272B), borderRadius: BorderRadius.circular(999), border: Border.all( - color: selected - ? AppColors.blue - : Colors.white.withValues(alpha: 0.08), + color: selected ? AppColors.blue : Colors.transparent, ), ), child: Text( @@ -403,7 +367,7 @@ class _DiscoverPageState extends State { maxLines: 1, style: TextStyle( color: selected ? AppColors.onPrimary : AppColors.muted, - fontSize: 12, + fontSize: AppDimens.chipFont, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, height: 1.1, ), @@ -423,23 +387,20 @@ class _DiscoverPageState extends State { WatchProgress record, ) { final progress = record.fraction; - return GlassPanel( + return Container( key: const Key('discover-continue-watching'), - radius: AppDimens.glassSmallRadius, - blur: 18, - padding: const EdgeInsets.all(12), - gradient: const LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [Color(0x1FFFFFFF), Color(0x0AFFFFFF)], + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), ), child: Row( children: [ ClipRRect( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(10), child: SizedBox( - width: 64, - height: 88, + width: 52, + height: 66, child: RemoteImage( drama.posterUrl, fit: BoxFit.cover, @@ -447,57 +408,38 @@ class _DiscoverPageState extends State { ), ), ), - const SizedBox(width: 12), + const SizedBox(width: 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 5), - Flexible( - child: Text( - l10n.continueWatching, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blue, - fontSize: 9.5, - fontWeight: FontWeight.w700, - letterSpacing: 0.4, - ), - ), - ), - ], + Text( + l10n.continueWatching, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.accent, + fontSize: 8, + fontWeight: FontWeight.w800, + ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( drama.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, + fontSize: 12, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - _resumeMeta(drama, record), + _resumeShort(drama, record), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 10.5, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), const SizedBox(height: 7), ClipRRect( @@ -505,7 +447,7 @@ class _DiscoverPageState extends State { child: LinearProgressIndicator( value: progress <= 0 ? null : progress, minHeight: 3, - backgroundColor: Colors.white.withValues(alpha: 0.12), + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -525,29 +467,24 @@ class _DiscoverPageState extends State { style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - minimumSize: const Size(64, 34), - padding: const EdgeInsets.symmetric(horizontal: 10), - shape: const StadiumBorder(), + minimumSize: const Size(56, 28), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), textStyle: const TextStyle( - fontSize: 11, + fontSize: 10, fontWeight: FontWeight.w700, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.play), - const SizedBox(width: 2), - const Icon(Icons.play_arrow_rounded, size: 14), - ], - ), + child: Text(l10n.resume), ), ], ), ); } - String _resumeMeta(Drama drama, WatchProgress record) { + String _resumeShort(Drama drama, WatchProgress record) { final label = AppLocalizations.of(context)!; Episode? episode; for (final item in drama.episodes) { @@ -556,10 +493,8 @@ class _DiscoverPageState extends State { break; } } - final head = episode == null - ? '' - : '${label.episodeLabel(episode.number)} / ${drama.episodeCount} · '; - return '$head${_duration(record.position)} / ${_duration(record.duration)}'; + if (episode == null) return _duration(record.position); + return label.episodeOfTotal(episode.number, drama.episodeCount); } Widget _quickActions(AppLocalizations l10n, CategoryController categories) { @@ -574,7 +509,7 @@ class _DiscoverPageState extends State { onTap: logic.openSearch, ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-charts'), @@ -584,17 +519,17 @@ class _DiscoverPageState extends State { onTap: () => categories.selectCode(CategoryController.hotCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-new'), icon: Icons.auto_awesome_rounded, - label: l10n.newest, + label: l10n.newReleases, color: AppColors.gold, onTap: () => categories.selectCode(CategoryController.newCode), ), ), - const SizedBox(width: 8), + const SizedBox(width: 7), Expanded( child: _QuickAction( key: const Key('discover-quick-watchlist'), @@ -617,8 +552,8 @@ class _DiscoverPageState extends State { items: [ AppBottomNavItem( tabKey: const Key('discover-nav-home'), - icon: Icons.home_outlined, - selectedIcon: Icons.home_rounded, + icon: Icons.play_circle_outline_rounded, + selectedIcon: Icons.play_circle_fill_rounded, label: l10n.homeTab, onTap: () => logic.act( context, @@ -637,8 +572,8 @@ class _DiscoverPageState extends State { ), AppBottomNavItem( tabKey: const Key('discover-nav-my'), - icon: Icons.person_outline_rounded, - selectedIcon: Icons.person_rounded, + icon: Icons.video_library_outlined, + selectedIcon: Icons.video_library_rounded, label: l10n.myTab, onTap: () => logic.act( context, @@ -651,6 +586,37 @@ class _DiscoverPageState extends State { ); } +class _HeaderIcon extends StatelessWidget { + const _HeaderIcon({ + super.key, + required this.icon, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 14), + ), + ); + } +} + class _QuickAction extends StatelessWidget { const _QuickAction({ super.key, @@ -668,31 +634,20 @@ class _QuickAction extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( - height: 76, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppDimens.glassSmallRadius), - border: Border.all(color: AppColors.border), - ), + height: 62, + padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 17), - ), - const SizedBox(height: 5), + Icon(icon, color: color, size: 16), + const SizedBox(height: 4), Text( label, maxLines: 2, @@ -700,7 +655,7 @@ class _QuickAction extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.muted, - fontSize: 9.5, + fontSize: 9, fontWeight: FontWeight.w500, height: 1.05, ), diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 015e269..f2a1ee0 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -37,27 +37,22 @@ class _HomeDramaGrid extends StatelessWidget { ), ); } - if (dramas.isEmpty) { - final missingCategory = categories.selectedSectionMissing; return _fill( EmptyState( key: const Key('home-all-dramas-empty'), icon: searching ? Icons.search_off_rounded - : missingCategory - ? Icons.grid_view_rounded : Icons.movie_filter_outlined, title: searching ? l10n.noSearchResults - : missingCategory + : categories.selectedSectionMissing ? l10n.noCategoryDramas : l10n.noSearchResults, subtitle: l10n.emptyHint, ), ); } - return SliverMainAxisGroup( slivers: [ _dramaGrid(dramas), @@ -68,14 +63,14 @@ class _HomeDramaGrid extends StatelessWidget { Widget _dramaGrid(List items) => SliverPadding( key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: AppDimens.pageHorizontal), + padding: const EdgeInsets.symmetric(horizontal: 14), sliver: SliverGrid( key: const Key('home-all-dramas-grid'), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - mainAxisSpacing: 14, - crossAxisSpacing: 12, - childAspectRatio: 0.55, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: 164, ), delegate: SliverChildBuilderDelegate( (context, index) => _HomeDramaCard( @@ -88,9 +83,8 @@ class _HomeDramaGrid extends StatelessWidget { ), ); - Widget _fill(Widget child) { - return SliverFillRemaining(hasScrollBody: false, child: child); - } + Widget _fill(Widget child) => + SliverFillRemaining(hasScrollBody: false, child: child); } class _HomeDramaCard extends StatelessWidget { @@ -107,18 +101,21 @@ class _HomeDramaCard extends StatelessWidget { @override Widget build(BuildContext context) { final genre = drama.genre.isEmpty ? drama.category : drama.genre; + final free = + drama.freeEpisodes > 0 || + drama.episodes.any((episode) => episode.isFree); return InkWell( key: Key('home-drama-card-${drama.id}'), onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( + SizedBox( key: Key('home-drama-cover-${drama.id}'), - aspectRatio: 3 / 4.2, + height: 120, child: ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(10), child: Stack( fit: StackFit.expand, children: [ @@ -132,120 +129,76 @@ class _HomeDramaCard extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0x33000000), - Color(0x00000000), - Color(0xB3000000), - ], - stops: [0, 0.42, 1], + colors: [Color(0x0D000000), Color(0xCC000000)], + stops: [0.45, 1], ), ), ), - Positioned( - top: 8, - left: 8, - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - if (drama.isHot) _badge(l10n.hot, AppColors.blue), - if (drama.freeEpisodes > 0) - _badge(l10n.free, AppColors.success), - ], - ), - ), - if (drama.viewCount > 0) + if (free) Positioned( - right: 8, - bottom: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.42), - borderRadius: BorderRadius.circular(7), + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - child: Text( - _compactCount(drama.viewCount), - key: Key('home-drama-views-${drama.id}'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ), ), + Positioned( + left: 6, + right: 6, + bottom: 6, + child: Text( + drama.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 9, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ), ], ), ), ), - const SizedBox(height: 8), + const SizedBox(height: 5), Text( drama.title, key: Key('home-drama-title-${drama.id}'), - maxLines: 2, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w600, - height: 1.2, + fontSize: 11, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Text( genre.isEmpty ? l10n.videoPending : genre, key: Key('home-drama-genre-${drama.id}'), maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 11, - fontWeight: FontWeight.w400, - height: 1.2, - ), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ], ), ); } - - Widget _badge(String label, Color color) { - return DecoratedBox( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(999), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), - child: Text( - label, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - height: 1.1, - ), - ), - ), - ); - } -} - -String _compactCount(int value) { - if (value >= 1000000) { - final scaled = value / 1000000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; - } - if (value >= 1000) { - final scaled = value / 1000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; - } - return '$value'; } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index f358240..fc8d774 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -21,14 +21,16 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/glass_panel.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; import '../interaction/widgets/report_dialog.dart'; import 'drama_detail_binding.dart'; import 'drama_detail_logic.dart'; -const String _reportMenuValue = 'report'; +const String _menuFavorite = 'favorite'; +const String _menuDownload = 'download'; +const String _menuShare = 'share'; +const String _menuReport = 'report'; class DramaDetailPage extends StatefulWidget { const DramaDetailPage({super.key, required this.drama}); @@ -41,7 +43,6 @@ class DramaDetailPage extends StatefulWidget { class _DramaDetailPageState extends State { late final DramaDetailLogic logic; - bool _synopsisExpanded = false; @override void initState() { @@ -93,13 +94,8 @@ class _DramaDetailPageState extends State { key: const Key('detail-scroll'), slivers: [ SliverToBoxAdapter(child: _hero(context, l10n, drama)), - SliverToBoxAdapter(child: _metadata(l10n, drama)), - SliverToBoxAdapter(child: _synopsis(l10n, drama)), - SliverToBoxAdapter(child: _primaryActions(context, l10n)), - SliverToBoxAdapter(child: _episodeHeader(l10n, drama)), - SliverToBoxAdapter(child: _episodeChips(drama)), - _episodeGrid(context, l10n, drama), - const SliverToBoxAdapter(child: SizedBox(height: 32)), + SliverToBoxAdapter(child: _content(context, l10n, drama)), + const SliverToBoxAdapter(child: SizedBox(height: 28)), ], ), ); @@ -159,314 +155,115 @@ class _DramaDetailPageState extends State { ); Widget _hero(BuildContext context, AppLocalizations l10n, Drama drama) { - return Stack( - children: [ - AspectRatio( - key: const Key('detail-hero'), - aspectRatio: 16 / 9, - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0x260B0C10), - Color(0x000B0C10), - AppColors.ink, - ], - stops: [0, 0.5, 1], - ), - ), - ), - ], + return SizedBox( + key: const Key('detail-hero'), + height: 190, + child: Stack( + fit: StackFit.expand, + children: [ + RemoteImage( + drama.posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, ), - ), - Positioned( - top: MediaQuery.paddingOf(context).top + 8, - left: 16, - right: 16, - child: Row( - children: [ - _GlassCircleButton( - key: const Key('detail-back'), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - icon: Icons.chevron_left_rounded, - onPressed: () => Navigator.of(context).maybePop(), + const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x33000000), Color(0xCC0B0C10)], ), - const Spacer(), - _GlassCircleButton( - key: const Key('detail-share'), - tooltip: l10n.share, - icon: Icons.share_outlined, - onPressed: () => unawaited(_shareDrama(l10n, drama)), - ), - const SizedBox(width: 8), - _favoriteButton( - context, - l10n, - key: const Key('detail-favorite-top'), - ), - const SizedBox(width: 8), - _reportAction(context, l10n), - ], + ), ), - ), - ], + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + left: 14, + child: _RoundAction( + key: const Key('detail-back'), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: Icons.chevron_left_rounded, + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 14, + right: 14, + child: _moreMenu(context, l10n), + ), + ], + ), ); } - Widget _metadata(AppLocalizations l10n, Drama drama) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ]; + Widget _content(BuildContext context, AppLocalizations l10n, Drama drama) { + final continueIndex = logic.continueIndex; return Padding( - padding: const EdgeInsets.fromLTRB(20, 2, 20, 0), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( + Text( + l10n.episodeCount(drama.episodeCount), key: const Key('detail-episode-count'), - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - decoration: BoxDecoration( - color: AppColors.secondary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: AppColors.secondary.withValues(alpha: 0.3), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 6, - height: 6, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.secondary, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 5), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.secondary, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 0.5, - ), - ), - ], + style: const TextStyle( + color: AppColors.secondary, + fontSize: 9, + fontWeight: FontWeight.w800, + letterSpacing: 1, ), ), - const SizedBox(height: 10), + const SizedBox(height: 7), Text( localizedDramaTitle(l10n, drama), key: const Key('detail-title'), style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 24, - fontWeight: FontWeight.w700, - height: 1.15, - letterSpacing: 0, - ), - ), - const SizedBox(height: 5), - Text( - l10n.seasonOneOriginalRelease, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - fontWeight: FontWeight.w500, + fontSize: 18, + fontWeight: FontWeight.w800, + height: 1.2, ), ), const SizedBox(height: 10), - Wrap( - spacing: 7, - runSpacing: 7, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (drama.viewCount > 0) - _metaPill( - icon: Icons.local_fire_department_rounded, - label: '${_compactCount(drama.viewCount)} ${l10n.views}', - color: AppColors.blue, - ), - for (final genre in genres) - _metaPill(label: genre, color: AppColors.cream), - ], - ), - ], - ), - ); - } - - Widget _metaPill({ - IconData? icon, - required String label, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(7), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon(icon, size: 13, color: color), - const SizedBox(width: 4), - ], Text( - label, - style: TextStyle( - color: color, + localizedDramaSynopsis(l10n, drama), + key: const Key('detail-synopsis'), + style: const TextStyle( + color: AppColors.muted, fontSize: 11, - fontWeight: FontWeight.w500, + height: 1.6, ), ), - ], - ), - ); - } - - Widget _synopsis(AppLocalizations l10n, Drama drama) { - final text = localizedDramaSynopsis(l10n, drama); - return Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: GlassPanel( - key: const Key('detail-synopsis-card'), - radius: AppDimens.glassSmallRadius, - blur: 12, - padding: const EdgeInsets.fromLTRB(13, 11, 13, 9), - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0x1A12141A), Color(0x3312141A)], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - text, - key: const Key('detail-synopsis'), - maxLines: _synopsisExpanded ? 100 : 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, - fontSize: 13, - height: 1.45, + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 40, + child: FilledButton.icon( + key: const Key('detail-cta'), + onPressed: () => logic.openPlayer(context, continueIndex ?? 0), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDimens.buttonRadius), + ), + textStyle: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.play_arrow_rounded, size: 18), + label: Text( + continueIndex == null + ? l10n.startWatching + : l10n.continueEpisode(continueIndex + 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - Align( - alignment: Alignment.centerRight, - child: TextButton( - key: const Key('detail-synopsis-toggle'), - onPressed: () => - setState(() => _synopsisExpanded = !_synopsisExpanded), - style: TextButton.styleFrom( - foregroundColor: AppColors.secondary, - minimumSize: const Size(44, 32), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(_synopsisExpanded ? l10n.showLess : l10n.more), - Icon( - _synopsisExpanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - size: 16, - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _primaryActions(BuildContext context, AppLocalizations l10n) { - final continueIndex = logic.continueIndex; - return Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: SizedBox( - height: 52, - child: FilledButton.icon( - key: const Key('detail-cta'), - onPressed: () => - logic.openPlayer(context, continueIndex ?? 0), - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - shape: const StadiumBorder(), - textStyle: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - ), - ), - icon: const Icon(Icons.play_arrow_rounded, size: 20), - label: Text( - continueIndex == null - ? l10n.startWatching - : l10n.continueEpisode(continueIndex + 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - const SizedBox(width: 10), - _squareAction( - key: const Key('detail-favorite'), - tooltip: l10n.favorite, - icon: - context.watch().isSaved(logic.drama.id) - ? Icons.bookmark_rounded - : Icons.bookmark_border_rounded, - selected: context.watch().isSaved( - logic.drama.id, - ), - onPressed: () => _toggleFavorite(l10n), - ), - const SizedBox(width: 10), - _squareAction( - key: const Key('detail-download'), - tooltip: l10n.downloads, - icon: Icons.download_outlined, - onPressed: _downloadCurrent, - ), - ], ), if (_continueFraction != null) ...[ - const SizedBox(height: 9), + const SizedBox(height: 8), _ResumeBar( fraction: _continueFraction!, label: l10n.resumeProgress( @@ -484,7 +281,7 @@ class _DramaDetailPageState extends State { errorText(l10n, logic.progressError, l10n.progressError), style: const TextStyle( color: AppColors.muted, - fontSize: 12, + fontSize: 11, ), ), ), @@ -495,393 +292,159 @@ class _DramaDetailPageState extends State { ], ), ], - ], - ), - ); - } - - Widget _episodeHeader(AppLocalizations l10n, Drama drama) { - return Padding( - padding: const EdgeInsets.fromLTRB(20, 22, 20, 10), - child: Row( - children: [ + const SizedBox(height: 18), Text( l10n.episodes, style: const TextStyle( fontFamily: 'Outfit', color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, + fontSize: 14, + fontWeight: FontWeight.w800, ), ), - const SizedBox(width: 5), - Text( - '(${drama.episodeCount})', - style: const TextStyle( - color: AppColors.subtle, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - Container( - key: const Key('detail-episodes-range'), - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 5), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - l10n.all, - style: const TextStyle( - color: AppColors.muted, - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(width: 3), - const Icon( - Icons.keyboard_arrow_down_rounded, - size: 15, - color: AppColors.muted, - ), - ], - ), + const SizedBox(height: 8), + Wrap( + key: const Key('detail-episode-chips'), + spacing: 6, + runSpacing: 6, + children: [ + for (var index = 0; index < drama.episodes.length; index++) + _episodeChip(context, drama, index, continueIndex), + ], ), ], ), ); } - Widget _episodeChips(Drama drama) { - final selected = logic.continueIndex ?? 0; - return SizedBox( - key: const Key('detail-episode-chips'), - height: 44, - child: ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 20), - scrollDirection: Axis.horizontal, - itemCount: drama.episodes.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final episode = drama.episodes[index]; - final active = index == selected; - final locked = episode.locked || (!episode.isFree && index > 0); - return InkWell( - key: Key('detail-episode-chip-${episode.number}'), - onTap: () => logic.openPlayer(context, index), - borderRadius: BorderRadius.circular(12), - child: Container( - width: 46, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - color: active - ? AppColors.blue - : locked - ? AppColors.secondary.withValues(alpha: 0.16) - : AppColors.card, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: active - ? AppColors.blue - : locked - ? AppColors.secondary.withValues(alpha: 0.35) - : AppColors.border, - ), - ), - child: Text( - '${episode.number}', - style: TextStyle( - color: active - ? AppColors.onPrimary - : locked - ? AppColors.secondary - : AppColors.muted, - fontSize: 13, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - }, - ), - ); - } - - Widget _episodeGrid( + Widget _episodeChip( BuildContext context, - AppLocalizations l10n, Drama drama, - ) { - return SliverPadding( - key: const Key('detail-episode-grid'), - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 108, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => - _episodeCard(context, l10n, drama, drama.episodes[index], index), - childCount: drama.episodes.length, - ), - ), - ); - } - - Widget _episodeCard( - BuildContext context, - AppLocalizations l10n, - Drama drama, - Episode episode, int index, + int? selectedIndex, ) { + final episode = drama.episodes[index]; + final selected = index == (selectedIndex ?? 0); final locked = episode.locked || (!episode.isFree && index > 0); - return Material( - key: Key('detail-episode-card-${episode.number}'), - color: AppColors.card, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => logic.openPlayer(context, index), - child: LayoutBuilder( - builder: (context, constraints) { - final thumbWidth = (constraints.maxWidth - 70).clamp(58.0, 96.0); - return Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - SizedBox( - key: Key('detail-episode-thumb-${episode.number}'), - width: thumbWidth, - height: 64, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration(color: Color(0x33000000)), - ), - Center( - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: locked - ? Colors.black54 - : AppColors.blue.withValues(alpha: 0.9), - shape: BoxShape.circle, - ), - child: Icon( - locked - ? Icons.lock_rounded - : Icons.play_arrow_rounded, - size: 14, - color: AppColors.onPrimary, - ), - ), - ), - if (episode.durationSec > 0) - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.68), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _clock( - Duration(seconds: episode.durationSec), - ), - style: const TextStyle( - color: AppColors.cream, - fontSize: 8, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Wrap( - spacing: 4, - runSpacing: 2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _episodeBadge( - episode.isFree && !locked - ? l10n.free - : l10n.coins, - locked ? AppColors.secondary : AppColors.success, - ), - Text( - l10n.episodeLabel(episode.number), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 9.5, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - localizedEpisodeTitle(l10n, episode.number), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: locked - ? AppColors.muted - : AppColors.onSurface, - fontSize: 11.5, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 3), - GestureDetector( - onTap: () => - requestEpisodeDownload(context, episode.id), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.download_outlined, - size: 12, - color: AppColors.subtle, - ), - const SizedBox(width: 3), - Flexible( - child: Text( - l10n.downloads, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.subtle, - fontSize: 9, - ), - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - }, + return InkWell( + key: Key('detail-episode-chip-${episode.number}'), + onTap: () => logic.openPlayer(context, index), + borderRadius: BorderRadius.circular(20), + child: Container( + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? AppColors.blue + : locked + ? AppColors.secondary.withValues(alpha: 0.16) + : const Color(0xFF29272B), + borderRadius: BorderRadius.circular(20), ), - ), - ); - } - - Widget _episodeBadge(String label, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: color.withValues(alpha: 0.28)), - ), - child: Text( - label, - style: TextStyle( - color: color, - fontSize: 8.5, - fontWeight: FontWeight.w700, - ), - ), - ); - } - - Widget _squareAction({ - required Key key, - required String tooltip, - required IconData icon, - required VoidCallback onPressed, - bool selected = false, - }) { - return SizedBox( - key: key, - width: 48, - height: 48, - child: IconButton( - tooltip: tooltip, - onPressed: onPressed, - style: IconButton.styleFrom( - backgroundColor: selected ? AppColors.raised : AppColors.card, - foregroundColor: selected ? AppColors.blue : AppColors.cream, - side: BorderSide( - color: selected ? AppColors.blue : AppColors.outlineDark, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + child: Text( + '${episode.number}', + style: TextStyle( + color: selected + ? AppColors.onPrimary + : locked + ? AppColors.secondary + : AppColors.muted, + fontSize: 11, + fontWeight: FontWeight.w700, ), ), - icon: Icon(icon, size: 21), ), ); } - Widget _favoriteButton( - BuildContext context, - AppLocalizations l10n, { - required Key key, - }) { + Widget _moreMenu(BuildContext context, AppLocalizations l10n) { final saved = context.watch().isSaved(logic.drama.id); - return _GlassCircleButton( - key: key, - tooltip: l10n.favorite, - icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - selected: saved, - onPressed: () => _toggleFavorite(l10n), - ); - } - - Widget _reportAction(BuildContext context, AppLocalizations l10n) { return PopupMenuButton( key: const Key('detail-more'), tooltip: l10n.more, color: AppColors.card, - onSelected: (_) => unawaited(_reportDrama(l10n)), + onSelected: (value) { + switch (value) { + case _menuFavorite: + unawaited(_toggleFavorite(l10n)); + case _menuDownload: + _downloadCurrent(); + case _menuShare: + unawaited(_shareDrama(l10n, logic.drama)); + case _menuReport: + unawaited(_reportDrama(l10n)); + } + }, itemBuilder: (_) => [ - PopupMenuItem(value: _reportMenuValue, child: Text(l10n.reportDrama)), + PopupMenuItem( + key: const Key('detail-favorite'), + value: _menuFavorite, + child: Row( + children: [ + Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.favorite), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-download'), + value: _menuDownload, + child: Row( + children: [ + const Icon( + Icons.download_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.downloads), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-share'), + value: _menuShare, + child: Row( + children: [ + const Icon( + Icons.share_outlined, + color: AppColors.muted, + size: 18, + ), + const SizedBox(width: 9), + Text(l10n.share), + ], + ), + ), + PopupMenuItem( + key: const Key('detail-report'), + value: _menuReport, + child: Row( + children: [ + const Icon(Icons.flag_outlined, color: AppColors.muted, size: 18), + const SizedBox(width: 9), + Text(l10n.reportDrama), + ], + ), + ), ], - child: const _GlassCircle( - child: Icon( + child: Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: const Icon( Icons.more_horiz_rounded, + size: 18, color: AppColors.onSurface, - size: 20, ), ), ); @@ -958,52 +521,37 @@ class _DramaDetailPageState extends State { } } -class _GlassCircleButton extends StatelessWidget { - const _GlassCircleButton({ +class _RoundAction extends StatelessWidget { + const _RoundAction({ super.key, required this.tooltip, required this.icon, required this.onPressed, - this.selected = false, }); final String tooltip; final IconData icon; - final VoidCallback onPressed; - final bool selected; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { - return _GlassCircle( + return Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), child: IconButton( - tooltip: tooltip, + tooltip: tooltip.isEmpty ? null : tooltip, onPressed: onPressed, padding: EdgeInsets.zero, - icon: Icon( - icon, - size: 20, - color: selected ? AppColors.blue : AppColors.onSurface, - ), + icon: Icon(icon, size: 18, color: AppColors.onSurface), ), ); } } -class _GlassCircle extends StatelessWidget { - const _GlassCircle({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: 40, - height: 40, - child: GlassPanel(radius: 999, blur: 14, child: child), - ); - } -} - class _ResumeBar extends StatelessWidget { const _ResumeBar({required this.fraction, this.label}); @@ -1016,11 +564,11 @@ class _ResumeBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.progressRadius), + borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator( value: fraction, - minHeight: AppDimens.progressLineHeight, - backgroundColor: Colors.white24, + minHeight: 3, + backgroundColor: const Color(0xFF474248), valueColor: const AlwaysStoppedAnimation(AppColors.blue), ), ), @@ -1028,25 +576,10 @@ class _ResumeBar extends StatelessWidget { const SizedBox(height: 4), Text( label!, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - ), + style: const TextStyle(color: AppColors.muted, fontSize: 10), ), ], ], ); } } - -String _compactCount(int value) { - if (value >= 1000000) { - final scaled = value / 1000000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}M'; - } - if (value >= 1000) { - final scaled = value / 1000; - return '${scaled.toStringAsFixed(scaled >= 10 ? 0 : 1)}K'; - } - return '$value'; -} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index aa4e50b..64c459e 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -531,6 +531,18 @@ class PlayerLogic extends ChangeNotifier { : null; } + bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null; + + Future playNextEpisode() async { + final next = nextVisibleIndex(session.currentIndex); + if (next == null || !pages.hasClients) return; + await pages.animateToPage( + visibleIndices.indexOf(next), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + void beginScrub(VideoPlayerPort player) { if (session.active != player) return; scrubbingPlayer = player; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a195c69..63a1b63 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -18,13 +18,13 @@ import '../../core/formatting/localized_content.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; -import '../../core/widgets/app_bottom_nav.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; @@ -340,7 +340,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { l10n: l10n, drama: drama, episode: episode, - controller: ready ? videoPort!.controller : null, liked: interactions.isLiked(drama.id), saved: context.watch().isSaved(drama.id), onLike: () async { @@ -381,10 +380,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, ) else if (!logic.screenCleared) _PlaybackOverlay( @@ -398,9 +393,13 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onBack: widget.embedded ? null : () => unawaited(logic.leave(context)), - onDiscover: () => logic.showDiscover(context), onEpisodes: () => logic.showEpisodes(context), - onMy: () => logic.showMy(context), + onNext: logic.hasNextEpisode + ? () => unawaited(logic.playNextEpisode()) + : null, + playing: logic.session.userWantsPlay, + onTogglePlay: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { @@ -434,7 +433,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onDownload: () => requestEpisodeDownload(context, episode.id), onComments: () => logic.showComments(context, drama), onShare: () => logic.shareDrama(context, drama), - onClear: () => logic.setScreenCleared(true), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, @@ -442,7 +440,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSeek: ready ? (position) => logic.seekTo(context, videoPort!, position) : null, - showBottomNav: false, ), if (logic.screenCleared) SafeArea( @@ -649,20 +646,19 @@ class _PlaybackOverlay extends StatelessWidget { required this.controller, required this.liked, required this.saved, + required this.playing, + required this.onTogglePlay, required this.onBack, - required this.onDiscover, required this.onEpisodes, - required this.onMy, + required this.onComments, required this.onLike, required this.onSave, required this.onDownload, - required this.onComments, required this.onShare, - required this.onClear, required this.onPip, required this.onScrubStart, required this.onSeek, - required this.showBottomNav, + this.onNext, }); final AppLocalizations l10n; @@ -671,230 +667,333 @@ class _PlaybackOverlay extends StatelessWidget { final VideoPlayerController? controller; final bool liked; final bool saved; + final bool playing; + final VoidCallback onTogglePlay; final VoidCallback? onBack; - final VoidCallback onDiscover; final VoidCallback onEpisodes; - final VoidCallback onMy; + final VoidCallback onComments; + final VoidCallback? onNext; final VoidCallback onLike; final VoidCallback onSave; final VoidCallback onDownload; - final VoidCallback onComments; final VoidCallback onShare; - final VoidCallback onClear; final VoidCallback? onPip; final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; - final bool showBottomNav; @override - Widget build(BuildContext context) => SafeArea( - bottom: showBottomNav, - child: Stack( - children: [ - Column( + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + _topBar(context), + Expanded( + child: Stack( + children: [ + Center( + child: GestureDetector( + key: const Key('player-play-toggle'), + onTap: onTogglePlay, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ), + const Positioned( + right: 10, + bottom: 14, + child: Text( + '1.0x', + style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: Column( + children: [ + SeekProgressBar( + valueListenable: controller, + semanticsLabel: l10n.seekVideo, + showTimes: false, + onScrubStart: onScrubStart, + onSeek: onSeek, + ), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: l10n.episodes, + onTap: onEpisodes, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: l10n.comments, + onTap: onComments, + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: onNext, + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.skip_next_rounded, size: 14), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + PlayerFeaturedComment(drama: drama, onViewAll: onComments), + ], + ), + ), + ], + ), + ); + } + + Widget _topBar(BuildContext context) { + return SizedBox( + height: 46, + child: Row( + children: [ + if (onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: onBack!, + ) + else + const SizedBox(width: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(l10n, drama)} · ' + '${l10n.episodeLabel(episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), + ); + } + + Future _openMore(BuildContext context) async { + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 8, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _info()), - _rail(), - ], + ListTile( + key: const Key('player-more-download'), + leading: const Icon( + Icons.download_outlined, + color: AppColors.secondary, ), + title: Text(l10n.downloads), + onTap: () { + Navigator.pop(sheetContext); + onDownload(); + }, ), - const SizedBox(height: 4), - Padding( - padding: EdgeInsets.fromLTRB( - 16, - 0, - 16, - 20 + MediaQuery.paddingOf(context).bottom, + if (onPip != null) + ListTile( + key: const Key('player-more-pip'), + leading: const Icon( + Icons.picture_in_picture_alt_outlined, + color: AppColors.secondary, + ), + title: Text(l10n.pictureInPicture), + onTap: () { + Navigator.pop(sheetContext); + onPip!(); + }, ), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, + ListTile( + key: const Key('player-more-like'), + leading: Icon( + liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, + color: liked ? AppColors.blue : AppColors.muted, ), + title: Text(l10n.like), + onTap: () { + Navigator.pop(sheetContext); + onLike(); + }, + ), + ListTile( + key: const Key('player-more-favorite'), + leading: Icon( + saved ? Icons.star_rounded : Icons.star_border_rounded, + color: saved ? AppColors.blue : AppColors.muted, + ), + title: Text(l10n.favorite), + onTap: () { + Navigator.pop(sheetContext); + onSave(); + }, + ), + ListTile( + key: const Key('player-more-share'), + leading: const Icon(Icons.share_outlined, color: AppColors.muted), + title: Text(l10n.share), + onTap: () { + Navigator.pop(sheetContext); + onShare(); + }, ), - if (showBottomNav) _fallbackNav(), ], ), - if (onBack != null) - Positioned( - top: 4, - left: 8, - child: IconButton( - tooltip: l10n.backToSeries, - onPressed: onBack, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.arrow_back_rounded), - ), - ), - Positioned( - top: 4, - right: 8, - child: IconButton( - tooltip: l10n.clearScreen, - onPressed: onClear, - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.fit_screen_outlined), - ), - ), - ], - ), - ); - - Widget _info() => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w800, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), ), - const SizedBox(height: 6), - Text( - '${l10n.episodeLabel(episode.number)} · ${l10n.nowPlaying}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ); - - Widget _rail() => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _RailAction( - key: const Key('rail-like'), - icon: liked ? Icons.favorite : Icons.favorite_border, - label: l10n.like, - selected: liked, - onTap: onLike, - ), - _RailAction( - key: const Key('rail-save'), - icon: saved ? Icons.bookmark : Icons.bookmark_border, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - key: const Key('rail-download'), - icon: Icons.download_outlined, - label: l10n.downloads, - onTap: onDownload, - ), - _RailAction( - icon: Icons.grid_view_outlined, - label: l10n.episodes, - onTap: onEpisodes, - ), - _RailAction( - key: const Key('rail-comments'), - icon: Icons.chat_bubble_outline, - label: l10n.comments, - onTap: onComments, - ), - _RailAction( - key: const Key('rail-share'), - icon: Icons.share_outlined, - label: l10n.share, - onTap: onShare, - ), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: AppColors.cream, - icon: const Icon( - Icons.picture_in_picture_alt_outlined, - size: _railIconSize, - ), - ), - ], - ); - - Widget _fallbackNav() => AppBottomNav( - items: [ - AppBottomNavItem( - tabKey: const Key('player-nav-home'), - icon: Icons.play_circle_outline_rounded, - selectedIcon: Icons.play_circle_fill_rounded, - label: l10n.homeTab, - selected: true, - onTap: () {}, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-discover'), - icon: Icons.explore_outlined, - selectedIcon: Icons.explore_rounded, - label: l10n.discoverTab, - onTap: onDiscover, - ), - AppBottomNavItem( - tabKey: const Key('player-nav-my'), - icon: Icons.video_library_outlined, - selectedIcon: Icons.video_library_rounded, - label: l10n.myTab, - onTap: onMy, - ), - ], - ); + ); + } } -const double _railIconSize = 28; -const double _railIconWeight = 300; -const double _railTapSize = 44; +class _TopAction extends StatelessWidget { + const _TopAction({ + super.key, + required this.tooltip, + required this.icon, + required this.onPressed, + }); -class _RailAction extends StatelessWidget { - const _RailAction({ + final String tooltip; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 15), + ), + ); + } +} + +class _PlayerAction extends StatelessWidget { + const _PlayerAction({ super.key, required this.icon, required this.label, required this.onTap, - this.selected = false, }); + final IconData icon; final String label; - final VoidCallback? onTap; - final bool selected; + final VoidCallback onTap; @override - Widget build(BuildContext context) => IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - constraints: const BoxConstraints.tightFor( - width: _railTapSize, - height: _railTapSize, - ), - color: selected ? AppColors.blueSoft : AppColors.cream, - icon: Icon(icon, size: _railIconSize, weight: _railIconWeight, fill: 0), - ); + Widget build(BuildContext context) { + return Material( + color: const Color(0xFF2A2A30), + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 32, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 14, color: AppColors.secondary), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e631877..1e0de71 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -3,27 +3,21 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/glass_panel.dart'; -import '../../../core/widgets/remote_image.dart'; import '../../../l10n/app_localizations.dart'; -import 'seek_progress_bar.dart'; -/// Immersive home layer from `screens/01-home.html`. -/// -/// Playback, ticket, progress, favorite, like, comment, and share callbacks are -/// all owned by [PlayerPage]; this widget only paints the design overlay. +/// Home feed layer from `docs/移动端设计预览.html` screen 01. class HomeFeedOverlay extends StatelessWidget { const HomeFeedOverlay({ super.key, required this.l10n, required this.drama, required this.episode, - required this.controller, required this.liked, required this.saved, required this.onLike, @@ -34,15 +28,12 @@ class HomeFeedOverlay extends StatelessWidget { required this.onDownload, required this.onClear, required this.onSearch, - required this.onScrubStart, - required this.onSeek, this.onPip, }); final AppLocalizations l10n; final Drama drama; final Episode episode; - final VideoPlayerController? controller; final bool liked; final bool saved; final VoidCallback onLike; @@ -54,12 +45,9 @@ class HomeFeedOverlay extends StatelessWidget { final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; @override Widget build(BuildContext context) { - final compact = MediaQuery.sizeOf(context).height < 620; return SafeArea( bottom: false, child: Column( @@ -67,16 +55,16 @@ class HomeFeedOverlay extends StatelessWidget { _header(context), Expanded( child: Align( - alignment: Alignment.bottomRight, + alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 12, 4), - child: _rail(compact), + padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), + child: _rail(), ), ), ), Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: _infoCard(context, compact), + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + child: _infoCard(), ), ], ), @@ -85,79 +73,35 @@ class HomeFeedOverlay extends StatelessWidget { Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 12, 4), + padding: const EdgeInsets.fromLTRB(14, 6, 10, 4), child: Row( - crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 7), - Flexible( - child: Text( - localizedDramaTitle(l10n, drama), - key: const Key('home-feed-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 17, - fontWeight: FontWeight.w700, - height: 1.1, - shadows: [ - Shadow(blurRadius: 10, color: Colors.black87), - ], - ), - ), - ), - if (drama.isHot) ...[const SizedBox(width: 6), _hotBadge()], - ], - ), - const SizedBox(height: 3), - Text( - '${l10n.seasonOneVerticalCinema} · ${l10n.nowPlaying}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.64), - fontSize: 10.5, - fontWeight: FontWeight.w500, - shadows: const [ - Shadow(blurRadius: 8, color: Colors.black87), - ], - ), - ), - ], + child: Text( + localizedDramaTitle(l10n, drama), + key: const Key('home-feed-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + shadows: [Shadow(blurRadius: 8, color: Colors.black)], + ), ), ), - const SizedBox(width: 6), - _GlassIconButton( + _RoundIcon( + key: const Key('home-feed-search'), tooltip: l10n.search, icon: Icons.search_rounded, onPressed: onSearch, ), - const SizedBox(width: 4), - _GlassIconButton( - tooltip: l10n.clearScreen, - icon: Icons.fit_screen_outlined, - onPressed: onClear, - ), - const SizedBox(width: 4), - _GlassIconButton( + const SizedBox(width: 6), + _RoundIcon( + key: const Key('home-feed-more'), tooltip: l10n.more, - icon: Icons.more_horiz_rounded, + icon: Icons.menu_rounded, onPressed: () => _openMore(context), ), ], @@ -165,131 +109,53 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _hotBadge() { - return DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(5), - border: Border.all(color: AppColors.blue.withValues(alpha: 0.4)), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - child: Text( - l10n.hot, - style: const TextStyle( - color: AppColors.blue, - fontSize: 8, - fontWeight: FontWeight.w800, - letterSpacing: 0.4, - height: 1.1, - ), - ), - ), - ); - } - - Widget _rail(bool compact) { + Widget _rail() { return Column( mainAxisSize: MainAxisSize.min, children: [ - Semantics( - button: true, - label: saved ? l10n.followingTab : l10n.followSeries, - child: InkWell( - key: const Key('home-feed-follow'), - onTap: onSave, - customBorder: const CircleBorder(), - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: compact ? 40 : 44, - height: compact ? 40 : 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.blue, width: 2), - color: AppColors.raised, - ), - child: ClipOval( - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - ), - ), - Positioned( - bottom: -4, - left: 14, - child: Container( - width: 16, - height: 16, - decoration: BoxDecoration( - color: saved ? AppColors.success : AppColors.blue, - shape: BoxShape.circle, - border: Border.all(color: AppColors.ink, width: 2), - ), - child: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - color: AppColors.onPrimary, - size: 11, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 8), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-like'), icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, label: l10n.like, selected: liked, onTap: onLike, ), - _RailAction( - compact: compact, - key: const Key('rail-save'), - icon: saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded, - label: l10n.save, - selected: saved, - onTap: onSave, - ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-comments'), icon: Icons.chat_bubble_outline_rounded, label: l10n.comments, onTap: onComments, ), - _RailAction( - compact: compact, + _RailItem( key: const Key('rail-share'), - icon: Icons.share_outlined, + icon: Icons.ios_share_rounded, label: l10n.share, onTap: onShare, ), + _RailItem( + key: const Key('rail-save'), + icon: saved ? Icons.star_rounded : Icons.star_border_rounded, + label: l10n.favorite, + selected: saved, + onTap: onSave, + ), ], ); } - Widget _infoCard(BuildContext context, bool compact) { - final genres = [ - if (drama.genre.isNotEmpty) drama.genre, - if (drama.category.isNotEmpty) drama.category, - ].take(2).toList(); + Widget _infoCard() { + final genre = drama.genre.isEmpty ? drama.category : drama.genre; return GlassPanel( key: const Key('home-feed-info-card'), - radius: 26, - blur: 24, - padding: const EdgeInsets.fromLTRB(16, 13, 16, 12), + radius: 14, + blur: 6, + padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0x260B0C10), Color(0x590B0C10)], + colors: [Color(0xBF0F0F14), Color(0xA60F0F14)], ), + borderColor: Colors.white.withValues(alpha: 0.06), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -298,94 +164,73 @@ class HomeFeedOverlay extends StatelessWidget { children: [ Expanded( child: Wrap( - spacing: 5, + spacing: 6, runSpacing: 4, children: [ - for (var i = 0; i < genres.length; i++) - _genreChip(genres[i], i.isEven), + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, + ), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), + ), ], ), ), const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - width: 5, - height: 5, - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.success, - shape: BoxShape.circle, - ), - ), - ), - const SizedBox(width: 4), - Text( - '${l10n.episodeLabel(episode.number)} / ' - '${drama.episodeCount}', - key: const Key('home-feed-episode'), - style: const TextStyle( - color: AppColors.cream, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), - ], + Flexible( + child: Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 7), Text( - drama.synopsis, - maxLines: compact ? 1 : 2, + localizedEpisodeTitle(l10n, episode.number), + key: const Key('home-feed-episode-title'), + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: AppColors.cream, - fontSize: 12, - height: 1.35, + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 6), - SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - showTimes: false, - compact: true, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - _timeRow(), - const SizedBox(height: 8), + const SizedBox(height: 10), Row( children: [ Expanded( child: SizedBox( - height: 40, + height: 34, child: FilledButton.icon( key: const Key('home-feed-follow-series'), onPressed: onSave, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, foregroundColor: AppColors.onPrimary, - shape: const StadiumBorder(), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, textStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, ), ), - icon: Icon( - saved ? Icons.check_rounded : Icons.add_rounded, - size: 16, - ), + icon: const Icon(Icons.play_arrow_rounded, size: 16), label: Text( saved ? l10n.followingTab : l10n.followSeries, maxLines: 1, @@ -395,11 +240,33 @@ class HomeFeedOverlay extends StatelessWidget { ), ), const SizedBox(width: 8), - _GlassAction( - tooltip: l10n.episodes, - icon: Icons.grid_view_rounded, - label: l10n.episodes, - onPressed: onEpisodes, + Expanded( + child: SizedBox( + height: 34, + child: FilledButton( + key: const Key('home-feed-episodes'), + onPressed: onEpisodes, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2A30), + foregroundColor: AppColors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppDimens.buttonRadius, + ), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: Text( + l10n.episodes, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), ), ], ), @@ -408,41 +275,29 @@ class HomeFeedOverlay extends StatelessWidget { ); } - Widget _genreChip(String label, bool secondary) { - final color = secondary ? AppColors.secondary : AppColors.blue; + Widget _tag( + String label, { + required Color background, + required Color foreground, + }) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.3)), + color: background, + borderRadius: BorderRadius.circular(6), ), child: Text( label, maxLines: 1, - overflow: TextOverflow.ellipsis, style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w600, - height: 1.1, + color: foreground, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ); } - Widget _timeRow() { - final source = controller; - if (source == null) { - return const _Times(position: Duration.zero, duration: Duration.zero); - } - return ValueListenableBuilder( - valueListenable: source, - builder: (context, value, _) => - _Times(position: value.position, duration: value.duration), - ); - } - Future _openMore(BuildContext context) async { await showModalBottomSheet( context: context, @@ -456,7 +311,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-download'), leading: const Icon( Icons.download_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.downloads), onTap: () { @@ -469,7 +324,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-pip'), leading: const Icon( Icons.picture_in_picture_alt_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.pictureInPicture), onTap: () { @@ -478,9 +333,10 @@ class HomeFeedOverlay extends StatelessWidget { }, ), ListTile( + key: const Key('home-feed-clear'), leading: const Icon( Icons.fit_screen_outlined, - color: AppColors.blueSoft, + color: AppColors.secondary, ), title: Text(l10n.clearScreen), onTap: () { @@ -495,35 +351,9 @@ class HomeFeedOverlay extends StatelessWidget { } } -class _Times extends StatelessWidget { - const _Times({required this.position, required this.duration}); - - final Duration position; - final Duration duration; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _format(position), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - Text( - _format(duration), - style: const TextStyle(color: AppColors.muted, fontSize: 9), - ), - ], - ), - ); - } -} - -class _GlassIconButton extends StatelessWidget { - const _GlassIconButton({ +class _RoundIcon extends StatelessWidget { + const _RoundIcon({ + super.key, required this.tooltip, required this.icon, required this.onPressed, @@ -531,140 +361,76 @@ class _GlassIconButton extends StatelessWidget { final String tooltip; final IconData icon; - final VoidCallback? onPressed; + final VoidCallback onPressed; @override Widget build(BuildContext context) { return SizedBox( - width: 34, - height: 34, + width: 28, + height: 28, child: IconButton( tooltip: tooltip, onPressed: onPressed, padding: EdgeInsets.zero, style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.28), + backgroundColor: const Color(0xCC2C2D33), foregroundColor: AppColors.onSurface, - side: BorderSide(color: Colors.white.withValues(alpha: 0.16)), ), - icon: Icon(icon, size: 18), + icon: Icon(icon, size: 14), ), ); } } -class _GlassAction extends StatelessWidget { - const _GlassAction({ - required this.tooltip, - required this.icon, - required this.label, - required this.onPressed, - }); - - final String tooltip; - final IconData icon; - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(999), - child: Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white.withValues(alpha: 0.15)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: AppColors.secondary, size: 15), - const SizedBox(width: 5), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _RailAction extends StatelessWidget { - const _RailAction({ +class _RailItem extends StatelessWidget { + const _RailItem({ super.key, required this.icon, required this.label, required this.onTap, this.selected = false, - this.compact = false, }); final IconData icon; final String label; final VoidCallback onTap; final bool selected; - final bool compact; @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: compact ? 2 : 3), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GlassPanel( - radius: 999, - blur: 16, - child: SizedBox( - width: compact ? 40 : 38, - height: compact ? 40 : 38, - child: IconButton( - tooltip: label, - onPressed: onTap, - padding: EdgeInsets.zero, - icon: Icon( + padding: const EdgeInsets.only(bottom: 14), + child: Semantics( + button: true, + label: label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(22), + child: SizedBox( + width: 44, + child: Column( + children: [ + Icon( icon, - size: 19, + size: 22, color: selected ? AppColors.blue : AppColors.onSurface, ), - ), + const SizedBox(height: 2), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.cream, + fontSize: 9, + shadows: [Shadow(blurRadius: 6, color: Colors.black)], + ), + ), + ], ), ), - if (!compact) ...[ - const SizedBox(height: 2), - Text( - label, - maxLines: 1, - style: const TextStyle( - color: AppColors.cream, - fontSize: 9, - fontWeight: FontWeight.w600, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ], - ], + ), ), ); } } - -String _format(Duration value) { - final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); - final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$minutes:$seconds'; -} diff --git a/lib/pages/player/widgets/player_featured_comment.dart b/lib/pages/player/widgets/player_featured_comment.dart new file mode 100644 index 0000000..e888ba8 --- /dev/null +++ b/lib/pages/player/widgets/player_featured_comment.dart @@ -0,0 +1,158 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/data/models/comment.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/data/repositories/comment_repository.dart'; +import '../../../core/data/repositories/interaction_repository.dart'; +import '../../../core/data/services/comment_controller.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../l10n/app_localizations.dart'; + +class PlayerFeaturedComment extends StatefulWidget { + const PlayerFeaturedComment({ + super.key, + required this.drama, + required this.onViewAll, + }); + + final Drama drama; + final VoidCallback onViewAll; + + @override + State createState() => _PlayerFeaturedCommentState(); +} + +class _PlayerFeaturedCommentState extends State { + late final CommentController _controller; + + @override + void initState() { + super.initState(); + _controller = CommentController( + comments: context.read(), + interactions: context.read(), + dramaId: widget.drama.id, + ); + unawaited(_controller.load()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + if (_controller.comments.isEmpty) return const SizedBox.shrink(); + return _card(context, _controller.comments.first); + }, + ); + } + + Widget _card(BuildContext context, DramaComment comment) { + final l10n = AppLocalizations.of(context)!; + final time = _time(l10n, comment.createdAt); + return InkWell( + key: const Key('player-featured-comment'), + onTap: widget.onViewAll, + borderRadius: BorderRadius.circular(10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.secondary.withValues(alpha: 0.2), + ), + child: Text( + _initial(comment.nickname), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 8, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + comment.nickname.isEmpty + ? l10n.localViewer + : comment.nickname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + if (time != null) ...[ + const SizedBox(width: 4), + Text( + '· $time', + style: const TextStyle( + color: Color(0xFF777777), + fontSize: 9, + ), + ), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 5), + Text( + comment.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 10, + height: 1.5, + ), + ), + ], + ), + ), + ); + } + + String? _time(AppLocalizations l10n, DateTime? createdAt) { + if (createdAt == null) return null; + final diff = DateTime.now().difference(createdAt); + if (diff.inMinutes < 1) return null; + return l10n.minutesAgo(diff.inMinutes); + } + + String _initial(String nickname) { + final trimmed = nickname.trim(); + return trimmed.isEmpty ? '?' : trimmed.substring(0, 1).toUpperCase(); + } +} diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index cc2855d..6680fa1 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -129,7 +129,7 @@ class _SeekProgressBarState extends State { activeTrackColor: AppColors.blue, inactiveTrackColor: widget.compact ? Colors.white.withValues(alpha: 0.18) - : Colors.white30, + : const Color(0xFF474248), thumbColor: widget.compact ? AppColors.blue : Colors.white, thumbShape: RoundSliderThumbShape( enabledThumbRadius: widget.compact diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b19c94d..3ff02ef 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -39,44 +39,42 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.text('For You'), findsNothing); - expect( - tester.getCenter(find.text('All')).dx, - lessThan(tester.getCenter(find.text('Hot')).dx), - ); final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 2); - expect(delegate.crossAxisSpacing, 12); - expect(delegate.mainAxisSpacing, 14); - expect(delegate.childAspectRatio, closeTo(0.55, 0.001)); + expect(delegate.crossAxisSpacing, 10); + expect(delegate.mainAxisSpacing, 10); + expect(delegate.mainAxisExtent, 164); expect( tester .widget( find.byKey(const Key('home-all-dramas-section')), ) .padding, - const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); expect(find.byType(RemoteImage), findsWidgets); - final cover = tester.widget( - find.byKey(const Key('home-drama-cover-city-lights-demo')), + expect( + tester + .getSize(find.byKey(const Key('home-drama-cover-city-lights-demo'))) + .height, + 120, ); - expect(cover.aspectRatio, closeTo(3 / 4.2, 0.001)); expect( tester .widget( find.byKey(const Key('home-drama-title-city-lights-demo')), ) .maxLines, - 2, + 1, ); expect( tester @@ -100,14 +98,14 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('The Signal'), findsWidgets); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); await tester.tap(find.text('Romance')); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect( find.byKey(const Key('home-drama-card-the-signal-demo')), findsNothing, @@ -182,7 +180,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); - expect(find.text('City Lights & Velvet Nights'), findsOneWidget); + expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 3bd89f3..7fee3c7 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -21,7 +21,7 @@ import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; void main() { - testWidgets('should_render_hero_cta_synopsis_and_two_column_episode_grid', ( + testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( tester, ) async { tester.view.physicalSize = const Size(390, 844); @@ -32,45 +32,24 @@ void main() { await tester.pumpWidget(_host(_detailDrama())); await tester.pumpAndSettle(); - final hero = tester.widget( - find.byKey(const Key('detail-hero')), - ); - expect(hero.aspectRatio, closeTo(16 / 9, 0.001)); + expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); expect(find.byKey(const Key('detail-cta')), findsOneWidget); - expect(find.byKey(const Key('detail-favorite')), findsOneWidget); - expect(find.byKey(const Key('detail-download')), findsOneWidget); - expect(find.byKey(const Key('detail-episodes-range')), findsOneWidget); + expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('detail-episode-chip-1'))), - const Size(46, 44), - ); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); final synopsis = tester.widget( find.byKey(const Key('detail-synopsis')), ); - expect(synopsis.maxLines, 2); - await tester.tap(find.byKey(const Key('detail-synopsis-toggle'))); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byKey(const Key('detail-synopsis'))).maxLines, - 100, - ); + expect(synopsis.style?.fontSize, 11); + expect(synopsis.style?.height, 1.6); - final grid = tester.widget( - find.descendant( - of: find.byKey(const Key('detail-episode-grid')), - matching: find.byType(SliverGrid), - ), - ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 2); - expect(find.byKey(const Key('detail-episode-card-1')), findsOneWidget); - expect( - tester.getSize(find.byKey(const Key('detail-episode-thumb-1'))), - const Size(96, 64), - ); + await tester.tap(find.byKey(const Key('detail-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('detail-favorite')), findsOneWidget); + expect(find.byKey(const Key('detail-download')), findsOneWidget); + expect(find.byKey(const Key('detail-share')), findsOneWidget); + expect(find.byKey(const Key('detail-report')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -90,7 +69,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('detail-episode-card-3')), findsOneWidget); + expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); expect(tester.takeException(), isNull); }); } diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart new file mode 100644 index 0000000..ca085e3 --- /dev/null +++ b/test/home/home_preview_layout_test.dart @@ -0,0 +1,64 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_preview_home_card_without_hot_or_follow_avatar', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository(), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); + expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); + expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, + 34, + ); + expect(find.text('HOT'), findsNothing); + expect(find.textContaining('Season'), findsNothing); + expect(find.byKey(const Key('home-feed-follow')), findsNothing); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1b4468a..e9693bc 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -29,11 +29,15 @@ void main() { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); expect(find.byType(PageView), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-download')), findsOneWidget); + expect(find.byKey(const Key('player-more-pip')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -57,24 +61,29 @@ void main() { await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); await tester.pumpAndSettle(); _expectFullscreenOverlay(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(tester.takeException(), isNull); }); } void _expectFullscreenOverlay() { - expect(find.byKey(const Key('rail-like')), findsOneWidget); - expect(find.byKey(const Key('rail-save')), findsOneWidget); - expect(find.byKey(const Key('rail-comments')), findsOneWidget); - expect(find.byKey(const Key('rail-share')), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('player-back')), findsOneWidget); + expect(find.byKey(const Key('player-title')), findsOneWidget); + expect(find.byKey(const Key('player-more')), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-episodes')), findsOneWidget); + expect(find.byKey(const Key('player-comments')), findsOneWidget); + expect(find.byKey(const Key('player-next')), findsOneWidget); expect(find.byType(Slider), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsNothing); + expect(find.byKey(const Key('rail-save')), findsNothing); expect(find.byKey(const Key('card-player-video-frame')), findsNothing); expect(find.byKey(const Key('card-player-progress')), findsNothing); expect(find.byKey(const Key('card-player-next')), findsNothing); - expect(find.byKey(const Key('player-title')), findsNothing); } Future _pumpApp(WidgetTester tester) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index 0d90f10..10525fd 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -110,17 +110,19 @@ void main() { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); - expect(find.byType(Slider), findsOneWidget); + expect(find.byType(Slider), findsNothing); expect(find.text('Home'), findsWidgets); - expect(find.textContaining('Playing now'), findsOneWidget); - expect(find.text('Recommended'), findsNothing); - expect(find.text('Following'), findsNothing); - expect(find.byTooltip('Episodes'), findsWidgets); - expect(find.byTooltip('Clear screen'), findsOneWidget); - expect(find.byTooltip('Picture in picture'), findsNothing); - await tester.tap(find.byTooltip('More')); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('rail-like')), findsOneWidget); + expect(find.byKey(const Key('rail-comments')), findsOneWidget); + expect(find.byKey(const Key('rail-share')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + await tester.tap(find.byKey(const Key('home-feed-more'))); await tester.pumpAndSettle(); - expect(find.text('Picture in picture'), findsOneWidget); + expect(find.byKey(const Key('home-feed-download')), findsOneWidget); + expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); + expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); @@ -139,14 +141,16 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Clear screen')); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); await tester.tap(find.byTooltip('Exit clear screen')); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byTooltip('Clear screen'), findsOneWidget); + expect(find.byKey(const Key('home-feed-more')), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -233,12 +237,11 @@ void main() { expect(find.text('我的'), findsWidgets); await tester.drag(find.byType(ListView), const Offset(0, 700)); await tester.pumpAndSettle(); - expect(find.text('我的账户'), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); expect(find.text('首页'), findsOneWidget); - await tester.tap(find.text('找剧')); + await tester.tap(find.text('发现')); await tester.pumpAndSettle(); - expect(find.text('全部'), findsOneWidget); - expect(find.text('热门'), findsOneWidget); + expect(find.text('推荐'), findsOneWidget); }); testWidgets( @@ -259,7 +262,9 @@ void main() { expect(find.text('City Lights & Velvet Nights'), findsOneWidget); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); expect(tester.takeException(), isNull); @@ -324,7 +329,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.text('All'), findsOneWidget); + expect(find.text('Recommended'), findsOneWidget); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); expect(find.byKey(const Key('discover-profile')), findsOneWidget); expect( @@ -341,7 +346,7 @@ void main() { ), findsOneWidget, ); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('tab-my'))); @@ -372,7 +377,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -396,7 +401,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byTooltip('Search'), findsNothing); + expect(find.byTooltip('Search'), findsOneWidget); expect( Navigator.of( tester.element(find.byKey(const Key('home-all-dramas-grid'))), @@ -440,12 +445,17 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - await tester.tap(find.text('City Lights & Velvet Nights')); + await tester.tap( + find.byKey(const Key('home-drama-card-city-lights-demo')), + ); await tester.pumpAndSettle(); expect(find.byType(Slider), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('Episode 2 · Playing now'), findsOneWidget); + expect( + tester.widget(find.byKey(const Key('player-title'))).data, + contains('Episode 2'), + ); expect(find.textContaining('City Lights & Velvet Nights'), findsWidgets); }, ); @@ -507,9 +517,9 @@ void main() { await tester.pumpAndSettle(); final keys = [ const Key('rail-like'), - const Key('rail-save'), const Key('rail-comments'), const Key('rail-share'), + const Key('rail-save'), ]; final tops = keys .map((key) => tester.getTopLeft(find.byKey(key)).dy) @@ -644,10 +654,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('mine-library-tab-1')), - 300, - ); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('The Signal'), findsOneWidget); From fecc5b0d270f5d635369ced3add4ea2c7a8907c4 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:15 +0800 Subject: [PATCH 08/30] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=85=A5=E5=8F=A3=E4=B8=8E=E6=88=91=E7=9A=84=E9=A1=B5?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/auth/auth_view.dart | 170 ++- lib/pages/mine/mine_view.dart | 1328 ++++++++++--------- test/account/mine_account_entry_test.dart | 2 + test/auth/auth_k21_test.dart | 3 + test/auth/auth_landing_test.dart | 65 + test/auth/auth_page_test.dart | 3 + test/auth/auth_visual_test.dart | 26 +- test/mine/mine_preview_layout_test.dart | 69 + test/notice/notices_entry_test.dart | 2 + test/watch/watch_history_provider_test.dart | 2 + 10 files changed, 1021 insertions(+), 649 deletions(-) create mode 100644 test/auth/auth_landing_test.dart create mode 100644 test/mine/mine_preview_layout_test.dart diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 555d495..5ddbde0 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -60,6 +60,7 @@ class _AuthPageState extends State { late final FocusNode _emailFocus; late final FocusNode _passwordFocus; bool _obscurePassword = true; + bool _emailEntryOpen = false; @override void initState() { @@ -243,7 +244,13 @@ class _AuthPageState extends State { tooltip: MaterialLocalizations.of( context, ).backButtonTooltip, - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + if (_emailEntryOpen) { + setState(() => _emailEntryOpen = false); + } else { + Navigator.of(context).pop(); + } + }, icon: const Icon( Icons.arrow_back_rounded, color: AppColors.onPrimary, @@ -272,16 +279,107 @@ class _AuthPageState extends State { child: child, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), - SizedBox(height: compact ? _compactBrandGap : 28), - _authCard(l10n), - ], - ), + child: _emailEntryOpen + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _AuthBrand(tagline: l10n.authBrandTagline, compact: compact), + SizedBox(height: compact ? _compactBrandGap : 28), + _authCard(l10n), + ], + ) + : _landing(l10n), ); + Widget _landing(AppLocalizations l10n) => Column( + key: const Key('auth-landing'), + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + key: const Key('auth-brand-logo'), + width: 64, + height: 64, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.blue, AppColors.secondary], + ), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 32, + ), + ), + ), + const SizedBox(height: 14), + Text( + l10n.fruitShortsBrand, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + l10n.authLandingSubtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11), + ), + const SizedBox(height: 22), + _AuthEntryButton( + key: const Key('auth-entry-google'), + label: 'G ${l10n.authContinueGoogle}', + background: Colors.white, + foreground: const Color(0xFF1A1A1A), + leadingColor: const Color(0xFF4285F4), + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-phone'), + label: l10n.authPhoneSignIn, + icon: Icons.phone_android_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => _comingSoon(l10n), + ), + const SizedBox(height: 11), + _AuthEntryButton( + key: const Key('auth-entry-email'), + label: l10n.authEmailSignIn, + icon: Icons.mail_outline_rounded, + background: AppColors.input, + foreground: AppColors.onSurface, + onTap: () => setState(() => _emailEntryOpen = true), + ), + const SizedBox(height: 18), + Text( + l10n.authLegal, + key: const Key('auth-legal'), + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF6B6772), + fontSize: 9, + height: 1.6, + ), + ), + ], + ); + + void _comingSoon(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.comingSoon))); + } + Widget _authCard(AppLocalizations l10n) => _AuthCard( state: _AuthCardState( l10n: l10n, @@ -320,3 +418,57 @@ class _AuthPageState extends State { setState(() => _logic.toggleMode()); } } + +class _AuthEntryButton extends StatelessWidget { + const _AuthEntryButton({ + super.key, + required this.label, + required this.background, + required this.foreground, + required this.onTap, + this.icon, + this.leadingColor, + }); + + final String label; + final Color background; + final Color foreground; + final VoidCallback onTap; + final IconData? icon; + final Color? leadingColor; + + @override + Widget build(BuildContext context) { + return Material( + color: background, + borderRadius: BorderRadius.circular(9), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 44, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) + Icon(icon, size: 16, color: leadingColor ?? foreground), + if (icon != null) const SizedBox(width: 7), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: foreground, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 8091ab5..3a21c53 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -6,27 +6,28 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../core/formatting/localized_content.dart'; +import '../../core/config/locale_controller.dart'; import '../../core/data/models/drama.dart'; +import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/auth_controller.dart'; -import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/interaction_controller.dart'; import '../../core/formatting/error_text.dart'; -import '../../core/widgets/empty_state.dart'; -import '../../core/widgets/app_bottom_nav.dart'; -import '../../core/widgets/remote_image.dart'; +import '../../core/formatting/localized_content.dart'; import '../../core/theme/app_colors.dart'; -import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; -import 'mine_binding.dart'; -import 'mine_logic.dart'; -import '../watch_records/watch_records_page.dart'; +import '../account/account_entry_card.dart'; +import '../downloads/downloads_page.dart'; import '../favorites/favorites_page.dart'; import '../notices/notices_page.dart'; -import '../downloads/downloads_page.dart'; -import '../account/account_entry_card.dart'; +import '../watch_records/watch_records_page.dart'; +import 'mine_binding.dart'; +import 'mine_logic.dart'; class MyPage extends StatefulWidget { const MyPage({ @@ -117,40 +118,27 @@ class _MyPageState extends State { children: [ _topBar(l10n), Expanded( - child: FutureBuilder( - future: logic.position, - builder: (context, snapshot) { - final position = snapshot.data ?? Duration.zero; - final total = widget.duration > Duration.zero - ? widget.duration - : const Duration(seconds: 12); - final fraction = - (position.inMilliseconds / total.inMilliseconds).clamp( - 0.0, - 1.0, - ); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - children: [ - if (widget.profile) ..._accountPage(l10n), - if (widget.profile) const SizedBox(height: 24), - _library(l10n, position, total, fraction), - if (!widget.profile) const SizedBox(height: 24), - if (!widget.profile) ..._accountPage(l10n), - const SizedBox(height: 24), - Center( - child: Text( - l10n.brandMark, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - letterSpacing: 1.3, - ), - ), - ), - ], - ); - }, + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 18), + children: [ + _userRow(l10n), + const SizedBox(height: 12), + _wallet(l10n), + if (_continueDrama != null) ...[ + const SizedBox(height: 12), + _continueCard(l10n, _continueDrama!, _continueRecord!), + ], + const SizedBox(height: 14), + _library(l10n), + const SizedBox(height: 14), + _languageRow(l10n), + if (widget.signedIn) ...[ + const SizedBox(height: 12), + const AccountEntryCard(), + ], + const SizedBox(height: 12), + _menuCard(l10n), + ], ), ), if (!widget.embedded) _bottomNav(l10n), @@ -166,124 +154,102 @@ class _MyPageState extends State { } Widget _topBar(AppLocalizations l10n) => SizedBox( - height: 54, + height: 46, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 14), child: Row( children: [ - const Icon(Icons.play_circle_fill_rounded, color: _accent, size: 27), - const SizedBox(width: 7), + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), + ), + const SizedBox(width: 8), Text( - widget.profile ? l10n.myTab : l10n.myListTab, + l10n.myTab, style: const TextStyle( - fontSize: AppDimens.pageTitle, + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, fontWeight: FontWeight.w800, ), ), const Spacer(), - IconButton( + _RoundHeaderButton( tooltip: l10n.search, - onPressed: () => logic.act( + icon: Icons.search_rounded, + onTap: () => logic.act( context, MyPageAction.discover, embedded: widget.embedded, onAction: widget.onAction, ), - icon: const Icon(Icons.search_rounded, size: 23), ), - const SizedBox(width: 6), - const CircleAvatar( - radius: 14, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 19), + const SizedBox(width: 7), + Container( + width: 28, + height: 28, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 15, + ), ), ], ), ), ); - List _accountPage(AppLocalizations l10n) => [ - if (widget.signedIn) _signedInRow(l10n) else _guestRow(l10n), - if (widget.signedIn) ...[ - const SizedBox(height: 8), - _wallet(l10n), - const SizedBox(height: 4), - const AccountEntryCard(), - ], - const SizedBox(height: 4), - _menuCard(l10n), - const SizedBox(height: 8), - Text( - l10n.preferencesHeading, - style: const TextStyle( - color: AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w800, - letterSpacing: 1.1, - ), - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.language_rounded, color: AppColors.blueSoft), - title: Text(l10n.appLanguage), - trailing: const Icon(Icons.chevron_right_rounded), - onTap: () => logic.showLanguage( - context, - showServerSetting: widget.debugShowSetting, - ), - ), - ]; - - Widget _guestRow(AppLocalizations l10n) { + Widget _userRow(AppLocalizations l10n) { + final user = widget.signedIn ? context.watch().user : null; + final name = widget.signedIn + ? ((user?.nickname.isNotEmpty ?? false) + ? user!.nickname + : l10n.localViewer) + : l10n.localViewer; + final subtitle = widget.signedIn + ? ((user?.uid.isNotEmpty ?? false) + ? 'ID: ${user!.uid}' + : l10n.myAccount) + : l10n.logInHint; return Row( + key: const Key('mine-user-row'), children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - l10n.logInHint, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 22, ), ), - const SizedBox(width: 8), - TextButton( - onPressed: widget.onSignIn, - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: AppColors.blue, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), - ), - child: Text(l10n.logIn), - ), - ], - ); - } - - Widget _signedInRow(AppLocalizations l10n) { - // 显示真实登录账号(昵称 + uid);拿不到才回落到演示文案 - final user = context.watch().user; - final name = (user?.nickname.isNotEmpty ?? false) - ? user!.nickname - : l10n.mockUserName; - final uidText = (user?.uid.isNotEmpty ?? false) - ? 'ID: ${user!.uid}' - : l10n.mockUserId; - return Row( - children: [ - const CircleAvatar( - radius: 28, - backgroundColor: AppColors.raised, - child: Icon(Icons.person_rounded, color: Colors.white, size: 30), - ), - const SizedBox(width: 12), + const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -293,120 +259,498 @@ class _MyPageState extends State { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( - uidText, - style: const TextStyle(color: AppColors.muted, fontSize: 12), + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 10), ), ], ), ), - TextButton( - onPressed: widget.onSignOut ?? () => _mockSnack(l10n), - style: TextButton.styleFrom( - backgroundColor: AppColors.raised, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: const StadiumBorder(), + if (widget.signedIn) + TextButton( + onPressed: widget.onSignOut, + style: TextButton.styleFrom( + foregroundColor: AppColors.muted, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(52, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text(l10n.signOut), + ) + else + TextButton( + key: const Key('mine-sign-in'), + onPressed: widget.onSignIn, + style: TextButton.styleFrom( + foregroundColor: AppColors.cream, + backgroundColor: const Color(0xFF24252A), + minimumSize: const Size(64, 30), + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: const StadiumBorder(), + textStyle: const TextStyle(fontSize: 10), + ), + child: Text('${l10n.logIn}/${l10n.signUp}'), ), - child: Text(l10n.signOut), - ), ], ); } Widget _wallet(AppLocalizations l10n) { + return InkWell( + key: const Key('mine-wallet'), + onTap: () => _mockSnack(l10n), + borderRadius: BorderRadius.circular(13), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.goldCard, + borderRadius: BorderRadius.circular(13), + ), + child: Row( + children: [ + const Icon( + Icons.monetization_on_rounded, + color: AppColors.gold, + size: 20, + ), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coins, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '0 ${l10n.coins} · ${l10n.topUpWallet}', + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: Color(0xFF666666), + size: 16, + ), + ], + ), + ), + ); + } + + WatchProgress? get _continueRecord { + for (final record in logic.recent) { + if (!record.finished && record.hasPosition) return record; + } + return null; + } + + Drama? get _continueDrama { + final record = _continueRecord; + if (record == null) return null; + for (final drama in [...?widget.dramas, widget.drama]) { + if (drama.id == record.dramaId) return drama; + } + return null; + } + + Widget _continueCard( + AppLocalizations l10n, + Drama drama, + WatchProgress record, + ) { return Container( - padding: const EdgeInsets.all(AppDimens.cardPadding), + key: const Key('mine-continue-watching'), + padding: const EdgeInsets.all(9), decoration: BoxDecoration( color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), + borderRadius: BorderRadius.circular(12), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - l10n.myAccount, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), + _poster(drama, width: 50, height: 64, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.continueWatching, + style: const TextStyle( + color: AppColors.accent, + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + ), + ], + ), ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _accountTile( - label: l10n.coins, - value: '\$ 0', - action: l10n.topUpWallet, - color: AppColors.gold, - onPressed: () => _mockSnack(l10n), + const SizedBox(width: 8), + SizedBox( + width: 60, + height: 28, + child: FilledButton( + onPressed: () => _resume(drama), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, ), ), - const SizedBox(width: 10), - Expanded( - child: _accountTile( - label: l10n.vipMembership, - value: l10n.notActive, - action: l10n.joinVip, - color: AppColors.blue, - onPressed: () => _mockSnack(l10n), - ), - ), - ], + child: Text(l10n.resume), + ), ), ], ), ); } - void _mockSnack(AppLocalizations l10n) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + Widget _library(AppLocalizations l10n) { + final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + for (var index = 0; index < tabs.length; index++) + Expanded( + child: Semantics( + selected: logic.section == index, + button: true, + child: InkWell( + key: Key('mine-library-tab-$index'), + onTap: () => logic.selectSection(index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + tabs[index], + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: logic.section == index + ? AppColors.blue + : const Color(0xFFE4DBDD), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 10), + if (logic.section == 1 && context.watch().loading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (logic.section == 1) + ..._favoriteCards(l10n) + else if (logic.section == 2) + ..._likedCards(l10n) + else if (logic.recentLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else if (_recentDramas.isEmpty) + EmptyState( + key: const Key('mine-recent-empty'), + icon: Icons.history_rounded, + title: l10n.noStories, + subtitle: l10n.emptyHint, + compact: true, + ) + else + for (final drama in _recentDramas) ...[ + _historyCard(l10n, drama), + const SizedBox(height: 8), + ], + ], + ); } - Widget _accountTile({ - required String label, - required String value, - required String action, - required Color color, - required VoidCallback onPressed, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.ink, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - border: Border.all(color: color.withValues(alpha: 0.45)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: TextStyle(color: color, fontSize: 12)), - const SizedBox(height: 6), - Text( - value, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 10), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: onPressed, - style: FilledButton.styleFrom( - backgroundColor: color, - foregroundColor: color == AppColors.blue - ? Colors.white - : AppColors.onGold, - shape: const StadiumBorder(), + List _favoriteCards(AppLocalizations l10n) { + final favorites = context.watch(); + if (favorites.error != null && favorites.cards.isEmpty) { + return [ + EmptyState( + icon: Icons.cloud_off_rounded, + title: errorText(l10n, favorites.error, l10n.favoritesError), + actionLabel: l10n.retry, + onAction: () => unawaited(favorites.load()), + compact: true, + ), + ]; + } + if (favorites.cards.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in favorites.cards) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + List _likedCards(AppLocalizations l10n) { + final interactions = context.watch(); + final dramas = [ + ...?widget.dramas, + widget.drama, + ].where((drama) => interactions.isLiked(drama.id)).toList(); + if (dramas.isEmpty) { + return [ + EmptyState( + key: const Key('mine-saved-empty'), + icon: Icons.favorite_border_rounded, + title: l10n.noSavedShows, + subtitle: l10n.emptyHint, + compact: true, + ), + ]; + } + return [ + for (final drama in dramas) ...[ + _smallDramaCard(l10n, drama), + const SizedBox(height: 8), + ], + ]; + } + + Widget _historyCard(AppLocalizations l10n, Drama drama) { + final record = _recordFor(drama.id); + return InkWell( + key: Key('mine-history-card-${drama.id}'), + onTap: () => _resume(drama), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + record == null + ? l10n.episodeCount(drama.episodeCount) + : '${_episodeText(drama, record)} · ' + '${_clock(record.position)} / ${_clock(record.duration)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], ), - child: Text(action, maxLines: 1, overflow: TextOverflow.ellipsis), ), - ), - ], + Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFF313137), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.blue, + size: 15, + ), + ), + ], + ), + ), + ); + } + + Widget _smallDramaCard(AppLocalizations l10n, Drama drama) { + return InkWell( + onTap: () { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + _resume(drama); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + _poster(drama, width: 48, height: 60, radius: 7), + const SizedBox(width: 9), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: Color(0xFFBBBBBB), + fontSize: 9, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + ], + ), + ), + ); + } + + Widget _languageRow(AppLocalizations l10n) { + final locale = context.watch(); + final code = locale.locale?.languageCode ?? 'id'; + final value = switch (code) { + 'zh' => l10n.simplifiedChinese, + 'en' => l10n.english, + _ => l10n.bahasaIndonesia, + }; + return InkWell( + key: const Key('mine-language'), + onTap: () => logic.showLanguage( + context, + showServerSetting: widget.debugShowSetting, + ), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon( + Icons.language_rounded, + color: AppColors.muted, + size: 14, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + l10n.appLanguage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 11, + ), + ), + ), + Text( + value, + style: const TextStyle(color: AppColors.accent, fontSize: 10), + ), + const SizedBox(width: 3), + const Icon( + Icons.chevron_right_rounded, + color: AppColors.accent, + size: 16, + ), + ], + ), ), ); } @@ -414,97 +758,42 @@ class _MyPageState extends State { Widget _menuCard(AppLocalizations l10n) { return Material( color: AppColors.card, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), clipBehavior: Clip.antiAlias, child: Column( children: [ - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.card_giftcard_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.earnRewards), - trailing: Text( - l10n.freeBonus, - style: const TextStyle(color: AppColors.blueSoft, fontSize: 12), - ), - onTap: () => _mockSnack(l10n), - ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, + _menuTile( + icon: Icons.notifications_none_rounded, + title: l10n.notices, key: const Key('mine-notices'), - leading: const Icon( - Icons.notifications_none_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.notices), - trailing: const Icon(Icons.chevron_right_rounded), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const NoticesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.history_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.watchHistory), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.history_rounded, + title: l10n.watchHistory, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WatchRecordsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.favorite_outline_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.myFavorites), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.favorite_outline_rounded, + title: l10n.myFavorites, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const FavoritesPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.download_outlined, - color: AppColors.blueSoft, - ), - title: Text(l10n.downloads), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.download_outlined, + title: l10n.downloads, onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const DownloadsPage()), ), ), - const Divider(height: 1, color: AppColors.outlineVariantDark), - ListTile( - dense: true, - visualDensity: VisualDensity.compact, - minTileHeight: AppDimens.tapTarget, - leading: const Icon( - Icons.settings_rounded, - color: AppColors.blueSoft, - ), - title: Text(l10n.settings), - trailing: const Icon(Icons.chevron_right_rounded), + _menuTile( + icon: Icons.settings_rounded, + title: l10n.settings, onTap: () => logic.showLanguage( context, showServerSetting: widget.debugShowSetting, @@ -515,346 +804,30 @@ class _MyPageState extends State { ); } - Widget _library( - AppLocalizations l10n, - Duration position, - Duration total, - double fraction, - ) { - final tabs = [l10n.history, l10n.watchlist, l10n.likedTab]; - final filters = [l10n.all, l10n.inProgress, l10n.completed]; - final hasHistory = - logic.filter == 0 || - (logic.filter == 1 && fraction < 0.99) || - (logic.filter == 2 && fraction >= 0.99); - final interactions = context.watch(); - // 收藏以服务端为准;剧集点赞暂按当前会话状态过滤 feed。 - final favorites = context.watch(); - final isWatchlist = logic.section == 1; - final markedDramas = isWatchlist - ? favorites.cards - : (widget.dramas ?? [widget.drama]) - .where((drama) => interactions.isLiked(drama.id)) - .toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < tabs.length; index++) ...[ - Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - key: Key('mine-library-tab-$index'), - onTap: () { - logic.selectSection(index); - // 打开收藏页签时以服务端为准刷新一次(多设备/多入口都能对齐) - if (index == 1) unawaited(favorites.load()); - }, - borderRadius: BorderRadius.circular(AppDimens.pillRadius), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, - ), - decoration: BoxDecoration( - color: logic.section == index - ? AppColors.blue - : AppColors.raised, - borderRadius: BorderRadius.circular( - AppDimens.pillRadius, - ), - ), - child: Text( - tabs[index], - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ), - if (index < tabs.length - 1) const SizedBox(width: 18), - ], - ], - ), - ), - const SizedBox(height: 17), - if (logic.section == 0) - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (var index = 0; index < filters.length; index++) - Padding( - padding: const EdgeInsets.only(right: 7), - child: ChoiceChip( - label: Text(filters[index]), - selected: logic.filter == index, - onSelected: (_) => logic.selectFilter(index), - showCheckmark: false, - materialTapTargetSize: MaterialTapTargetSize.padded, - backgroundColor: AppColors.raised, - selectedColor: AppColors.blue, - labelStyle: TextStyle( - color: logic.filter == index - ? Colors.white - : AppColors.muted, - fontSize: AppDimens.caption, - fontWeight: FontWeight.w700, - ), - side: BorderSide.none, - ), - ), - ], - ), - ), - if (logic.section == 0) const SizedBox(height: 11), - if (logic.section == 0 && hasHistory) - InkWell( - onTap: () => logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ), - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - _poster(width: 74, height: 103), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, widget.drama), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 5), - Text( - l10n.dramaTag, - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - const SizedBox(height: 32), - Text( - '${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.blueSoft, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const CircleAvatar( - radius: 15, - backgroundColor: AppColors.raised, - child: Icon( - Icons.play_arrow_rounded, - color: _accent, - size: 20, - ), - ), - ], - ), - ), - ) - else if (isWatchlist && favorites.loading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 22), - child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (isWatchlist && favorites.error != null) - EmptyState( - icon: Icons.cloud_off_rounded, - title: errorText(l10n, favorites.error, l10n.favoritesError), - actionLabel: l10n.retry, - onAction: () => unawaited(favorites.load()), - compact: true, - ) - else if (logic.section != 0 && markedDramas.isNotEmpty) - for (final drama in markedDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ] - else - EmptyState( - key: const Key('mine-saved-empty'), - icon: Icons.bookmark_border_rounded, - title: l10n.noSavedShows, - subtitle: l10n.emptyHint, - ), - // 最近观看(服务端 /app/watch/recent):只渲染能在已加载目录里匹配到的剧; - // 匹配不到的(例如不在当前首页分区里)先不显示,K2 的记录页会做独立请求。 - if (logic.section == 0) ...[ - const SizedBox(height: 18), - Text( - l10n.history, - style: const TextStyle( - color: Colors.white, - fontSize: AppDimens.body, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 9), - if (logic.positionFailed) - Row( - children: [ - Expanded( - child: Text( - l10n.progressError, - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: logic.refreshPosition, - child: Text(l10n.retry), - ), - ], - ) - else if (logic.recentLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ) - else if (logic.recentError != null) - Row( - children: [ - Expanded( - child: Text( - errorText(l10n, logic.recentError, l10n.progressError), - style: const TextStyle(color: AppColors.cream), - ), - ), - TextButton( - onPressed: () => unawaited(logic.refreshRecent()), - child: Text(l10n.retry), - ), - ], - ) - else if (_recentDramas.isEmpty) - EmptyState( - key: const Key('mine-recent-empty'), - icon: Icons.history_rounded, - title: l10n.noStories, - subtitle: l10n.emptyHint, - ) - else - for (final drama in _recentDramas) ...[ - _markedDramaCard(drama, l10n), - const SizedBox(height: 9), - ], - ], - ], + Widget _menuTile({ + required IconData icon, + required String title, + required VoidCallback onTap, + Key? key, + }) { + return ListTile( + key: key, + dense: true, + minTileHeight: 44, + leading: Icon(icon, color: AppColors.secondary, size: 17), + title: Text( + title, + style: const TextStyle(color: AppColors.onSurface, fontSize: 11), + ), + trailing: const Icon( + Icons.chevron_right_rounded, + color: AppColors.muted, + size: 16, + ), + onTap: onTap, ); } - /// 服务端最近观看 → 已加载目录里的卡片(按 id 匹配,去重,当前剧也算一条)。 - List get _recentDramas { - final pool = [...?widget.dramas, widget.drama]; - final result = []; - for (final item in logic.recent) { - for (final drama in pool) { - if (drama.id == item.dramaId && - !result.any((picked) => picked.id == drama.id)) { - result.add(drama); - } - } - } - return result; - } - - Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell( - onTap: () { - if (widget.onDramaSelected != null) { - widget.onDramaSelected!(drama); - } else if (drama.id == widget.drama.id) { - logic.act( - context, - MyPageAction.resume, - embedded: widget.embedded, - onAction: widget.onAction, - ); - } - }, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Container( - padding: const EdgeInsets.all(AppDimens.innerPadding), - decoration: BoxDecoration( - color: AppColors.card, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - ), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: RemoteImage(drama.posterUrl, width: 74, height: 103), - ), - const SizedBox(width: 11), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: AppDimens.listTitle, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle( - color: AppColors.cream, - fontSize: AppDimens.caption, - ), - ), - ], - ), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.cream), - ], - ), - ), - ); - Widget _bottomNav(AppLocalizations l10n) => AppBottomNav( items: [ AppBottomNavItem( @@ -892,15 +865,100 @@ class _MyPageState extends State { ], ); - Widget _poster({required double width, required double height}) => ClipRRect( - borderRadius: BorderRadius.circular(7), - child: RemoteImage(widget.drama.posterUrl, width: width, height: height), + List get _recentDramas { + final pool = [...?widget.dramas, widget.drama]; + final result = []; + for (final item in logic.recent) { + for (final drama in pool) { + if (drama.id == item.dramaId && + !result.any((picked) => picked.id == drama.id)) { + result.add(drama); + } + } + } + return result; + } + + WatchProgress? _recordFor(String dramaId) { + for (final record in logic.recent) { + if (record.dramaId == dramaId) return record; + } + return null; + } + + String _episodeText(Drama drama, WatchProgress record) { + for (final episode in drama.episodes) { + if (episode.id == record.episodeId) { + return AppLocalizations.of( + context, + )!.episodeOfTotal(episode.number, drama.episodeCount); + } + } + return AppLocalizations.of(context)!.episodeCount(drama.episodeCount); + } + + void _resume(Drama drama) { + if (widget.onDramaSelected != null) { + widget.onDramaSelected!(drama); + } else { + logic.act( + context, + MyPageAction.resume, + embedded: widget.embedded, + onAction: widget.onAction, + ); + } + } + + void _mockSnack(AppLocalizations l10n) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.mockNotice))); + } + + Widget _poster( + Drama drama, { + required double width, + required double height, + required double radius, + }) => ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: RemoteImage(drama.posterUrl, width: width, height: height), ); + + String _clock(Duration duration) { + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } } -String _clock(Duration duration) { - final seconds = duration.inSeconds; - return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}'; -} +class _RoundHeaderButton extends StatelessWidget { + const _RoundHeaderButton({ + required this.tooltip, + required this.icon, + required this.onTap, + }); -const _accent = AppColors.blueSoft; + final String tooltip; + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 28, + height: 28, + child: IconButton( + tooltip: tooltip, + onPressed: onTap, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2C2D33), + foregroundColor: AppColors.onSurface, + ), + icon: Icon(icon, size: 14), + ), + ); + } +} diff --git a/test/account/mine_account_entry_test.dart b/test/account/mine_account_entry_test.dart index 7097af0..d0612ca 100644 --- a/test/account/mine_account_entry_test.dart +++ b/test/account/mine_account_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-account-card')), findsOneWidget); expect(find.byKey(const Key('mine-account-profile')), findsOneWidget); diff --git a/test/auth/auth_k21_test.dart b/test/auth/auth_k21_test.dart index 14fefef..6312819 100644 --- a/test/auth/auth_k21_test.dart +++ b/test/auth/auth_k21_test.dart @@ -82,4 +82,7 @@ Future _pumpAuth(WidgetTester tester) async { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } diff --git a/test/auth/auth_landing_test.dart b/test/auth/auth_landing_test.dart new file mode 100644 index 0000000..af8b6d7 --- /dev/null +++ b/test/auth/auth_landing_test.dart @@ -0,0 +1,65 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/auth_api.dart'; +import 'package:fruitshorts/core/data/services/auth_controller.dart'; +import 'package:fruitshorts/core/data/services/auth_session_store.dart'; +import 'package:fruitshorts/core/platform/haptics.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/auth/auth_view.dart'; + +import '../support/fake_haptics.dart'; +import '../support/fake_http_adapter.dart'; + +void main() { + testWidgets('should_show_three_login_entries_and_open_email_form', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final auth = AuthController( + AuthApi( + ApiClient( + baseUrl: 'http://127.0.0.1:1', + adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)), + ), + ), + AuthSessionStore(), + ); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.value(value: FakeHaptics()), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const AuthPage(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-google'))); + await tester.pumpAndSettle(); + expect(find.text('Coming soon'), findsOneWidget); + + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('auth-email')), findsOneWidget); + expect(find.byKey(const Key('auth-password')), findsOneWidget); + }); +} diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index ffa24e0..3a5851c 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -59,6 +59,9 @@ void main() { ), ); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); } testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', ( diff --git a/test/auth/auth_visual_test.dart b/test/auth/auth_visual_test.dart index 5f5e052..10c94f4 100644 --- a/test/auth/auth_visual_test.dart +++ b/test/auth/auth_visual_test.dart @@ -29,6 +29,7 @@ void main() { WidgetTester tester, { AuthApi? api, FakeHaptics? haptics, + bool openEmail = true, }) async { final auth = AuthController( api ?? @@ -57,20 +58,32 @@ void main() { ), ); await tester.pump(const Duration(milliseconds: 220)); + if (openEmail) { + await tester.ensureVisible(find.byKey(const Key('auth-entry-email'))); + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + } } testWidgets( 'should_render_branded_dark_layout_with_approved_tokens_when_page_opens', (tester) async { - await pumpAuthPage(tester); + await pumpAuthPage(tester, openEmail: false); - expect(find.byKey(const Key('auth-brand')), findsOneWidget); + expect(find.byKey(const Key('auth-landing')), findsOneWidget); expect(find.text('FruitShorts'), findsOneWidget); - expect(find.text('Short dramas made for Indonesia'), findsOneWidget); expect( - tester.getSize(find.byKey(const Key('auth-brand-mark'))), - const Size(48, 48), + find.text('Sign in to sync progress, favorites and coins'), + findsOneWidget, ); + expect( + tester.getSize(find.byKey(const Key('auth-brand-logo'))), + const Size(64, 64), + ); + expect(find.byKey(const Key('auth-entry-google')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget); + expect(find.byKey(const Key('auth-entry-email')), findsOneWidget); + expect(find.byKey(const Key('auth-legal')), findsOneWidget); final glow = tester.widget( find.byKey(const Key('auth-background-glow')), @@ -81,6 +94,9 @@ void main() { expect(color.a, lessThanOrEqualTo(0.18)); } + await tester.tap(find.byKey(const Key('auth-entry-email'))); + await tester.pumpAndSettle(); + final card = tester.widget(find.byKey(const Key('auth-card'))); final cardDecoration = card.decoration! as BoxDecoration; expect(cardDecoration.color, AppColors.card); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart new file mode 100644 index 0000000..cb9d013 --- /dev/null +++ b/test/mine/mine_preview_layout_test.dart @@ -0,0 +1,69 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/watch_progress.dart'; +import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; +import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_haptics.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/fake_notice_repository.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_me_user_wallet_continue_and_language_rows', ( + tester, + ) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: InMemoryWatchHistoryRepository( + seed: const [ + WatchProgress( + dramaId: 'city-lights-demo', + episodeId: 'city-lights-demo-1', + position: Duration(minutes: 2), + duration: Duration(minutes: 5), + ), + ], + ), + favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + noticeRepository: FakeNoticeRepository(), + haptics: FakeHaptics(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-my'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('mine-user-row')), findsOneWidget); + expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); + expect(find.byKey(const Key('mine-language')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); + expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/notice/notices_entry_test.dart b/test/notice/notices_entry_test.dart index db6d08b..1767c7f 100644 --- a/test/notice/notices_entry_test.dart +++ b/test/notice/notices_entry_test.dart @@ -46,6 +46,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-notices')), findsOneWidget); await tester.tap(find.byKey(const Key('mine-notices'))); diff --git a/test/watch/watch_history_provider_test.dart b/test/watch/watch_history_provider_test.dart index 932728f..2b10336 100644 --- a/test/watch/watch_history_provider_test.dart +++ b/test/watch/watch_history_provider_test.dart @@ -42,6 +42,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); + await tester.drag(find.byType(ListView), const Offset(0, -520)); + await tester.pumpAndSettle(); await tester.tap(find.text('Watch History')); await tester.pumpAndSettle(); From 8767b5644b483684966976e8d327155632c98559 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:50:24 +0800 Subject: [PATCH 09/30] =?UTF-8?q?test(ui):=20=E6=9B=B4=E6=96=B0=E4=B8=89?= =?UTF-8?q?=20Tab=20=E9=A2=84=E8=A7=88=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/home/home_nav_ui_test.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 79dca09..14ceed5 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -52,15 +52,14 @@ void main() { final decoration = bar.decoration as BoxDecoration; final border = decoration.border as Border; expect(decoration.color, AppColors.bar.withValues(alpha: 0.95)); - expect(border.top.color, AppColors.border); - expect(border.top.color.a, lessThan(0.2)); + expect(border.top.color, AppColors.outlineVariantDark); expect(border.top.color, isNot(Colors.white)); expect( tester - .widget( + .widget( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byType(AnimatedContainer), + matching: find.byType(AnimatedScale), ), ) .duration, @@ -69,7 +68,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_rounded), + matching: find.byIcon(Icons.play_circle_fill_rounded), ), findsOneWidget, ); @@ -90,7 +89,7 @@ void main() { expect( find.descendant( of: find.byKey(const Key('tab-home')), - matching: find.byIcon(Icons.home_outlined), + matching: find.byIcon(Icons.play_circle_outline_rounded), ), findsOneWidget, ); @@ -100,7 +99,9 @@ void main() { tester, ) async { await _pumpApp(tester); - await tester.tap(find.byTooltip('Clear screen')); + await tester.tap(find.byKey(const Key('home-feed-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear'))); await tester.pumpAndSettle(); for (final key in _keys) { From 5422581f8b2a02fbb51d1451852d79adb259ecc9 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 15:58:22 +0800 Subject: [PATCH 10/30] =?UTF-8?q?refactor(ui):=20=E6=B8=85=E7=90=86=20K26?= =?UTF-8?q?=20=E6=97=A7=20UI=20=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 4 - lib/l10n/app_id.arb | 4 - lib/l10n/app_localizations.dart | 24 -- lib/l10n/app_localizations_en.dart | 12 - lib/l10n/app_localizations_id.dart | 12 - lib/l10n/app_localizations_zh.dart | 12 - lib/l10n/app_zh.arb | 4 - .../discover/widgets/cover_carousel.dart | 310 ------------------ lib/pages/home/home_logic.dart | 23 +- lib/pages/home/home_view.dart | 39 ++- lib/pages/home/widgets/home_menu_sheet.dart | 246 ++++++++++++++ lib/pages/home/widgets/home_quick_drawer.dart | 287 ---------------- lib/pages/player/player_logic.dart | 55 +--- lib/pages/player/player_view.dart | 90 +---- .../player/widgets/home_feed_overlay.dart | 14 - test/discover/cover_carousel_test.dart | 193 ----------- test/discover/home_all_dramas_grid_test.dart | 4 - test/discover/home_banner_source_test.dart | 85 ----- test/home/home_logic_test.dart | 18 +- test/home/home_nav_ui_test.dart | 15 - test/player/player_fullscreen_test.dart | 3 - test/player/player_logic_test.dart | 28 +- test/widget_test.dart | 34 +- 23 files changed, 291 insertions(+), 1225 deletions(-) delete mode 100644 lib/pages/discover/widgets/cover_carousel.dart create mode 100644 lib/pages/home/widgets/home_menu_sheet.dart delete mode 100644 lib/pages/home/widgets/home_quick_drawer.dart delete mode 100644 test/discover/cover_carousel_test.dart delete mode 100644 test/discover/home_banner_source_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 671ee4f..74e71e8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -142,8 +142,6 @@ "reportFailed": "Could not submit report.", "shareFailed": "Could not open the share sheet.", "nowPlaying": "Playing now", - "watchFullscreen": "Watch in landscape", - "exitFullscreen": "Exit fullscreen", "pictureInPicture": "Picture in picture", "pipUnavailable": "Picture in picture is unavailable on this device", "originalTag": "Original", @@ -272,8 +270,6 @@ "myListTab": "My List", "watchFull": "Watch Full", "swipeForNext": "Swipe up for next", - "clearScreen": "Clear screen", - "exitClearScreen": "Exit clear screen", "logIn": "Log in", "logInHint": "Keep your coins and list", "rewards": "Rewards", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index c75f7c3..66274c6 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -142,8 +142,6 @@ "reportFailed": "Laporan tidak dapat dikirim.", "shareFailed": "Tidak bisa membuka menu berbagi.", "nowPlaying": "Sedang diputar", - "watchFullscreen": "Tonton mode lanskap", - "exitFullscreen": "Keluar dari layar penuh", "pictureInPicture": "Gambar dalam gambar", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "originalTag": "Original", @@ -272,8 +270,6 @@ "myListTab": "Daftar Saya", "watchFull": "Tonton Penuh", "swipeForNext": "Geser ke atas untuk lanjut", - "clearScreen": "Bersihkan layar", - "exitClearScreen": "Keluar dari mode bersih", "logIn": "Masuk", "logInHint": "Simpan koin dan daftar kamu", "rewards": "Hadiah", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cf14047..9d9ecb0 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -772,18 +772,6 @@ abstract class AppLocalizations { /// **'Playing now'** String get nowPlaying; - /// No description provided for @watchFullscreen. - /// - /// In en, this message translates to: - /// **'Watch in landscape'** - String get watchFullscreen; - - /// No description provided for @exitFullscreen. - /// - /// In en, this message translates to: - /// **'Exit fullscreen'** - String get exitFullscreen; - /// No description provided for @pictureInPicture. /// /// In en, this message translates to: @@ -1222,18 +1210,6 @@ abstract class AppLocalizations { /// **'Swipe up for next'** String get swipeForNext; - /// No description provided for @clearScreen. - /// - /// In en, this message translates to: - /// **'Clear screen'** - String get clearScreen; - - /// No description provided for @exitClearScreen. - /// - /// In en, this message translates to: - /// **'Exit clear screen'** - String get exitClearScreen; - /// No description provided for @logIn. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3a00090..0fd6034 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -360,12 +360,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get nowPlaying => 'Playing now'; - @override - String get watchFullscreen => 'Watch in landscape'; - - @override - String get exitFullscreen => 'Exit fullscreen'; - @override String get pictureInPicture => 'Picture in picture'; @@ -604,12 +598,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get swipeForNext => 'Swipe up for next'; - @override - String get clearScreen => 'Clear screen'; - - @override - String get exitClearScreen => 'Exit clear screen'; - @override String get logIn => 'Log in'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 5f2b421..4947298 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -360,12 +360,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get nowPlaying => 'Sedang diputar'; - @override - String get watchFullscreen => 'Tonton mode lanskap'; - - @override - String get exitFullscreen => 'Keluar dari layar penuh'; - @override String get pictureInPicture => 'Gambar dalam gambar'; @@ -606,12 +600,6 @@ class AppLocalizationsId extends AppLocalizations { @override String get swipeForNext => 'Geser ke atas untuk lanjut'; - @override - String get clearScreen => 'Bersihkan layar'; - - @override - String get exitClearScreen => 'Keluar dari mode bersih'; - @override String get logIn => 'Masuk'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b3fb2b3..6010528 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -354,12 +354,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get nowPlaying => '正在播放'; - @override - String get watchFullscreen => '横屏全屏观看'; - - @override - String get exitFullscreen => '退出全屏'; - @override String get pictureInPicture => '画中画'; @@ -593,12 +587,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get swipeForNext => '上滑看下一部'; - @override - String get clearScreen => '清屏'; - - @override - String get exitClearScreen => '退出清屏'; - @override String get logIn => '登录'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0ddd3bb..493979a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -142,8 +142,6 @@ "reportFailed": "举报提交失败,请重试。", "shareFailed": "无法打开系统分享面板。", "nowPlaying": "正在播放", - "watchFullscreen": "横屏全屏观看", - "exitFullscreen": "退出全屏", "pictureInPicture": "画中画", "pipUnavailable": "此设备无法使用画中画", "originalTag": "原创", @@ -234,8 +232,6 @@ "myListTab": "片单", "watchFull": "看全集", "swipeForNext": "上滑看下一部", - "clearScreen": "清屏", - "exitClearScreen": "退出清屏", "logIn": "登录", "logInHint": "登录后保存金币和片单", "rewards": "奖励", diff --git a/lib/pages/discover/widgets/cover_carousel.dart b/lib/pages/discover/widgets/cover_carousel.dart deleted file mode 100644 index 0fdb0b1..0000000 --- a/lib/pages/discover/widgets/cover_carousel.dart +++ /dev/null @@ -1,310 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; - -const Duration _autoPlayInterval = Duration(milliseconds: 4500); - -class CoverItem { - const CoverItem({ - required this.title, - required this.subtitle, - required this.image, - required this.onOpen, - }); - - final String title; - final String subtitle; - final String image; - final VoidCallback onOpen; -} - -/// Full-bleed posters that turn as faces of a cube. Titles sit on the image. -class CoverCarousel extends StatefulWidget { - const CoverCarousel({super.key, required this.items, this.active = true}); - - final List items; - final bool active; - - @override - State createState() => _CoverCarouselState(); -} - -class _CoverCarouselState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _snap; - double _page = 0; - double _from = 0; - double _to = 0; - int? _pointer; - double _drag = 0; - double _dragPage = 0; - Timer? _autoTimer; - bool _tickerEnabled = true; - - @override - void initState() { - super.initState(); - _snap = AnimationController(vsync: this)..addListener(_onSnap); - } - - @override - void dispose() { - _autoTimer?.cancel(); - _snap.dispose(); - super.dispose(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final enabled = TickerMode.valuesOf(context).enabled; - if (enabled != _tickerEnabled) { - _tickerEnabled = enabled; - _syncAutoTimer(); - } else if (_autoTimer == null) { - _syncAutoTimer(); - } - } - - @override - void didUpdateWidget(covariant CoverCarousel oldWidget) { - super.didUpdateWidget(oldWidget); - if (_page > _last) _page = _last; - if (oldWidget.items.length != widget.items.length || - oldWidget.active != widget.active) { - _syncAutoTimer(); - } - } - - double get _last => math.max(0, widget.items.length - 1).toDouble(); - - void _onSnap() { - if (!mounted) return; - final t = Curves.easeOutCubic.transform(_snap.value); - setState(() => _page = _from + (_to - _from) * t); - } - - void _turnTo(double target) { - if (!mounted || widget.items.isEmpty) return; - _from = _page; - _to = target.clamp(0, _last); - _snap.duration = const Duration(milliseconds: 420); - _snap.forward(from: 0); - } - - void _syncAutoTimer() { - _autoTimer?.cancel(); - _autoTimer = null; - if (!mounted || - !widget.active || - !_tickerEnabled || - widget.items.length <= 1) { - return; - } - _autoTimer = Timer.periodic(_autoPlayInterval, (_) { - if (!mounted || _pointer != null || !widget.active || !_tickerEnabled) { - return; - } - final current = _page.round().clamp(0, _last.toInt()); - if (current >= _last.toInt()) { - setState(() => _page = 0); - } else { - _turnTo((current + 1).toDouble()); - } - }); - } - - @override - Widget build(BuildContext context) { - if (widget.items.isEmpty) return const SizedBox.shrink(); - final current = _page.round().clamp(0, widget.items.length - 1); - final l10n = AppLocalizations.of(context)!; - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final height = math.min(width * 0.52, 210).toDouble(); - return Semantics( - container: true, - label: l10n.carouselSemantics(current + 1, widget.items.length), - child: SizedBox( - width: double.infinity, - height: height, - child: Stack( - children: [ - Positioned.fill(child: _stage(width)), - _indicator(l10n, current), - ], - ), - ), - ); - }, - ); - } - - Widget _stage(double width) { - return Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (event) { - _autoTimer?.cancel(); - _autoTimer = null; - _snap.stop(); - _pointer = event.pointer; - _drag = 0; - _dragPage = _page; - }, - onPointerMove: (event) { - if (event.pointer != _pointer) return; - _drag += event.delta.dx; - setState(() { - _page = (_dragPage - _drag / width).clamp(0.0, _last); - }); - }, - onPointerUp: (event) { - if (event.pointer != _pointer) return; - _pointer = null; - _turnTo(_page.roundToDouble()); - _syncAutoTimer(); - }, - onPointerCancel: (event) { - if (event.pointer != _pointer) return; - _pointer = null; - _turnTo(_page.roundToDouble()); - _syncAutoTimer(); - }, - child: ClipRect( - child: Stack( - fit: StackFit.expand, - children: [for (final index in _visible) _face(index, width)], - ), - ), - ); - } - - Widget _indicator(AppLocalizations l10n, int current) { - return Positioned( - left: 0, - right: 0, - bottom: 8, - child: Semantics( - label: l10n.carouselSemantics(current + 1, widget.items.length), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (var index = 0; index < widget.items.length; index++) - Container( - key: Key('carousel-dot-$index'), - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: index == current - ? AppColors.onPrimary - : AppColors.muted.withValues(alpha: 0.4), - ), - ), - ], - ), - ), - ); - } - - List get _visible { - final first = _page.floor(); - final indexes = [first, first + 1]; - indexes.retainWhere((index) => index >= 0 && index < widget.items.length); - indexes.sort((a, b) => (b - _page).abs().compareTo((a - _page).abs())); - return indexes; - } - - Widget _face(int index, double width) { - final delta = index - _page; - if (delta <= -1 || delta >= 1) return const SizedBox.shrink(); - final item = widget.items[index]; - final angle = delta * math.pi / 2; - // Push the face out to the cube surface, spin the whole cube on its - // vertical center, then bring that center back to the banner. - final face = Matrix4.identity() - ..translateByDouble(0, 0, -width / 2, 1) - ..rotateY(angle) - ..translateByDouble(0, 0, width / 2, 1); - final transform = Matrix4.identity() - ..setEntry(3, 2, 0.0018) - ..multiply(face); - return Positioned.fill( - child: Transform( - alignment: Alignment.center, - filterQuality: FilterQuality.medium, - transform: transform, - child: GestureDetector( - onTap: () { - if ((_page - index).abs() < 0.08) item.onOpen(); - }, - child: _poster(item), - ), - ), - ); - } - - Widget _poster(CoverItem item) { - return Stack( - fit: StackFit.expand, - children: [ - RemoteImage( - item.image, - showFallbackIcon: true, - fallbackIconKey: const Key('carousel-image-fallback'), - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x00000000), Color(0x00000000), Color(0xE6000000)], - stops: [0, 0.5, 1], - ), - ), - ), - Positioned( - left: 20, - right: 20, - bottom: 30, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w600, - height: 1.15, - shadows: [Shadow(blurRadius: 12, color: Colors.black)], - ), - ), - const SizedBox(height: 6), - Text( - item.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Color(0xF2FFFFFF), - fontSize: 14, - fontWeight: FontWeight.w400, - height: 1.2, - shadows: [Shadow(blurRadius: 8, color: Colors.black)], - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/pages/home/home_logic.dart b/lib/pages/home/home_logic.dart index d4a6e49..307b42c 100644 --- a/lib/pages/home/home_logic.dart +++ b/lib/pages/home/home_logic.dart @@ -49,21 +49,20 @@ class HomeLogic extends ChangeNotifier { notify(); } - void openMenu() { - drawerOpen = true; + void setMenuOpen(bool value) { + if (drawerOpen == value) return; + drawerOpen = value; notify(); - scaffoldKey.currentState?.openDrawer(); } void selectFromMenu(int nextTab, {int? mySection}) { - scaffoldKey.currentState?.closeDrawer(); + drawerOpen = false; select(nextTab, mySection: mySection); } Future openCurrentSeries(BuildContext context) async { detailOpen = true; notify(); - scaffoldKey.currentState?.closeDrawer(); await WidgetsBinding.instance.endOfFrame; if (!context.mounted) return; try { @@ -102,13 +101,11 @@ class HomeLogic extends ChangeNotifier { void openProfile() { profile = true; - scaffoldKey.currentState?.closeDrawer(); select(2, mySection: 0); } void openLibrary({int section = 1}) { profile = false; - scaffoldKey.currentState?.closeDrawer(); select(2, mySection: section); } @@ -139,24 +136,14 @@ class HomeLogic extends ChangeNotifier { } void onSystemBack() { - if (drawerOpen) { - scaffoldKey.currentState?.closeDrawer(); - } else if (videoImmersive) { + if (videoImmersive) { backRequest++; notify(); } else if (tab != 1) { select(1); - } else { - openMenu(); } } - void onDrawerChanged(bool opened) { - if (drawerOpen == opened) return; - drawerOpen = opened; - notify(); - } - void onDramaChanged(Drama drama) { currentDrama = drama; notify(); diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 2bdb917..2c9377c 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -24,7 +24,7 @@ import '../mine/mine_view.dart'; import '../player/player_view.dart'; import 'home_binding.dart'; import 'home_logic.dart'; -import 'widgets/home_quick_drawer.dart'; +import 'widgets/home_menu_sheet.dart'; class HomePage extends StatelessWidget { const HomePage({super.key}); @@ -173,21 +173,15 @@ class _HomeTabsState extends State<_HomeTabs> { canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - logic.onSystemBack(); + if (!logic.videoImmersive && logic.tab == 1) { + unawaited(_openMenu(context)); + } else { + logic.onSystemBack(); + } }, child: Scaffold( key: logic.scaffoldKey, backgroundColor: AppColors.ink, - drawer: HomeQuickDrawer( - drama: logic.currentDrama, - onOpenSeries: () => logic.openCurrentSeries(context), - onDiscover: () => logic.selectFromMenu(1), - onHistory: () => logic.openLibrary(section: 0), - onWatchlist: logic.openLibrary, - onLiked: () => logic.selectFromMenu(2, mySection: 2), - ), - drawerEnableOpenDragGesture: false, - onDrawerChanged: logic.onDrawerChanged, body: IndexedStack( index: logic.tab, children: [ @@ -204,7 +198,7 @@ class _HomeTabsState extends State<_HomeTabs> { onDiscover: () => logic.select(1), onMy: logic.openProfile, onSearch: () => logic.select(1, search: true), - onMenu: logic.openMenu, + onMenu: () => _openMenu(context), onImmersiveChanged: logic.onImmersiveChanged, ), DiscoverPage( @@ -284,4 +278,23 @@ class _HomeTabsState extends State<_HomeTabs> { logic.select(tab); } } + + Future _openMenu(BuildContext context) async { + logic.setMenuOpen(true); + await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + isScrollControlled: true, + builder: (_) => HomeMenuSheet( + drama: logic.currentDrama, + onOpenSeries: () => logic.openCurrentSeries(context), + onDiscover: () => logic.selectFromMenu(1), + onHistory: () => logic.openLibrary(section: 0), + onWatchlist: logic.openLibrary, + onLiked: () => logic.selectFromMenu(2, mySection: 2), + ), + ); + logic.setMenuOpen(false); + } } diff --git a/lib/pages/home/widgets/home_menu_sheet.dart b/lib/pages/home/widgets/home_menu_sheet.dart new file mode 100644 index 0000000..e870bd4 --- /dev/null +++ b/lib/pages/home/widgets/home_menu_sheet.dart @@ -0,0 +1,246 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../core/config/locale_controller.dart'; +import '../../../core/data/models/drama.dart'; +import '../../../core/formatting/localized_content.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; +import '../../../l10n/app_localizations.dart'; + +/// Compact navigation menu opened from the preview home header. +class HomeMenuSheet extends StatelessWidget { + const HomeMenuSheet({ + super.key, + required this.drama, + required this.onOpenSeries, + required this.onDiscover, + required this.onHistory, + required this.onWatchlist, + required this.onLiked, + }); + + final Drama drama; + final VoidCallback onOpenSeries; + final VoidCallback onDiscover; + final VoidCallback onHistory; + final VoidCallback onWatchlist; + final VoidCallback onLiked; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final languageCode = Localizations.localeOf(context).languageCode; + void closeThen(VoidCallback action) { + Navigator.pop(context); + action(); + } + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardAlt, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: RemoteImage(drama.posterUrl, width: 64, height: 86), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + localizedDramaTitle(l10n, drama), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + l10n.episodeCount(drama.episodeCount), + style: const TextStyle( + color: AppColors.muted, + fontSize: 9, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 34, + child: FilledButton.icon( + key: const Key('drawer-open-series'), + onPressed: () => closeThen(onOpenSeries), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + textStyle: const TextStyle(fontSize: 11), + ), + icon: const Icon( + Icons.play_arrow_rounded, + size: 15, + ), + label: Text(l10n.exploreSeries), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Text( + l10n.quickAccess, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-discover'), + icon: Icons.explore_outlined, + label: l10n.discoverTab, + onTap: () => closeThen(onDiscover), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-history'), + icon: Icons.history_rounded, + label: l10n.history, + onTap: () => closeThen(onHistory), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _MenuTile( + key: const Key('drawer-watchlist'), + icon: Icons.bookmark_border_rounded, + label: l10n.watchlist, + onTap: () => closeThen(onWatchlist), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _MenuTile( + key: const Key('drawer-liked'), + icon: Icons.favorite_border_rounded, + label: l10n.likedTab, + onTap: () => closeThen(onLiked), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + l10n.appLanguage, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final (code, label) in [ + ('en', l10n.english), + ('zh', l10n.simplifiedChinese), + ('id', l10n.bahasaIndonesia), + ]) + ChoiceChip( + label: Text(label), + selected: languageCode == code, + onSelected: (_) => + context.read().select(code), + backgroundColor: AppColors.input, + selectedColor: AppColors.blue, + labelStyle: TextStyle( + color: languageCode == code + ? AppColors.onPrimary + : AppColors.muted, + fontSize: 10, + ), + side: BorderSide.none, + showCheckmark: false, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile({ + super.key, + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.quick, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + height: 58, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: AppColors.secondary, size: 16), + const SizedBox(height: 4), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.muted, fontSize: 9), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/home/widgets/home_quick_drawer.dart b/lib/pages/home/widgets/home_quick_drawer.dart deleted file mode 100644 index b18575f..0000000 --- a/lib/pages/home/widgets/home_quick_drawer.dart +++ /dev/null @@ -1,287 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../core/config/locale_controller.dart'; -import '../../../core/data/models/drama.dart'; -import '../../../core/formatting/localized_content.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_dimens.dart'; -import '../../../core/widgets/remote_image.dart'; -import '../../../l10n/app_localizations.dart'; - -/// Shortcuts for the existing root tabs; this widget owns no navigation state. -class HomeQuickDrawer extends StatelessWidget { - const HomeQuickDrawer({ - super.key, - required this.drama, - required this.onOpenSeries, - required this.onDiscover, - required this.onHistory, - required this.onWatchlist, - required this.onLiked, - }); - - final Drama drama; - final VoidCallback onOpenSeries; - final VoidCallback onDiscover; - final VoidCallback onHistory; - final VoidCallback onWatchlist; - final VoidCallback onLiked; - - static const _panel = AppColors.card; - static const _card = AppColors.raised; - static const _accent = AppColors.blueSoft; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final languageCode = Localizations.localeOf(context).languageCode; - return Drawer( - width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0), - backgroundColor: _panel, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.horizontal(right: Radius.circular(20)), - ), - child: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 24), - children: [ - Row( - children: [ - const CircleAvatar( - radius: 30, - backgroundColor: AppColors.raised, - child: Icon( - Icons.person_rounded, - size: 34, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.localViewer, - style: const TextStyle( - color: Colors.white, - fontSize: 19, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 3), - Text( - l10n.localDemoProfile, - style: const TextStyle(color: AppColors.cream), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 26), - _SectionCard( - title: l10n.currentSeries, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - AppDimens.innerRadius, - ), - child: RemoteImage( - drama.posterUrl, - width: 74, - height: 100, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - localizedDramaTitle(l10n, drama), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w700, - fontSize: 16, - ), - ), - const SizedBox(height: 7), - Text( - l10n.episodeCount(drama.episodeCount), - style: const TextStyle(color: AppColors.cream), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 14), - SizedBox( - width: double.infinity, - child: FilledButton.icon( - key: const Key('drawer-open-series'), - onPressed: onOpenSeries, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - foregroundColor: AppColors.onPrimary, - ), - icon: const Icon(Icons.play_arrow_rounded), - label: Text(l10n.exploreSeries), - ), - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.quickAccess, - child: Column( - children: [ - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-discover'), - icon: Icons.explore_outlined, - label: l10n.discoverTab, - onTap: onDiscover, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-history'), - icon: Icons.history_rounded, - label: l10n.history, - onTap: onHistory, - ), - ), - ], - ), - const SizedBox(height: 9), - Row( - children: [ - Expanded( - child: _QuickAction( - key: const Key('drawer-watchlist'), - icon: Icons.bookmark_border_rounded, - label: l10n.watchlist, - onTap: onWatchlist, - ), - ), - const SizedBox(width: 9), - Expanded( - child: _QuickAction( - key: const Key('drawer-liked'), - icon: Icons.favorite_border_rounded, - label: l10n.likedTab, - onTap: onLiked, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 14), - _SectionCard( - title: l10n.appLanguage, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final (code, label) in [ - ('en', l10n.english), - ('zh', l10n.simplifiedChinese), - ('id', l10n.bahasaIndonesia), - ]) - ChoiceChip( - label: Text(label, maxLines: 1), - selected: languageCode == code, - onSelected: (_) => - context.read().select(code), - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _SectionCard extends StatelessWidget { - const _SectionCard({required this.title, required this.child}); - - final String title; - final Widget child; - - @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: HomeQuickDrawer._card, - borderRadius: BorderRadius.circular(AppDimens.cardRadius), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700), - ), - const SizedBox(height: 14), - child, - ], - ), - ); -} - -class _QuickAction extends StatelessWidget { - const _QuickAction({ - super.key, - required this.icon, - required this.label, - required this.onTap, - }); - - final IconData icon; - final String label; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) => Material( - color: AppColors.raised, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppDimens.innerRadius), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7), - child: Column( - children: [ - Icon(icon, color: HomeQuickDrawer._accent, size: 25), - const SizedBox(height: 6), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ), - ); -} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 64c459e..7362e1d 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/data/models/drama.dart'; @@ -71,10 +70,8 @@ class PlayerLogic extends ChangeNotifier { bool muted = false; bool stoppedAtFeedEnd = false; VideoPlayerPort? scrubbingPlayer; - bool fullscreen = false; bool pip = false; bool pipPreparing = false; - bool screenCleared = false; final Map pickedEpisode = {}; bool Function() isMounted = () => false; @@ -98,9 +95,9 @@ class PlayerLogic extends ChangeNotifier { /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 bool _started = false; - bool get immersive => fullscreen || pip || pipPreparing; + bool get immersive => pip || pipPreparing; - bool get chromeHidden => immersive || screenCleared; + bool get chromeHidden => immersive; Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; @@ -111,13 +108,6 @@ class PlayerLogic extends ChangeNotifier { return fallback; } - void setScreenCleared(bool value) { - if (screenCleared == value) return; - screenCleared = value; - notify(); - notifyImmersive(); - } - void notify() { if (!_disposed) notifyListeners(); } @@ -262,13 +252,7 @@ class PlayerLogic extends ChangeNotifier { final request = backRequest; WidgetsBinding.instance.addPostFrameCallback((_) { if (isMounted() && backRequest == request) { - if (fullscreen) { - unawaited(setFullscreen(false)); - } else if (screenCleared) { - setScreenCleared(false); - } else { - onMenu?.call(); - } + onMenu?.call(); } }); } @@ -295,12 +279,6 @@ class PlayerLogic extends ChangeNotifier { if (_disposed) return; _disposed = true; pictureInPicture.setStateListener(null); - if (fullscreen) { - unawaited( - SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), - ); - unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)); - } unawaited(saveCurrentProgress()); if (_started) { session.setRouteVisible(false); @@ -313,32 +291,6 @@ class PlayerLogic extends ChangeNotifier { void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); - Future setFullscreen(bool value) async { - if (fullscreen == value || !isMounted()) return; - fullscreen = value; - notify(); - notifyImmersive(); - try { - await SystemChrome.setPreferredOrientations( - value - ? [ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ] - : [DeviceOrientation.portraitUp], - ); - await SystemChrome.setEnabledSystemUIMode( - value ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, - ); - } catch (_) { - if (value && isMounted()) { - fullscreen = false; - notify(); - notifyImmersive(); - } - } - } - Future enterPip(BuildContext context) async { if (pip || pipPreparing) return; if (session.active is! VideoPlayerPort) { @@ -347,7 +299,6 @@ class PlayerLogic extends ChangeNotifier { ); return; } - if (fullscreen) await setFullscreen(false); pipPreparing = true; notify(); notifyImmersive(); diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 63a1b63..997cfe6 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -162,7 +162,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.preparedPlayerAt(index); final videoPort = port is VideoPlayerPort ? port : null; final ready = videoPort?.controller.value.isInitialized ?? false; - final theater = logic.fullscreen || logic.pip || logic.pipPreparing; + final theater = logic.pip || logic.pipPreparing; Widget? video; if (ready) { video = GestureDetector( @@ -322,20 +322,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() - else if (logic.fullscreen) - _FullscreenControls( - l10n: l10n, - controller: ready ? videoPort!.controller : null, - onClose: () => unawaited(logic.setFullscreen(false)), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - onScrubStart: ready ? () => logic.beginScrub(videoPort!) : null, - onSeek: ready - ? (position) => logic.seekTo(context, videoPort!, position) - : null, - ) - else if (!logic.screenCleared && widget.homeMode) + else if (widget.homeMode) HomeFeedOverlay( l10n: l10n, drama: drama, @@ -375,13 +362,12 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), onDownload: () => requestEpisodeDownload(context, episode.id), - onClear: () => logic.setScreenCleared(true), onSearch: widget.onSearch ?? () => logic.showDiscover(context), onPip: logic.pictureInPicture.isPlatformSupported ? () => unawaited(logic.enterPip(context)) : null, ) - else if (!logic.screenCleared) + else _PlaybackOverlay( l10n: l10n, drama: drama, @@ -441,81 +427,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? (position) => logic.seekTo(context, videoPort!, position) : null, ), - if (logic.screenCleared) - SafeArea( - child: Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.only(top: 4, right: 8), - child: IconButton( - tooltip: l10n.exitClearScreen, - onPressed: () => logic.setScreenCleared(false), - style: IconButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: const Color(0x59000000), - ), - icon: const Icon(Icons.close_fullscreen_rounded), - ), - ), - ), - ), ], ); } } -class _FullscreenControls extends StatelessWidget { - const _FullscreenControls({ - required this.l10n, - required this.controller, - required this.onClose, - required this.onPip, - required this.onScrubStart, - required this.onSeek, - }); - - final AppLocalizations l10n; - final VideoPlayerController? controller; - final VoidCallback onClose; - final VoidCallback? onPip; - final VoidCallback? onScrubStart; - final Future Function(Duration)? onSeek; - - @override - Widget build(BuildContext context) => SafeArea( - child: Column( - children: [ - Row( - children: [ - IconButton( - tooltip: l10n.exitFullscreen, - onPressed: onClose, - icon: const Icon(Icons.close_fullscreen_rounded), - ), - const Spacer(), - if (onPip != null) - IconButton( - tooltip: l10n.pictureInPicture, - onPressed: onPip, - icon: const Icon(Icons.picture_in_picture_alt_outlined), - ), - ], - ), - const Spacer(), - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), - child: SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - ), - ], - ), - ); -} - /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 class _BlockedNotice extends StatelessWidget { const _BlockedNotice({ diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 1e0de71..faca527 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -26,7 +26,6 @@ class HomeFeedOverlay extends StatelessWidget { required this.onShare, required this.onEpisodes, required this.onDownload, - required this.onClear, required this.onSearch, this.onPip, }); @@ -42,7 +41,6 @@ class HomeFeedOverlay extends StatelessWidget { final VoidCallback onShare; final VoidCallback onEpisodes; final VoidCallback onDownload; - final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; @@ -332,18 +330,6 @@ class HomeFeedOverlay extends StatelessWidget { onPip!(); }, ), - ListTile( - key: const Key('home-feed-clear'), - leading: const Icon( - Icons.fit_screen_outlined, - color: AppColors.secondary, - ), - title: Text(l10n.clearScreen), - onTap: () { - Navigator.pop(sheetContext); - onClear(); - }, - ), ], ), ), diff --git a/test/discover/cover_carousel_test.dart b/test/discover/cover_carousel_test.dart deleted file mode 100644 index 75040b6..0000000 --- a/test/discover/cover_carousel_test.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/l10n/app_localizations.dart'; -import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - List items(int count) => [ - for (var index = 0; index < count; index++) - CoverItem( - title: 'Banner $index', - subtitle: 'Subtitle $index', - image: '/files/banner-$index.png', - onOpen: _noop, - ), - ]; - - testWidgets('should_turn_cube_and_keep_title_on_poster_when_carousel_pages', ( - tester, - ) async { - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: [ - for (final title in ['The Signal', 'City Lights', 'Vengeance']) - CoverItem( - title: title, - subtitle: 'Mock', - image: '/files/poster.png', - onOpen: _noop, - ), - ], - ), - ), - ); - await tester.pump(); - - expect(find.text('The Signal'), findsOneWidget); - expect(find.text('Mock'), findsOneWidget); - - final box = tester.getRect(find.byType(CoverCarousel)); - final gesture = await tester.startGesture( - Offset(box.right - 40, box.center.dy), - ); - await gesture.moveBy(const Offset(-500, 0)); - await tester.pump(); - - final turned = tester - .widgetList(find.byType(Transform)) - .any((transform) => (transform.transform.entry(0, 0) - 1).abs() > 0.15); - expect(turned, isTrue); - - await gesture.moveBy(const Offset(-200, 0)); - await gesture.up(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 450)); - - expect(find.text('City Lights'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('should_auto_advance_and_show_dots_when_multiple_items_exist', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3)))); - await tester.pump(); - - expect(find.text('Banner 0'), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-2')), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_not_schedule_auto_advance_when_only_one_item_exists', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(1)))); - await tester.pump(const Duration(seconds: 12)); - - expect(find.text('Banner 0'), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-0')), findsOneWidget); - expect(find.byKey(const Key('carousel-dot-1')), findsNothing); - }); - - testWidgets('should_pause_auto_advance_while_pointer_is_down', ( - tester, - ) async { - await tester.pumpWidget(_localizedApp(CoverCarousel(items: items(3)))); - await tester.pump(); - final box = tester.getRect(find.byType(CoverCarousel)); - final gesture = await tester.startGesture(box.center); - await tester.pump(const Duration(seconds: 6)); - - expect(find.text('Banner 0'), findsOneWidget); - - await gesture.up(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_pause_when_inactive_and_resume_when_active_again', ( - tester, - ) async { - final active = ValueNotifier(false); - await tester.pumpWidget( - _localizedApp( - ValueListenableBuilder( - valueListenable: active, - builder: (context, enabled, _) => - CoverCarousel(items: items(2), active: enabled), - ), - ), - ); - await tester.pump(const Duration(seconds: 6)); - expect(find.text('Banner 0'), findsOneWidget); - - active.value = true; - await tester.pump(); - await tester.pump(const Duration(milliseconds: 4500)); - await tester.pump(const Duration(milliseconds: 500)); - expect(find.text('Banner 1'), findsOneWidget); - await tester.pumpWidget(const SizedBox.shrink()); - }); - - testWidgets('should_show_icon_fallback_when_banner_image_is_empty', ( - tester, - ) async { - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: const [ - CoverItem( - title: 'Broken image', - subtitle: '', - image: '', - onOpen: _noop, - ), - ], - ), - ), - ); - await tester.pump(); - - expect(find.byKey(const Key('carousel-image-fallback')), findsOneWidget); - }); - - testWidgets( - 'should_keep_existing_open_callback_when_active_poster_is_tapped', - (tester) async { - var opened = 0; - await tester.pumpWidget( - _localizedApp( - CoverCarousel( - items: [ - CoverItem( - title: 'Tap me', - subtitle: '', - image: '/files/banner.png', - onOpen: () => opened++, - ), - ...items(1), - ], - ), - ), - ); - await tester.pump(); - - await tester.tap(find.text('Tap me')); - expect(opened, 1); - await tester.pumpWidget(const SizedBox.shrink()); - }, - ); -} - -void _noop() {} - -Widget _localizedApp(Widget child) => MaterialApp( - locale: const Locale('en'), - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold(body: child), -); diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 3ff02ef..79d4be9 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -38,7 +38,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Continue Watching'), findsNothing); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('Recommended'), findsOneWidget); expect(find.text('For You'), findsNothing); final grid = tester.widget( @@ -133,7 +132,6 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('No matching dramas.'), findsOneWidget); expect(find.byKey(const Key('empty-state-content')), findsOneWidget); @@ -153,7 +151,6 @@ void main() { await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('Categories could not be loaded.'), findsOneWidget); expect(find.text('Retry'), findsOneWidget); expect(find.byKey(const Key('empty-state-icon')), findsOneWidget); @@ -179,7 +176,6 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-all-dramas-title')), findsNothing); expect(find.text('City Lights & Velvet Nights'), findsWidgets); expect(tester.takeException(), isNull); }); diff --git a/test/discover/home_banner_source_test.dart b/test/discover/home_banner_source_test.dart deleted file mode 100644 index 0c5d90c..0000000 --- a/test/discover/home_banner_source_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -// Author: fruit.com.cn - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:fruitshorts/app.dart'; -import 'package:fruitshorts/core/data/models/drama.dart'; -import 'package:fruitshorts/core/data/models/home_feed.dart'; -import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; -import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; -import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; -import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/pages/discover/widgets/cover_carousel.dart'; -import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; -import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; - -import '../support/fake_catalog.dart'; -import '../support/fake_category.dart'; -import '../support/fake_interaction_repositories.dart'; -import '../support/fake_notice_repository.dart'; -import '../support/widget_player.dart'; - -void main() { - testWidgets( - 'should_not_mount_carousel_on_discover_even_when_home_banners_exist', - (tester) async { - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - final catalog = _HomeBannerCatalogRepository(); - await tester.pumpWidget( - LumaDramaApp( - playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), - watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(), - playbackRepository: FakePlaybackRepository(), - commentRepository: FakeCommentRepository(), - interactionRepository: FakeInteractionRepository(), - noticeRepository: FakeNoticeRepository(), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-discover'))); - await tester.pumpAndSettle(); - - expect(find.byType(CoverCarousel), findsNothing); - expect(find.byKey(const Key('discover-search')), findsOneWidget); - expect(find.byKey(const Key('home-all-dramas-grid')), findsOneWidget); - expect(find.byKey(const Key('discover-quick-filter')), findsOneWidget); - expect(find.text('The Signal'), findsWidgets); - await tester.pumpWidget(const SizedBox.shrink()); - }, - ); -} - -class _HomeBannerCatalogRepository implements CatalogRepository { - final _delegate = FakeCatalogRepository(); - - @override - Future fetchHomeFeed() async => HomeFeed( - banners: const [ - FeedBanner( - id: 'banner-1', - imageUrl: '/files/banner-1.png', - linkType: 'DRAMA', - linkValue: 'the-signal-demo', - ), - FeedBanner( - id: 'banner-2', - imageUrl: '/files/banner-2.png', - linkType: 'DRAMA', - linkValue: 'city-lights-demo', - ), - ], - sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)], - ); - - @override - Future fetchDiscoverFeed() async => HomeFeed( - sections: [FeedSection(code: 'hot', title: 'Hot', dramas: demoDramas)], - ); - - @override - Future fetchDrama(String dramaId) => _delegate.fetchDrama(dramaId); -} diff --git a/test/home/home_logic_test.dart b/test/home/home_logic_test.dart index 7daa918..85c609c 100644 --- a/test/home/home_logic_test.dart +++ b/test/home/home_logic_test.dart @@ -109,16 +109,15 @@ void main() { }, ); - test('should_return_home_and_open_drawer_when_system_back_is_pressed', () { + test('should_return_to_discover_and_handle_immersive_back', () { final logic = HomeLogic(dramas); // Home is the launch tab; the first back press lands on Discover. logic.onSystemBack(); expect(logic.tab, 1); logic.onSystemBack(); - expect(logic.drawerOpen, isTrue); + expect(logic.tab, 1); expect(logic.backRequest, 0); - logic.onDrawerChanged(false); logic.select(0); logic.onSystemBack(); @@ -131,30 +130,25 @@ void main() { expect(logic.backRequest, 1); expect(logic.tab, 0); - logic.onDrawerChanged(true); - logic.onSystemBack(); - expect(logic.backRequest, 1); - - logic.onDrawerChanged(false); logic.onImmersiveChanged(false); logic.onSystemBack(); expect(logic.tab, 1); logic.dispose(); }); - test('should_notify_only_on_actual_change_when_drawer_state_is_set', () { + test('should_notify_only_on_actual_change_when_menu_state_is_set', () { final logic = HomeLogic(dramas); var notifications = 0; logic.addListener(() => notifications++); - logic.onDrawerChanged(false); + logic.setMenuOpen(false); expect(notifications, 0); - logic.onDrawerChanged(true); + logic.setMenuOpen(true); expect(logic.drawerOpen, isTrue); expect(notifications, 1); - logic.onDrawerChanged(true); + logic.setMenuOpen(true); expect(notifications, 1); logic.dispose(); }); diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 14ceed5..0c9d15a 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -95,24 +95,9 @@ void main() { ); }); - testWidgets('should_hide_themed_navigation_when_immersive_mode_is_enabled', ( - tester, - ) async { - await _pumpApp(tester); - await tester.tap(find.byKey(const Key('home-feed-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-clear'))); - await tester.pumpAndSettle(); - - for (final key in _keys) { - expect(find.byKey(key), findsNothing); - } - }); - testWidgets('should_map_three_tabs_to_home_discover_and_me', (tester) async { await _pumpApp(tester); - expect(find.byKey(const Key('tab-my-list')), findsNothing); expect(find.byKey(const Key('tab-home')), findsOneWidget); expect(find.byKey(const Key('tab-discover')), findsOneWidget); expect(find.byKey(const Key('tab-my')), findsOneWidget); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index e9693bc..1cd1142 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -81,9 +81,6 @@ void _expectFullscreenOverlay() { expect(find.byKey(const Key('rail-like')), findsNothing); expect(find.byKey(const Key('rail-save')), findsNothing); - expect(find.byKey(const Key('card-player-video-frame')), findsNothing); - expect(find.byKey(const Key('card-player-progress')), findsNothing); - expect(find.byKey(const Key('card-player-next')), findsNothing); } Future _pumpApp(WidgetTester tester) async { diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 0b516ea..ff0b5c9 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -117,33 +117,7 @@ void main() { }); group('surface flags', () { - test('should_hide_chrome_until_restored_when_screen_is_cleared', () { - final logic = _logic(FakeWatchHistory()); - final immersive = []; - logic.onImmersiveChanged = immersive.add; - var notifications = 0; - logic.addListener(() => notifications++); - - expect(logic.chromeHidden, isFalse); - expect(logic.immersive, isFalse); - - logic.setScreenCleared(true); - expect(logic.chromeHidden, isTrue); - expect(notifications, 1); - expect(immersive, [true]); - - // Repeating the same value is not a state change. - logic.setScreenCleared(true); - expect(notifications, 1); - expect(immersive, [true]); - - logic.setScreenCleared(false); - expect(logic.chromeHidden, isFalse); - expect(notifications, 2); - expect(immersive, [true, false]); - }); - - test('should_outrank_cleared_screen_flag_when_picture_in_picture_callback_fires', () async { + test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); final drama = _drama('signal'); final logic = _logic(FakeWatchHistory()); diff --git a/test/widget_test.dart b/test/widget_test.dart index 10525fd..aa33829 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -122,38 +122,12 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-download')), findsOneWidget); expect(find.byKey(const Key('home-feed-pip')), findsOneWidget); - expect(find.byKey(const Key('home-feed-clear')), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(find.text('Watch Full'), findsNothing); expect(find.byTooltip('Explore series'), findsNothing); }); - testWidgets('should_hide_tabs_and_restore_them_when_clear_screen_is_tapped', ( - tester, - ) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget(appUnderTest()); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('tab-home'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-more'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('home-feed-clear'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsNothing); - expect(find.byTooltip('Exit clear screen'), findsOneWidget); - await tester.tap(find.byTooltip('Exit clear screen')); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); - expect(find.byKey(const Key('home-feed-more')), findsOneWidget); - expect(tester.takeException(), isNull); - }); - testWidgets( 'should_open_current_series_and_switch_root_tabs_when_quick_drawer_is_used', (tester) async { @@ -177,13 +151,7 @@ void main() { ); expect(find.text('Quick access'), findsOneWidget); expect(find.text('Bahasa Indonesia'), findsOneWidget); - expect( - find.descendant( - of: find.byType(Drawer), - matching: find.text('7 EPISODES'), - ), - findsOneWidget, - ); + expect(find.text('7 EPISODES'), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.byKey(const Key('drawer-open-series'))); From f0c53542ef32f173d50f42911af443bb9d8fa946 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:07:06 +0800 Subject: [PATCH 11/30] =?UTF-8?q?fix(player):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E6=9A=82=E5=81=9C=E9=87=8D=E5=A4=8D=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 19 ++++++++- test/player/player_fullscreen_test.dart | 55 +++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 997cfe6..0641b60 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -29,6 +29,16 @@ import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; +/// The home feed has no overlay play button, so it keeps its own center hint. +/// Detail/series playback uses `_PlaybackOverlay.player-play-toggle` instead. +bool shouldShowFeedPauseIndicator({ + required bool homeMode, + required bool selected, + required bool ready, + required bool playing, + required bool pip, +}) => homeMode && selected && ready && !playing && !pip; + class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -301,8 +311,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - if (selected && ready && !logic.session.userWantsPlay && !logic.pip) + if (shouldShowFeedPauseIndicator( + homeMode: widget.homeMode, + selected: selected, + ready: ready, + playing: logic.session.userWantsPlay, + pip: logic.pip, + )) const Center( + key: Key('home-feed-center-play'), child: IgnorePointer( child: DecoratedBox( decoration: BoxDecoration( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 1cd1142..22acdaa 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -9,6 +9,7 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -67,6 +68,60 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('should_keep_single_pause_toggle_when_detail_player_is_paused', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); + }); + + test('should_only_show_feed_pause_indicator_in_home_mode', () { + expect( + shouldShowFeedPauseIndicator( + homeMode: true, + selected: true, + ready: true, + playing: false, + pip: false, + ), + isTrue, + ); + expect( + shouldShowFeedPauseIndicator( + homeMode: false, + selected: true, + ready: true, + playing: false, + pip: false, + ), + isFalse, + ); + expect( + shouldShowFeedPauseIndicator( + homeMode: true, + selected: true, + ready: true, + playing: true, + pip: false, + ), + isFalse, + ); + }); } void _expectFullscreenOverlay() { From 03e34b3b57591438e2ea4c7c9b0046d797934705 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:16:14 +0800 Subject: [PATCH 12/30] =?UTF-8?q?fix(player):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=9A=82=E5=81=9C=E6=8C=89=E9=92=AE=E5=B9=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E6=8E=A7=E5=88=B6=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 398 ++++++++++-------- .../player/widgets/home_feed_overlay.dart | 2 +- .../player/widgets/player_center_toggle.dart | 41 ++ test/player/player_center_toggle_test.dart | 49 +++ test/player/player_fullscreen_test.dart | 34 ++ 5 files changed, 353 insertions(+), 171 deletions(-) create mode 100644 lib/pages/player/widgets/player_center_toggle.dart create mode 100644 test/player/player_center_toggle_test.dart diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 0641b60..5b757ed 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/player_center_toggle.dart'; import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -318,23 +319,11 @@ class _PlayerPageState extends State with WidgetsBindingObserver { playing: logic.session.userWantsPlay, pip: logic.pip, )) - const Center( - key: Key('home-feed-center-play'), - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - color: AppColors.blue, - shape: BoxShape.circle, - ), - child: Padding( - padding: EdgeInsets.all(10), - child: Icon( - Icons.play_arrow_rounded, - size: 48, - color: Colors.white, - ), - ), - ), + Center( + child: PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: false, + onTap: () => logic.session.setUserWantsPlay(true), ), ), if (logic.pip || logic.pipPreparing) @@ -571,7 +560,7 @@ class _UnlockCard extends StatelessWidget { } } -class _PlaybackOverlay extends StatelessWidget { +class _PlaybackOverlay extends StatefulWidget { const _PlaybackOverlay({ required this.l10n, required this.drama, @@ -614,123 +603,185 @@ class _PlaybackOverlay extends StatelessWidget { final VoidCallback? onScrubStart; final Future Function(Duration)? onSeek; + @override + State<_PlaybackOverlay> createState() => _PlaybackOverlayState(); +} + +class _PlaybackOverlayState extends State<_PlaybackOverlay> { + Timer? _hideTimer; + bool _controlsVisible = true; + + @override + void initState() { + super.initState(); + _scheduleHide(); + } + + @override + void dispose() { + _hideTimer?.cancel(); + super.dispose(); + } + + void _scheduleHide() { + _hideTimer?.cancel(); + _hideTimer = Timer(const Duration(milliseconds: 2500), () { + if (mounted) setState(() => _controlsVisible = false); + }); + } + + void _reveal() { + setState(() => _controlsVisible = true); + _scheduleHide(); + } + + void _togglePlay() { + widget.onTogglePlay(); + _reveal(); + } + + void _onVideoTap() { + if (!_controlsVisible) { + _reveal(); + } else { + _togglePlay(); + } + } + + void _run(VoidCallback action) { + action(); + _reveal(); + } + @override Widget build(BuildContext context) { return SafeArea( - child: Column( + child: Stack( children: [ - _topBar(context), - Expanded( - child: Stack( - children: [ - Center( - child: GestureDetector( - key: const Key('player-play-toggle'), - onTap: onTogglePlay, - child: Container( - width: 52, - height: 52, - decoration: const BoxDecoration( - color: Color(0x80000000), - shape: BoxShape.circle, - ), - child: Icon( - playing - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 26, - ), - ), - ), - ), - const Positioned( - right: 10, - bottom: 14, - child: Text( - '1.0x', - style: TextStyle(color: Color(0xFFDDDDDD), fontSize: 9), - ), - ), - ], + Positioned.fill( + child: GestureDetector( + key: const Key('player-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: _onVideoTap, ), ), - Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 12), - child: Column( + if (_controlsVisible) + Column( + key: const Key('player-controls-visible'), children: [ - SeekProgressBar( - valueListenable: controller, - semanticsLabel: l10n.seekVideo, - showTimes: false, - onScrubStart: onScrubStart, - onSeek: onSeek, - ), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: _PlayerAction( - key: const Key('player-episodes'), - icon: Icons.view_list_rounded, - label: l10n.episodes, - onTap: onEpisodes, + _topBar(context), + Expanded( + child: Stack( + children: [ + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: widget.playing, + onTap: _togglePlay, + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: _PlayerAction( - key: const Key('player-comments'), - icon: Icons.chat_bubble_outline_rounded, - label: l10n.comments, - onTap: onComments, - ), - ), - const SizedBox(width: 8), - Expanded( - child: SizedBox( - height: 32, - child: FilledButton( - key: const Key('player-next'), - onPressed: onNext, - style: FilledButton.styleFrom( - backgroundColor: AppColors.blue, - disabledBackgroundColor: AppColors.raised, - disabledForegroundColor: AppColors.subtle, - foregroundColor: AppColors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(9), - ), - padding: EdgeInsets.zero, - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: Text( - l10n.nextEpisode, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 2), - const Icon(Icons.skip_next_rounded, size: 14), - ], + const Positioned( + right: 10, + bottom: 14, + child: Text( + '1.0x', + style: TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 9, ), ), ), - ), - ], + ], + ), + ), + Padding( + key: const Key('player-controls-bottom'), + padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), + child: Column( + children: [ + SeekProgressBar( + key: const Key('player-progress'), + valueListenable: widget.controller, + semanticsLabel: widget.l10n.seekVideo, + showTimes: false, + onScrubStart: widget.onScrubStart, + onSeek: widget.onSeek, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _PlayerAction( + key: const Key('player-episodes'), + icon: Icons.view_list_rounded, + label: widget.l10n.episodes, + onTap: () => _run(widget.onEpisodes), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _PlayerAction( + key: const Key('player-comments'), + icon: Icons.chat_bubble_outline_rounded, + label: widget.l10n.comments, + onTap: () => _run(widget.onComments), + ), + ), + const SizedBox(width: 8), + Expanded( + child: SizedBox( + height: 32, + child: FilledButton( + key: const Key('player-next'), + onPressed: widget.onNext == null + ? null + : () => _run(widget.onNext!), + style: FilledButton.styleFrom( + backgroundColor: AppColors.blue, + disabledBackgroundColor: AppColors.raised, + disabledForegroundColor: AppColors.subtle, + foregroundColor: AppColors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(9), + ), + padding: EdgeInsets.zero, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + widget.l10n.nextEpisode, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.skip_next_rounded, + size: 14, + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + PlayerFeaturedComment( + drama: widget.drama, + onViewAll: () => _run(widget.onComments), + ), + ], + ), ), - const SizedBox(height: 10), - PlayerFeaturedComment(drama: drama, onViewAll: onComments), ], ), - ), ], ), ); @@ -738,43 +789,47 @@ class _PlaybackOverlay extends StatelessWidget { Widget _topBar(BuildContext context) { return SizedBox( + key: const Key('player-controls-top'), height: 46, - child: Row( - children: [ - if (onBack != null) - _TopAction( - key: const Key('player-back'), - tooltip: l10n.backToSeries, - icon: Icons.chevron_left_rounded, - onPressed: onBack!, - ) - else - const SizedBox(width: 28), - const SizedBox(width: 8), - Expanded( - child: Text( - '${localizedDramaTitle(l10n, drama)} · ' - '${l10n.episodeLabel(episode.number)}', - key: const Key('player-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 13, - fontWeight: FontWeight.w700, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + if (widget.onBack != null) + _TopAction( + key: const Key('player-back'), + tooltip: widget.l10n.backToSeries, + icon: Icons.chevron_left_rounded, + onPressed: () => _run(widget.onBack!), + ) + else + const SizedBox(width: 28), + const SizedBox(width: 8), + Expanded( + child: Text( + '${localizedDramaTitle(widget.l10n, widget.drama)} · ' + '${widget.l10n.episodeLabel(widget.episode.number)}', + key: const Key('player-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 13, + fontWeight: FontWeight.w700, + ), ), ), - ), - const SizedBox(width: 8), - _TopAction( - key: const Key('player-more'), - tooltip: l10n.more, - icon: Icons.more_horiz_rounded, - onPressed: () => _openMore(context), - ), - ], + const SizedBox(width: 8), + _TopAction( + key: const Key('player-more'), + tooltip: widget.l10n.more, + icon: Icons.more_horiz_rounded, + onPressed: () => _openMore(context), + ), + ], + ), ), ); } @@ -794,62 +849,65 @@ class _PlaybackOverlay extends StatelessWidget { Icons.download_outlined, color: AppColors.secondary, ), - title: Text(l10n.downloads), + title: Text(widget.l10n.downloads), onTap: () { Navigator.pop(sheetContext); - onDownload(); + widget.onDownload(); }, ), - if (onPip != null) + if (widget.onPip != null) ListTile( key: const Key('player-more-pip'), leading: const Icon( Icons.picture_in_picture_alt_outlined, color: AppColors.secondary, ), - title: Text(l10n.pictureInPicture), + title: Text(widget.l10n.pictureInPicture), onTap: () { Navigator.pop(sheetContext); - onPip!(); + widget.onPip!(); }, ), ListTile( key: const Key('player-more-like'), leading: Icon( - liked ? Icons.favorite_rounded : Icons.favorite_border_rounded, - color: liked ? AppColors.blue : AppColors.muted, + widget.liked + ? Icons.favorite_rounded + : Icons.favorite_border_rounded, + color: widget.liked ? AppColors.blue : AppColors.muted, ), - title: Text(l10n.like), + title: Text(widget.l10n.like), onTap: () { Navigator.pop(sheetContext); - onLike(); + widget.onLike(); }, ), ListTile( key: const Key('player-more-favorite'), leading: Icon( - saved ? Icons.star_rounded : Icons.star_border_rounded, - color: saved ? AppColors.blue : AppColors.muted, + widget.saved ? Icons.star_rounded : Icons.star_border_rounded, + color: widget.saved ? AppColors.blue : AppColors.muted, ), - title: Text(l10n.favorite), + title: Text(widget.l10n.favorite), onTap: () { Navigator.pop(sheetContext); - onSave(); + widget.onSave(); }, ), ListTile( key: const Key('player-more-share'), leading: const Icon(Icons.share_outlined, color: AppColors.muted), - title: Text(l10n.share), + title: Text(widget.l10n.share), onTap: () { Navigator.pop(sheetContext); - onShare(); + widget.onShare(); }, ), ], ), ), ); + if (mounted) _reveal(); } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index faca527..c6653ba 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -55,7 +55,7 @@ class HomeFeedOverlay extends StatelessWidget { child: Align( alignment: Alignment.centerRight, child: Padding( - padding: const EdgeInsets.fromLTRB(0, 0, 10, 12), + padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), child: _rail(), ), ), diff --git a/lib/pages/player/widgets/player_center_toggle.dart b/lib/pages/player/widgets/player_center_toggle.dart new file mode 100644 index 0000000..b15e355 --- /dev/null +++ b/lib/pages/player/widgets/player_center_toggle.dart @@ -0,0 +1,41 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../../core/theme/app_colors.dart'; + +/// Shared play/pause control used by the home feed and full-screen player. +class PlayerCenterToggle extends StatelessWidget { + const PlayerCenterToggle({ + super.key, + required this.playing, + required this.onTap, + }); + + final bool playing; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: playing ? 'Pause' : 'Play', + child: GestureDetector( + onTap: onTap, + child: Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Color(0x80000000), + shape: BoxShape.circle, + ), + child: Icon( + playing ? Icons.pause_rounded : Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 26, + ), + ), + ), + ); + } +} diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart new file mode 100644 index 0000000..1e3a0ae --- /dev/null +++ b/test/player/player_center_toggle_test.dart @@ -0,0 +1,49 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; + +void main() { + testWidgets('should_use_one_shared_52px_style_for_home_and_player', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Row( + children: [ + PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: false, + onTap: () {}, + ), + PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: true, + onTap: () {}, + ), + ], + ), + ), + ), + ); + + for (final key in const [ + Key('home-feed-center-play'), + Key('player-play-toggle'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(52, 52)); + final container = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Container)), + ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, const Color(0x80000000)); + expect(decoration.shape, BoxShape.circle); + final icon = tester.widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ); + expect(icon.size, 26); + } + }); +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 22acdaa..2c9607b 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; +import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -90,6 +91,39 @@ void main() { expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); }); + testWidgets('should_hide_controls_after_2_5s_and_reveal_on_video_tap', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 2600)); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byType(PlayerCenterToggle), findsNothing); + expect(find.byKey(const Key('player-video-tap')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + }); + test('should_only_show_feed_pause_indicator_in_home_mode', () { expect( shouldShowFeedPauseIndicator( From 2fd92cdbeaa6f164185a4eab30c469eb5a0714a3 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:25:25 +0800 Subject: [PATCH 13/30] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E6=AD=A3=E5=85=AD?= =?UTF-8?q?=E5=B1=8F=E9=A1=B6=E6=A0=8F=E4=B8=8E=E5=86=85=E5=AE=B9=E9=97=B4?= =?UTF-8?q?=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 14 +-- lib/pages/drama_detail/drama_detail_view.dart | 8 +- lib/pages/mine/mine_view.dart | 89 ++++++++++++------- lib/pages/player/player_view.dart | 8 +- .../player/widgets/home_feed_overlay.dart | 18 ++-- test/discover/home_all_dramas_grid_test.dart | 17 ++++ test/drama_detail/drama_detail_view_test.dart | 15 ++++ test/home/home_preview_layout_test.dart | 82 +++++++++++++++++ test/mine/mine_preview_layout_test.dart | 47 ++++++++++ test/player/player_fullscreen_test.dart | 36 ++++++-- test/widget_test.dart | 13 +-- 11 files changed, 281 insertions(+), 66 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index c468231..f8122c6 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -257,7 +257,7 @@ class _DiscoverPageState extends State { tooltip: l10n.search, onTap: logic.openSearch, ), - const SizedBox(width: 7), + const SizedBox(width: 8), Semantics( button: true, label: widget.signedIn ? l10n.myTab : l10n.logIn, @@ -266,8 +266,8 @@ class _DiscoverPageState extends State { customBorder: const CircleBorder(), onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, child: Container( - width: 28, - height: 28, + width: 36, + height: 36, decoration: BoxDecoration( shape: BoxShape.circle, gradient: const LinearGradient( @@ -279,7 +279,7 @@ class _DiscoverPageState extends State { child: const Icon( Icons.person_rounded, color: AppColors.onSurface, - size: 15, + size: 18, ), ), ), @@ -601,8 +601,8 @@ class _HeaderIcon extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 28, - height: 28, + width: 36, + height: 36, child: IconButton( tooltip: tooltip, onPressed: onTap, @@ -611,7 +611,7 @@ class _HeaderIcon extends StatelessWidget { backgroundColor: const Color(0xFF2C2D33), foregroundColor: AppColors.onSurface, ), - icon: Icon(icon, size: 14), + icon: Icon(icon, size: 18), ), ); } diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index fc8d774..2cdfe14 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -435,8 +435,8 @@ class _DramaDetailPageState extends State { ), ], child: Container( - width: 28, - height: 28, + width: 36, + height: 36, decoration: const BoxDecoration( color: Color(0x80000000), shape: BoxShape.circle, @@ -536,8 +536,8 @@ class _RoundAction extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: 28, - height: 28, + width: 36, + height: 36, decoration: const BoxDecoration( color: Color(0x80000000), shape: BoxShape.circle, diff --git a/lib/pages/mine/mine_view.dart b/lib/pages/mine/mine_view.dart index 3a21c53..bc1e7a5 100644 --- a/lib/pages/mine/mine_view.dart +++ b/lib/pages/mine/mine_view.dart @@ -185,6 +185,7 @@ class _MyPageState extends State { ), const Spacer(), _RoundHeaderButton( + key: const Key('mine-search-button'), tooltip: l10n.search, icon: Icons.search_rounded, onTap: () => logic.act( @@ -194,10 +195,11 @@ class _MyPageState extends State { onAction: widget.onAction, ), ), - const SizedBox(width: 7), + const SizedBox(width: 8), Container( - width: 28, - height: 28, + key: const Key('mine-avatar'), + width: 36, + height: 36, decoration: const BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( @@ -209,7 +211,7 @@ class _MyPageState extends State { child: const Icon( Icons.person_rounded, color: AppColors.onSurface, - size: 15, + size: 18, ), ), ], @@ -455,37 +457,59 @@ class _MyPageState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - for (var index = 0; index < tabs.length; index++) - Expanded( - child: Semantics( - selected: logic.section == index, - button: true, - child: InkWell( - key: Key('mine-library-tab-$index'), - onTap: () => logic.selectSection(index), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Text( - tabs[index], - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: logic.section == index - ? AppColors.blue - : const Color(0xFFE4DBDD), - fontSize: 14, - fontWeight: FontWeight.w800, + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var index = 0; index < tabs.length; index++) + Padding( + padding: EdgeInsets.only( + right: index == tabs.length - 1 ? 0 : 20, + ), + child: Semantics( + selected: logic.section == index, + button: true, + child: InkWell( + key: Key('mine-library-tab-$index'), + onTap: () => logic.selectSection(index), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tabs[index], + maxLines: 1, + style: TextStyle( + color: logic.section == index + ? AppColors.blue + : const Color(0xFFE4DBDD), + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 18, + height: 2, + decoration: BoxDecoration( + color: logic.section == index + ? AppColors.blue + : Colors.transparent, + borderRadius: BorderRadius.circular(1), + ), + ), + ], ), ), ), ), ), - ), - ], + ], + ), ), - const SizedBox(height: 10), + const SizedBox(height: 12), if (logic.section == 1 && context.watch().loading) const Center( child: Padding( @@ -935,6 +959,7 @@ class _MyPageState extends State { class _RoundHeaderButton extends StatelessWidget { const _RoundHeaderButton({ + super.key, required this.tooltip, required this.icon, required this.onTap, @@ -947,8 +972,8 @@ class _RoundHeaderButton extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 28, - height: 28, + width: 36, + height: 36, child: IconButton( tooltip: tooltip, onPressed: onTap, @@ -957,7 +982,7 @@ class _RoundHeaderButton extends StatelessWidget { backgroundColor: const Color(0xFF2C2D33), foregroundColor: AppColors.onSurface, ), - icon: Icon(icon, size: 14), + icon: Icon(icon, size: 18), ), ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5b757ed..7649661 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -803,7 +803,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { onPressed: () => _run(widget.onBack!), ) else - const SizedBox(width: 28), + const SizedBox(width: 36), const SizedBox(width: 8), Expanded( child: Text( @@ -926,8 +926,8 @@ class _TopAction extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 28, - height: 28, + width: 36, + height: 36, child: IconButton( tooltip: tooltip, onPressed: onPressed, @@ -936,7 +936,7 @@ class _TopAction extends StatelessWidget { backgroundColor: const Color(0xFF2C2D33), foregroundColor: AppColors.onSurface, ), - icon: Icon(icon, size: 15), + icon: Icon(icon, size: 18), ), ); } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index c6653ba..30eadf0 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -95,7 +95,7 @@ class HomeFeedOverlay extends StatelessWidget { icon: Icons.search_rounded, onPressed: onSearch, ), - const SizedBox(width: 6), + const SizedBox(width: 8), _RoundIcon( key: const Key('home-feed-more'), tooltip: l10n.more, @@ -147,7 +147,7 @@ class HomeFeedOverlay extends StatelessWidget { key: const Key('home-feed-info-card'), radius: 14, blur: 6, - padding: const EdgeInsets.fromLTRB(12, 11, 12, 11), + padding: const EdgeInsets.all(12), gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -180,7 +180,7 @@ class HomeFeedOverlay extends StatelessWidget { ], ), ), - const SizedBox(width: 6), + const SizedBox(width: 8), Flexible( child: Text( l10n.episodeOfTotal(episode.number, drama.episodeCount), @@ -193,9 +193,11 @@ class HomeFeedOverlay extends StatelessWidget { ), ], ), - const SizedBox(height: 7), + const SizedBox(height: 8), Text( - localizedEpisodeTitle(l10n, episode.number), + episode.title.trim().isEmpty + ? localizedDramaTitle(l10n, drama) + : episode.title, key: const Key('home-feed-episode-title'), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -352,8 +354,8 @@ class _RoundIcon extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - width: 28, - height: 28, + width: 36, + height: 36, child: IconButton( tooltip: tooltip, onPressed: onPressed, @@ -362,7 +364,7 @@ class _RoundIcon extends StatelessWidget { backgroundColor: const Color(0xCC2C2D33), foregroundColor: AppColors.onSurface, ), - icon: Icon(icon, size: 14), + icon: Icon(icon, size: 18), ), ); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 79d4be9..f7b747a 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -39,6 +39,23 @@ void main() { expect(find.text('Continue Watching'), findsNothing); expect(find.text('Recommended'), findsOneWidget); + for (final key in const [ + Key('discover-search-button'), + Key('discover-profile'), + ]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); + } expect(find.text('For You'), findsNothing); final grid = tester.widget( find.byKey(const Key('home-all-dramas-grid')), diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 7fee3c7..66352a7 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -33,6 +33,21 @@ void main() { await tester.pumpAndSettle(); expect(tester.getSize(find.byKey(const Key('detail-hero'))).height, 190); + expect( + tester.getSize(find.byKey(const Key('detail-back'))), + const Size(36, 36), + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const Key('detail-back')), + matching: find.byType(Icon), + ), + ) + .size, + 18, + ); expect(find.byKey(const Key('detail-cta')), findsOneWidget); expect(tester.getSize(find.byKey(const Key('detail-cta'))).height, 40); expect(find.byKey(const Key('detail-episode-chip-1')), findsOneWidget); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index ca085e3..c7428f1 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -6,9 +6,13 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -52,6 +56,17 @@ void main() { expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); + for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } expect( tester.getSize(find.byKey(const Key('home-feed-follow-series'))).height, 34, @@ -61,4 +76,71 @@ void main() { expect(find.byKey(const Key('home-feed-follow')), findsNothing); expect(tester.takeException(), isNull); }); + + testWidgets('should_use_episode_title_then_drama_title_as_fallback', ( + tester, + ) async { + Future pumpEpisode(Episode episode) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: _drama, + episode: episode, + liked: false, + saved: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onEpisodes: () {}, + onDownload: () {}, + onSearch: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await pumpEpisode( + const Episode( + id: 'featured', + number: 2, + title: 'Midnight Contract', + isFree: true, + ), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Midnight Contract', + ); + + await pumpEpisode( + const Episode(id: 'fallback', number: 2, title: '', isFree: true), + ); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + 'Fallback Drama', + ); + }); } + +const _drama = Drama( + id: 'featured-drama', + title: 'Fallback Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, +); diff --git a/test/mine/mine_preview_layout_test.dart b/test/mine/mine_preview_layout_test.dart index cb9d013..d9c9946 100644 --- a/test/mine/mine_preview_layout_test.dart +++ b/test/mine/mine_preview_layout_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; +import 'package:fruitshorts/core/theme/app_colors.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -59,11 +60,57 @@ void main() { expect(find.byKey(const Key('mine-user-row')), findsOneWidget); expect(find.byKey(const Key('mine-wallet')), findsOneWidget); + for (final key in const [Key('mine-search-button'), Key('mine-avatar')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + expect( + tester + .widget( + find.descendant(of: find.byKey(key), matching: find.byType(Icon)), + ) + .size, + 18, + ); + } expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget); expect(find.byKey(const Key('mine-language')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget); expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget); + final tab0 = find.byKey(const Key('mine-library-tab-0')); + final tab1 = find.byKey(const Key('mine-library-tab-1')); + final tab2 = find.byKey(const Key('mine-library-tab-2')); + expect(tester.getTopLeft(tab0).dx, lessThan(tester.getTopLeft(tab1).dx)); + expect(tester.getTopLeft(tab1).dx, lessThan(tester.getTopLeft(tab2).dx)); + expect( + tester.getTopLeft(tab1).dx - tester.getTopRight(tab0).dx, + closeTo(20, 1), + ); + expect( + (tester + .widget( + find.descendant( + of: tab0, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + AppColors.blue, + ); + expect( + (tester + .widget( + find.descendant( + of: tab1, + matching: find.byType(AnimatedContainer), + ), + ) + .decoration + as BoxDecoration) + .color, + Colors.transparent, + ); expect(tester.takeException(), isNull); }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 2c9607b..7f71cad 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -34,7 +34,7 @@ void main() { await tester.tap(find.byKey(const Key('home-drama-card-city-lights-demo'))); await tester.pumpAndSettle(); - _expectFullscreenOverlay(); + _expectFullscreenOverlay(tester); expect(find.byType(PageView), findsOneWidget); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); @@ -56,13 +56,13 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - _expectFullscreenOverlay(); + _expectFullscreenOverlay(tester); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('detail-episode-chip-2'))); await tester.pumpAndSettle(); - _expectFullscreenOverlay(); + _expectFullscreenOverlay(tester); expect( tester.widget(find.byKey(const Key('player-title'))).data, contains('Episode 2'), @@ -156,12 +156,33 @@ void main() { isFalse, ); }); + + testWidgets('should_fit_player_header_on_320_width', (tester) async { + await _pumpApp(tester, size: const Size(320, 568)); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byKey(const Key('player-back'))), + const Size(36, 36), + ); + expect(tester.takeException(), isNull); + }); } -void _expectFullscreenOverlay() { +void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-title')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); + for (final key in const [Key('player-back'), Key('player-more')]) { + expect(tester.getSize(find.byKey(key)), const Size(36, 36)); + } expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-comments')), findsOneWidget); @@ -172,8 +193,11 @@ void _expectFullscreenOverlay() { expect(find.byKey(const Key('rail-save')), findsNothing); } -Future _pumpApp(WidgetTester tester) async { - tester.view.physicalSize = const Size(390, 844); +Future _pumpApp( + WidgetTester tester, { + Size size = const Size(390, 844), +}) async { + tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); diff --git a/test/widget_test.dart b/test/widget_test.dart index aa33829..430b533 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -319,10 +319,8 @@ void main() { await tester.tap(find.byKey(const Key('tab-my'))); await tester.pumpAndSettle(); - await tester.scrollUntilVisible( - find.byKey(const Key('mine-library-tab-1')), - 260, - ); + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('mine-library-tab-1'))); await tester.pumpAndSettle(); expect(find.text('No shows here yet.'), findsOneWidget); @@ -409,7 +407,12 @@ void main() { await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect(find.text('City Lights & Velvet Nights'), findsOneWidget); - expect(find.textContaining('Episode 1'), findsOneWidget); + expect( + tester + .widget(find.byKey(const Key('home-feed-episode-title'))) + .data, + contains('Episode 1'), + ); await tester.tap(find.byKey(const Key('tab-discover'))); await tester.pumpAndSettle(); From fbfb9769a0ab0701d399af9b4e6346d57c350302 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:32:09 +0800 Subject: [PATCH 14/30] =?UTF-8?q?fix(detail):=20=E9=80=89=E9=9B=86?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=AD=89=E5=AE=BD=E6=87=92=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=BD=91=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/drama_detail/drama_detail_view.dart | 56 ++++++++--- test/drama_detail/drama_detail_view_test.dart | 99 ++++++++++++++++++- 2 files changed, 135 insertions(+), 20 deletions(-) diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 2cdfe14..952dda7 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -303,14 +303,41 @@ class _DramaDetailPageState extends State { ), ), const SizedBox(height: 8), - Wrap( - key: const Key('detail-episode-chips'), - spacing: 6, - runSpacing: 6, - children: [ - for (var index = 0; index < drama.episodes.length; index++) - _episodeChip(context, drama, index, continueIndex), - ], + LayoutBuilder( + builder: (context, constraints) { + final sixColumnsWidth = (constraints.maxWidth - 5 * 8) / 6; + final columns = sixColumnsWidth >= 40 ? 6 : 5; + final selectedIndex = continueIndex ?? 0; + final selectedNumber = drama.episodes.isEmpty + ? null + : drama.episodes[selectedIndex].number; + final ordered = [ + for (var index = 0; index < drama.episodes.length; index++) + (index: index, episode: drama.episodes[index]), + ]..sort((a, b) => a.episode.number.compareTo(b.episode.number)); + return GridView.builder( + key: const Key('detail-episode-chips'), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: ordered.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columns, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1.35, + ), + itemBuilder: (context, position) { + final item = ordered[position]; + return _episodeChip( + context, + item.episode, + item.index, + selectedNumber, + ); + }, + ); + }, ), ], ), @@ -319,20 +346,17 @@ class _DramaDetailPageState extends State { Widget _episodeChip( BuildContext context, - Drama drama, + Episode episode, int index, - int? selectedIndex, + int? selectedNumber, ) { - final episode = drama.episodes[index]; - final selected = index == (selectedIndex ?? 0); + final selected = episode.number == selectedNumber; final locked = episode.locked || (!episode.isFree && index > 0); return InkWell( key: Key('detail-episode-chip-${episode.number}'), onTap: () => logic.openPlayer(context, index), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(9), child: Container( - constraints: const BoxConstraints(minWidth: 32, minHeight: 32), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), alignment: Alignment.center, decoration: BoxDecoration( color: selected @@ -340,7 +364,7 @@ class _DramaDetailPageState extends State { : locked ? AppColors.secondary.withValues(alpha: 0.16) : const Color(0xFF29272B), - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(9), ), child: Text( '${episode.number}', diff --git a/test/drama_detail/drama_detail_view_test.dart b/test/drama_detail/drama_detail_view_test.dart index 66352a7..ed939b3 100644 --- a/test/drama_detail/drama_detail_view_test.dart +++ b/test/drama_detail/drama_detail_view_test.dart @@ -5,20 +5,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/repositories/comment_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; +import 'package:fruitshorts/core/data/repositories/interaction_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; import 'package:fruitshorts/core/data/services/favorite_controller.dart'; import 'package:fruitshorts/core/data/services/interaction_controller.dart'; +import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/platform/haptics.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/drama_detail/drama_detail_view.dart'; +import 'package:fruitshorts/pages/player/player_view.dart'; import '../support/fake_catalog.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; void main() { testWidgets('should_render_preview_hero_cta_synopsis_and_episode_chips', ( @@ -78,6 +83,14 @@ void main() { await tester.pumpWidget(_host(_detailDrama())); await tester.pumpAndSettle(); + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + expect( + (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) + .crossAxisCount, + 6, + ); await tester.drag( find.byKey(const Key('detail-scroll')), const Offset(0, -520), @@ -87,22 +100,96 @@ void main() { expect(find.byKey(const Key('detail-episode-chip-8')), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('should_render_sorted_six_column_grid_and_open_original_index', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80))); + await tester.pumpAndSettle(); + + final grid = tester.widget( + find.byKey(const Key('detail-episode-chips')), + ); + final delegate = + grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; + expect(delegate.crossAxisCount, 6); + expect(delegate.mainAxisSpacing, 8); + expect(delegate.crossAxisSpacing, 8); + expect(delegate.childAspectRatio, closeTo(1.35, 0.001)); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect(tester.getSize(chip1), tester.getSize(chip2)); + + await tester.drag( + find.byKey(const Key('detail-scroll')), + const Offset(0, -420), + ); + await tester.pumpAndSettle(); + final chip12 = find.byKey(const Key('detail-episode-chip-12')); + expect(chip12, findsOneWidget); + await tester.tap(chip12); + await tester.pumpAndSettle(); + + final player = tester.widget(find.byType(PlayerPage)); + expect(player.initialIndex, 11); + }); + + testWidgets('should_sort_episode_chips_ascending_by_number', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_host(_detailDrama(count: 80, descending: true))); + await tester.pumpAndSettle(); + + final chip1 = find.byKey(const Key('detail-episode-chip-1')); + final chip2 = find.byKey(const Key('detail-episode-chip-2')); + final chip6 = find.byKey(const Key('detail-episode-chip-6')); + final chip7 = find.byKey(const Key('detail-episode-chip-7')); + expect(tester.getTopLeft(chip1).dy, tester.getTopLeft(chip6).dy); + expect(tester.getTopLeft(chip1).dx, lessThan(tester.getTopLeft(chip2).dx)); + expect( + tester.getTopLeft(chip7).dy, + greaterThan(tester.getTopLeft(chip1).dy), + ); + expect(tester.getSize(chip1), tester.getSize(chip2)); + }); } Widget _host(Drama drama) { final catalog = FakeCatalogRepository(dramas: [drama]); + final interactions = FakeInteractionRepository(); return MultiProvider( providers: [ Provider.value( value: InMemoryWatchHistoryRepository(), ), Provider.value(value: FakePlaybackRepository()), + Provider.value(value: createWidgetPlayer), + Provider(create: (_) => PlaybackCoordinator()), + Provider.value(value: interactions), + Provider.value(value: FakeCommentRepository()), Provider.value(value: FakeHaptics()), ChangeNotifierProvider( create: (_) => FavoriteController(InMemoryFavoriteRepository()), ), ChangeNotifierProvider( - create: (_) => InteractionController(FakeInteractionRepository()), + create: (_) => InteractionController(interactions), ), ChangeNotifierProvider(create: (_) => CatalogController(catalog)..load()), ], @@ -116,7 +203,11 @@ Widget _host(Drama drama) { ); } -Drama _detailDrama() { +Drama _detailDrama({int count = 8, bool descending = false}) { + final numbers = List.generate( + count, + (index) => descending ? count - index : index + 1, + ); return Drama( id: 'detail-k25', title: 'Detail K25', @@ -126,11 +217,11 @@ Drama _detailDrama() { posterUrl: '/files/detail-k25.png', genre: 'Urban Romance', category: 'Romance', - totalEpisodes: 8, + totalEpisodes: count, viewCount: 4800000, detailLoaded: true, episodes: [ - for (var number = 1; number <= 8; number++) + for (final number in numbers) Episode( id: 'detail-k25-$number', number: number, From f613033088aa951e7c80e93b56ccb4aa717a7b4f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:43:50 +0800 Subject: [PATCH 15/30] =?UTF-8?q?fix(player):=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=B8=85=E5=B1=8F=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/player/player_view.dart | 208 ++++++++---------- .../player/widgets/home_feed_overlay.dart | 9 + test/home/home_preview_layout_test.dart | 1 + test/player/player_center_toggle_test.dart | 8 +- test/player/player_fullscreen_test.dart | 80 +++---- 12 files changed, 158 insertions(+), 166 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 74e71e8..ccb0f0b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "total": {"type": "int"} } }, + "clearScreen": "Clear screen", "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 66274c6..eebf31f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -46,6 +46,7 @@ "total": {"type": "int"} } }, + "clearScreen": "Bersihkan layar", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 9d9ecb0..ee63cd3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -340,6 +340,12 @@ abstract class AppLocalizations { /// **'Episode {current} / {total}'** String episodeOfTotal(int current, int total); + /// No description provided for @clearScreen. + /// + /// In en, this message translates to: + /// **'Clear screen'** + String get clearScreen; + /// No description provided for @minutesAgo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0fd6034..dbafe08 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -133,6 +133,9 @@ class AppLocalizationsEn extends AppLocalizations { return 'Episode $current / $total'; } + @override + String get clearScreen => 'Clear screen'; + @override String minutesAgo(int count) { return '$count min ago'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4947298..02e2e01 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -133,6 +133,9 @@ class AppLocalizationsId extends AppLocalizations { return 'Episode $current / $total'; } + @override + String get clearScreen => 'Bersihkan layar'; + @override String minutesAgo(int count) { return '$count mnt lalu'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6010528..41821ad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -130,6 +130,9 @@ class AppLocalizationsZh extends AppLocalizations { return '第 $current 集 / 共 $total 集'; } + @override + String get clearScreen => '清屏'; + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 493979a..88d2dfd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -46,6 +46,7 @@ "total": {"type": "int"} } }, + "clearScreen": "清屏", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 7649661..49edc2a 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -30,16 +30,6 @@ import 'widgets/seek_progress_bar.dart'; import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/playback_repository.dart'; -/// The home feed has no overlay play button, so it keeps its own center hint. -/// Detail/series playback uses `_PlaybackOverlay.player-play-toggle` instead. -bool shouldShowFeedPauseIndicator({ - required bool homeMode, - required bool selected, - required bool ready, - required bool playing, - required bool pip, -}) => homeMode && selected && ready && !playing && !pip; - class PlayerPage extends StatefulWidget { const PlayerPage({ super.key, @@ -80,6 +70,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; + bool _feedChromeVisible = true; @override void initState() { @@ -198,9 +189,10 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ const ColoredBox(color: AppColors.ink), ?video, - if (!logic.chromeHidden) - const IgnorePointer( - child: DecoratedBox( + if (!logic.chromeHidden && (!widget.homeMode || _feedChromeVisible)) + IgnorePointer( + key: widget.homeMode ? const Key('home-feed-scrim') : null, + child: const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, @@ -312,67 +304,59 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - if (shouldShowFeedPauseIndicator( - homeMode: widget.homeMode, - selected: selected, - ready: ready, - playing: logic.session.userWantsPlay, - pip: logic.pip, - )) - Center( - child: PlayerCenterToggle( - key: const Key('home-feed-center-play'), - playing: false, - onTap: () => logic.session.setUserWantsPlay(true), - ), - ), if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode) - HomeFeedOverlay( - l10n: l10n, - drama: drama, - episode: episode, - liked: interactions.isLiked(drama.id), - saved: context.watch().isSaved(drama.id), - onLike: () async { - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final result = await interactions.toggleDramaLike(drama.id); - if (!result.isSuccess && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - result.errorMessage ?? - AppLocalizations.of(context)!.likeFailed, - ), - ), - ); - } - } - }, - onSave: () async { - final favorites = context.read(); - if (await ensureSignedIn(context)) { - if (!context.mounted) return; - unawaited(context.read().lightImpact()); - final ok = await favorites.toggle(drama.id, card: drama); - if (ok || !context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteToggleFailed)), - ); - } - }, - onComments: () => logic.showComments(context, drama), - onShare: () => logic.shareDrama(context, drama), - onEpisodes: () => logic.showEpisodes(context), - onDownload: () => requestEpisodeDownload(context, episode.id), - onSearch: widget.onSearch ?? () => logic.showDiscover(context), - onPip: logic.pictureInPicture.isPlatformSupported - ? () => unawaited(logic.enterPip(context)) - : null, - ) + _feedChromeVisible + ? HomeFeedOverlay( + l10n: l10n, + drama: drama, + episode: episode, + liked: interactions.isLiked(drama.id), + saved: context.watch().isSaved(drama.id), + onLike: () async { + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final result = await interactions.toggleDramaLike( + drama.id, + ); + if (!result.isSuccess && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.errorMessage ?? + AppLocalizations.of(context)!.likeFailed, + ), + ), + ); + } + } + }, + onSave: () async { + final favorites = context.read(); + if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); + final ok = await favorites.toggle(drama.id, card: drama); + if (ok || !context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.favoriteToggleFailed)), + ); + } + }, + onComments: () => logic.showComments(context, drama), + onShare: () => logic.shareDrama(context, drama), + onEpisodes: () => logic.showEpisodes(context), + onDownload: () => requestEpisodeDownload(context, episode.id), + onClear: () => setState(() => _feedChromeVisible = false), + onSearch: + widget.onSearch ?? () => logic.showDiscover(context), + onPip: logic.pictureInPicture.isPlatformSupported + ? () => unawaited(logic.enterPip(context)) + : null, + ) + : const SizedBox.shrink() else _PlaybackOverlay( l10n: l10n, @@ -433,6 +417,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? (position) => logic.seekTo(context, videoPort!, position) : null, ), + if (widget.homeMode && !_feedChromeVisible) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-tap'), + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _feedChromeVisible = true), + ), + ), ], ); } @@ -608,50 +600,17 @@ class _PlaybackOverlay extends StatefulWidget { } class _PlaybackOverlayState extends State<_PlaybackOverlay> { - Timer? _hideTimer; bool _controlsVisible = true; - @override - void initState() { - super.initState(); - _scheduleHide(); - } - - @override - void dispose() { - _hideTimer?.cancel(); - super.dispose(); - } - - void _scheduleHide() { - _hideTimer?.cancel(); - _hideTimer = Timer(const Duration(milliseconds: 2500), () { - if (mounted) setState(() => _controlsVisible = false); - }); - } - - void _reveal() { - setState(() => _controlsVisible = true); - _scheduleHide(); - } - - void _togglePlay() { - widget.onTogglePlay(); - _reveal(); - } - void _onVideoTap() { if (!_controlsVisible) { - _reveal(); - } else { - _togglePlay(); + setState(() => _controlsVisible = true); + } else if (!widget.playing) { + widget.onTogglePlay(); } } - void _run(VoidCallback action) { - action(); - _reveal(); - } + void _clearScreen() => setState(() => _controlsVisible = false); @override Widget build(BuildContext context) { @@ -673,13 +632,14 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { Expanded( child: Stack( children: [ - Center( - child: PlayerCenterToggle( - key: const Key('player-play-toggle'), - playing: widget.playing, - onTap: _togglePlay, + if (widget.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: true, + onTap: widget.onTogglePlay, + ), ), - ), const Positioned( right: 10, bottom: 14, @@ -715,7 +675,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-episodes'), icon: Icons.view_list_rounded, label: widget.l10n.episodes, - onTap: () => _run(widget.onEpisodes), + onTap: widget.onEpisodes, ), ), const SizedBox(width: 8), @@ -724,7 +684,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-comments'), icon: Icons.chat_bubble_outline_rounded, label: widget.l10n.comments, - onTap: () => _run(widget.onComments), + onTap: widget.onComments, ), ), const SizedBox(width: 8), @@ -733,9 +693,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { height: 32, child: FilledButton( key: const Key('player-next'), - onPressed: widget.onNext == null - ? null - : () => _run(widget.onNext!), + onPressed: widget.onNext, style: FilledButton.styleFrom( backgroundColor: AppColors.blue, disabledBackgroundColor: AppColors.raised, @@ -775,7 +733,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { const SizedBox(height: 12), PlayerFeaturedComment( drama: widget.drama, - onViewAll: () => _run(widget.onComments), + onViewAll: widget.onComments, ), ], ), @@ -800,7 +758,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-back'), tooltip: widget.l10n.backToSeries, icon: Icons.chevron_left_rounded, - onPressed: () => _run(widget.onBack!), + onPressed: widget.onBack!, ) else const SizedBox(width: 36), @@ -822,6 +780,13 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), ), const SizedBox(width: 8), + _TopAction( + key: const Key('player-clear-screen'), + tooltip: widget.l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: _clearScreen, + ), + const SizedBox(width: 8), _TopAction( key: const Key('player-more'), tooltip: widget.l10n.more, @@ -907,7 +872,6 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), ), ); - if (mounted) _reveal(); } } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 30eadf0..292d4ed 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -26,6 +26,7 @@ class HomeFeedOverlay extends StatelessWidget { required this.onShare, required this.onEpisodes, required this.onDownload, + required this.onClear, required this.onSearch, this.onPip, }); @@ -41,6 +42,7 @@ class HomeFeedOverlay extends StatelessWidget { final VoidCallback onShare; final VoidCallback onEpisodes; final VoidCallback onDownload; + final VoidCallback onClear; final VoidCallback onSearch; final VoidCallback? onPip; @@ -96,6 +98,13 @@ class HomeFeedOverlay extends StatelessWidget { onPressed: onSearch, ), const SizedBox(width: 8), + _RoundIcon( + key: const Key('home-feed-clear-screen'), + tooltip: l10n.clearScreen, + icon: Icons.fullscreen_rounded, + onPressed: onClear, + ), + const SizedBox(width: 8), _RoundIcon( key: const Key('home-feed-more'), tooltip: l10n.more, diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index c7428f1..df22998 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -101,6 +101,7 @@ void main() { onShare: () {}, onEpisodes: () {}, onDownload: () {}, + onClear: () {}, onSearch: () {}, ), ), diff --git a/test/player/player_center_toggle_test.dart b/test/player/player_center_toggle_test.dart index 1e3a0ae..e6912cd 100644 --- a/test/player/player_center_toggle_test.dart +++ b/test/player/player_center_toggle_test.dart @@ -5,16 +5,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; void main() { - testWidgets('should_use_one_shared_52px_style_for_home_and_player', ( - tester, - ) async { + testWidgets('should_use_one_shared_52px_center_toggle_style', (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: Row( children: [ PlayerCenterToggle( - key: const Key('home-feed-center-play'), + key: const Key('test-center-toggle-a'), playing: false, onTap: () {}, ), @@ -30,7 +28,7 @@ void main() { ); for (final key in const [ - Key('home-feed-center-play'), + Key('test-center-toggle-a'), Key('player-play-toggle'), ]) { expect(tester.getSize(find.byKey(key)), const Size(52, 52)); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7f71cad..21db0c5 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -9,7 +9,6 @@ import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import '../support/fake_catalog.dart'; @@ -70,7 +69,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_keep_single_pause_toggle_when_detail_player_is_paused', ( + testWidgets('should_hide_pause_toggle_when_playback_is_paused', ( tester, ) async { await _pumpApp(tester); @@ -86,12 +85,16 @@ void main() { await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - expect(find.byIcon(Icons.play_arrow_rounded), findsOneWidget); }); - testWidgets('should_hide_controls_after_2_5s_and_reveal_on_video_tap', ( + testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( tester, ) async { await _pumpApp(tester); @@ -108,7 +111,14 @@ void main() { expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.pump(const Duration(milliseconds: 2600)); + await tester.pump(const Duration(seconds: 5)); + await tester.pump(); + + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); @@ -124,39 +134,6 @@ void main() { expect(find.byType(PlayerCenterToggle), findsOneWidget); }); - test('should_only_show_feed_pause_indicator_in_home_mode', () { - expect( - shouldShowFeedPauseIndicator( - homeMode: true, - selected: true, - ready: true, - playing: false, - pip: false, - ), - isTrue, - ); - expect( - shouldShowFeedPauseIndicator( - homeMode: false, - selected: true, - ready: true, - playing: false, - pip: false, - ), - isFalse, - ); - expect( - shouldShowFeedPauseIndicator( - homeMode: true, - selected: true, - ready: true, - playing: true, - pip: false, - ), - isFalse, - ); - }); - testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); await tester.binding.handlePopRoute(); @@ -174,6 +151,31 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect(find.byKey(const Key('home-feed-scrim')), findsNothing); + expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.pump(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + }); } void _expectFullscreenOverlay(WidgetTester tester) { From 0eabb0a763faf95e78d97a3ab83f759112992d97 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:50:44 +0800 Subject: [PATCH 16/30] =?UTF-8?q?fix(player):=20=E4=BB=85=E5=9C=A8?= =?UTF-8?q?=E6=9A=82=E5=81=9C=E6=97=B6=E6=98=BE=E7=A4=BA=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 28 +++++++++++++++++--- test/player/player_fullscreen_test.dart | 35 ++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 49edc2a..23e9db4 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -208,6 +208,28 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), + if (widget.homeMode && _feedChromeVisible && !logic.chromeHidden) + Positioned.fill( + child: GestureDetector( + key: const Key('home-feed-video-toggle'), + behavior: HitTestBehavior.opaque, + onTap: () => logic.session.setUserWantsPlay( + !logic.session.userWantsPlay, + ), + ), + ), + if (widget.homeMode && + _feedChromeVisible && + !logic.chromeHidden && + selected && + !logic.session.userWantsPlay) + Center( + child: PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: false, + onTap: () => logic.session.setUserWantsPlay(true), + ), + ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( @@ -605,7 +627,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { void _onVideoTap() { if (!_controlsVisible) { setState(() => _controlsVisible = true); - } else if (!widget.playing) { + } else { widget.onTogglePlay(); } } @@ -632,11 +654,11 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { Expanded( child: Stack( children: [ - if (widget.playing) + if (!widget.playing) Center( child: PlayerCenterToggle( key: const Key('player-play-toggle'), - playing: true, + playing: false, onTap: widget.onTogglePlay, ), ), diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 21db0c5..6dc0fcb 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -69,7 +69,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_hide_pause_toggle_when_playback_is_paused', ( + testWidgets('should_show_play_toggle_only_when_playback_is_paused', ( tester, ) async { await _pumpApp(tester); @@ -82,16 +82,18 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-play-toggle'))); - await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-play-toggle')), findsNothing); - expect(find.byKey(const Key('player-controls-top')), findsOneWidget); - expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); await tester.tap(find.byKey(const Key('player-video-tap'))); await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( @@ -109,6 +111,10 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); + + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); expect(find.byType(PlayerCenterToggle), findsOneWidget); await tester.pump(const Duration(seconds: 5)); @@ -176,6 +182,21 @@ void main() { expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); }); + + testWidgets('should_show_home_play_toggle_only_when_paused', (tester) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + + await tester.tap(find.byKey(const Key('home-feed-center-play'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + }); } void _expectFullscreenOverlay(WidgetTester tester) { @@ -185,7 +206,7 @@ void _expectFullscreenOverlay(WidgetTester tester) { for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } - expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-comments')), findsOneWidget); expect(find.byKey(const Key('player-next')), findsOneWidget); From e2e809e2d5c326f1e4f66556fa99abc19d91403b Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 16:53:23 +0800 Subject: [PATCH 17/30] =?UTF-8?q?fix(player):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E9=80=80=E5=87=BA=E6=B8=85=E5=B1=8F=E6=B5=AE=E5=8A=A8=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 +++ lib/l10n/app_localizations_en.dart | 3 ++ lib/l10n/app_localizations_id.dart | 3 ++ lib/l10n/app_localizations_zh.dart | 3 ++ lib/l10n/app_zh.arb | 1 + lib/pages/player/player_view.dart | 26 +++++++++++-- .../widgets/exit_clear_screen_button.dart | 34 +++++++++++++++++ test/player/player_fullscreen_test.dart | 38 ++++++++++++++++++- 10 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 lib/pages/player/widgets/exit_clear_screen_button.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ccb0f0b..3d4ceef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -47,6 +47,7 @@ } }, "clearScreen": "Clear screen", + "exitClearScreen": "Exit clear screen", "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index eebf31f..6876854 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -47,6 +47,7 @@ } }, "clearScreen": "Bersihkan layar", + "exitClearScreen": "Keluar dari layar bersih", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee63cd3..42042f4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -346,6 +346,12 @@ abstract class AppLocalizations { /// **'Clear screen'** String get clearScreen; + /// No description provided for @exitClearScreen. + /// + /// In en, this message translates to: + /// **'Exit clear screen'** + String get exitClearScreen; + /// No description provided for @minutesAgo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dbafe08..a09195b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -136,6 +136,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get clearScreen => 'Clear screen'; + @override + String get exitClearScreen => 'Exit clear screen'; + @override String minutesAgo(int count) { return '$count min ago'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 02e2e01..a1fadb8 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -136,6 +136,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get clearScreen => 'Bersihkan layar'; + @override + String get exitClearScreen => 'Keluar dari layar bersih'; + @override String minutesAgo(int count) { return '$count mnt lalu'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 41821ad..c58a9a5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -133,6 +133,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get clearScreen => '清屏'; + @override + String get exitClearScreen => '退出清屏'; + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 88d2dfd..42ebcaf 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -47,6 +47,7 @@ } }, "clearScreen": "清屏", + "exitClearScreen": "退出清屏", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 23e9db4..8d3bb32 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -24,6 +24,7 @@ import '../../l10n/app_localizations.dart'; import 'player_binding.dart'; import 'player_logic.dart'; import 'widgets/home_feed_overlay.dart'; +import 'widgets/exit_clear_screen_button.dart'; import 'widgets/player_center_toggle.dart'; import 'widgets/player_featured_comment.dart'; import 'widgets/seek_progress_bar.dart'; @@ -213,9 +214,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { child: GestureDetector( key: const Key('home-feed-video-toggle'), behavior: HitTestBehavior.opaque, - onTap: () => logic.session.setUserWantsPlay( - !logic.session.userWantsPlay, - ), + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), if (widget.homeMode && @@ -447,6 +447,16 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onTap: () => setState(() => _feedChromeVisible = true), ), ), + if (widget.homeMode && !_feedChromeVisible) + Positioned( + top: 8, + right: 12, + child: ExitClearScreenButton( + key: const Key('home-feed-exit-clear-screen'), + tooltip: l10n.exitClearScreen, + onPressed: () => setState(() => _feedChromeVisible = true), + ), + ), ], ); } @@ -762,6 +772,16 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), ], ), + if (!_controlsVisible) + Positioned( + top: 8, + right: 12, + child: ExitClearScreenButton( + key: const Key('player-exit-clear-screen'), + tooltip: widget.l10n.exitClearScreen, + onPressed: () => setState(() => _controlsVisible = true), + ), + ), ], ), ); diff --git a/lib/pages/player/widgets/exit_clear_screen_button.dart b/lib/pages/player/widgets/exit_clear_screen_button.dart new file mode 100644 index 0000000..a2b1c51 --- /dev/null +++ b/lib/pages/player/widgets/exit_clear_screen_button.dart @@ -0,0 +1,34 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +/// Shared floating action used to leave clear-screen mode. +class ExitClearScreenButton extends StatelessWidget { + const ExitClearScreenButton({ + super.key, + required this.tooltip, + required this.onPressed, + }); + + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 36, + height: 36, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: const Color(0x80000000), + foregroundColor: Colors.white, + shape: const CircleBorder(), + ), + icon: const Icon(Icons.fullscreen_exit_rounded, size: 18), + ), + ); + } +} diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 6dc0fcb..8122046 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -131,13 +131,27 @@ void main() { expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byKey(const Key('player-video-tap')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester.getSize(find.byKey(const Key('player-exit-clear-screen'))), + const Size(36, 36), + ); - await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); }); testWidgets('should_fit_player_header_on_320_width', (tester) async { @@ -174,13 +188,33 @@ void main() { expect(find.byKey(const Key('home-feed-info-card')), findsNothing); expect(find.byKey(const Key('home-feed-scrim')), findsNothing); expect(find.byKey(const Key('home-feed-video-tap')), findsOneWidget); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), + const Size(36, 36), + ); - await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); }); testWidgets('should_show_home_play_toggle_only_when_paused', (tester) async { From aea130be11dfc939e6817c39f6ac2477e1307ec0 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:05:52 +0800 Subject: [PATCH 18/30] =?UTF-8?q?fix(player):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E6=B8=85=E5=B1=8F=E6=80=81=E7=82=B9=E5=87=BB=E4=B8=8E=E6=BB=91?= =?UTF-8?q?=E5=8A=A8=E5=A4=8D=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 29 +++++---- test/player/player_fullscreen_test.dart | 78 ++++++++++++++++++------- 2 files changed, 75 insertions(+), 32 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 8d3bb32..93d2f84 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -128,6 +128,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; + if (widget.homeMode && !_feedChromeVisible) { + setState(() => _feedChromeVisible = true); + } widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(logic.selectEpisode(index)); }, @@ -444,17 +447,23 @@ class _PlayerPageState extends State with WidgetsBindingObserver { child: GestureDetector( key: const Key('home-feed-video-tap'), behavior: HitTestBehavior.opaque, - onTap: () => setState(() => _feedChromeVisible = true), + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), if (widget.homeMode && !_feedChromeVisible) Positioned( - top: 8, - right: 12, - child: ExitClearScreenButton( - key: const Key('home-feed-exit-clear-screen'), - tooltip: l10n.exitClearScreen, - onPressed: () => setState(() => _feedChromeVisible = true), + top: 0, + right: 0, + child: SafeArea( + left: false, + bottom: false, + minimum: const EdgeInsets.only(top: 8, right: 12), + child: ExitClearScreenButton( + key: const Key('home-feed-exit-clear-screen'), + tooltip: l10n.exitClearScreen, + onPressed: () => setState(() => _feedChromeVisible = true), + ), ), ), ], @@ -635,11 +644,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { bool _controlsVisible = true; void _onVideoTap() { - if (!_controlsVisible) { - setState(() => _controlsVisible = true); - } else { - widget.onTogglePlay(); - } + widget.onTogglePlay(); } void _clearScreen() => setState(() => _controlsVisible = false); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 8122046..b946d8a 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -137,21 +137,22 @@ void main() { const Size(36, 36), ); + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); - - await tester.tap(find.byKey(const Key('player-clear-screen'))); - await tester.pump(); - expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); - await tester.tap(find.byKey(const Key('player-video-tap'))); - await tester.pump(); - expect(find.byKey(const Key('player-controls-top')), findsOneWidget); - expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); }); testWidgets('should_fit_player_header_on_320_width', (tester) async { @@ -196,6 +197,35 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), const Size(36, 36), ); + expect( + find.ancestor( + of: find.byKey(const Key('home-feed-exit-clear-screen')), + matching: find.byType(SafeArea), + ), + findsWidgets, + ); + expect( + tester + .getTopLeft(find.byKey(const Key('home-feed-exit-clear-screen'))) + .dy, + greaterThanOrEqualTo(8), + ); + + await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); await tester.pump(); @@ -204,17 +234,7 @@ void main() { expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - - await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); - await tester.pump(); - expect( - find.byKey(const Key('home-feed-exit-clear-screen')), - findsOneWidget, - ); - await tester.tap(find.byKey(const Key('home-feed-video-tap'))); - await tester.pump(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); testWidgets('should_show_home_play_toggle_only_when_paused', (tester) async { @@ -231,6 +251,24 @@ void main() { await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); + + testWidgets('should_reset_home_chrome_when_swiping_to_next_page', ( + tester, + ) async { + await _pumpApp(tester); + await tester.tap(find.byKey(const Key('tab-home'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); + expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + }); } void _expectFullscreenOverlay(WidgetTester tester) { From 0d977da75bd36173a62d33437f2f7983ab34c297 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:14:03 +0800 Subject: [PATCH 19/30] =?UTF-8?q?fix(player):=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=B8=85=E5=B1=8F=E5=B9=B6=E5=B8=B8=E9=A9=BB=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 39 +++-- test/player/player_fullscreen_test.dart | 183 ++++++++++++++++++++---- 2 files changed, 171 insertions(+), 51 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 93d2f84..c45ff8c 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -128,9 +128,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { itemCount: indices.length, onPageChanged: (position) { final index = indices[position]; - if (widget.homeMode && !_feedChromeVisible) { - setState(() => _feedChromeVisible = true); - } widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(logic.selectEpisode(index)); }, @@ -221,18 +218,6 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), - if (widget.homeMode && - _feedChromeVisible && - !logic.chromeHidden && - selected && - !logic.session.userWantsPlay) - Center( - child: PlayerCenterToggle( - key: const Key('home-feed-center-play'), - playing: false, - onTap: () => logic.session.setUserWantsPlay(true), - ), - ), // 票据三态之一:起播前正在向后端要签名地址 if (selected && logic.ticketLoading) const Center( @@ -466,6 +451,15 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), + if (widget.homeMode && !logic.chromeHidden && selected) + Center( + child: PlayerCenterToggle( + key: const Key('home-feed-center-play'), + playing: logic.session.userWantsPlay, + onTap: () => + logic.session.setUserWantsPlay(!logic.session.userWantsPlay), + ), + ), ], ); } @@ -669,14 +663,6 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { Expanded( child: Stack( children: [ - if (!widget.playing) - Center( - child: PlayerCenterToggle( - key: const Key('player-play-toggle'), - playing: false, - onTap: widget.onTogglePlay, - ), - ), const Positioned( right: 10, bottom: 14, @@ -787,6 +773,13 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { onPressed: () => setState(() => _controlsVisible = true), ), ), + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: widget.playing, + onTap: widget.onTogglePlay, + ), + ), ], ), ); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b946d8a..7b46bdd 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -69,9 +69,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_show_play_toggle_only_when_playback_is_paused', ( - tester, - ) async { + testWidgets('should_keep_center_toggle_in_both_play_states', (tester) async { await _pumpApp(tester); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -82,18 +80,51 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-play-toggle')), findsNothing); - - await tester.tap(find.byKey(const Key('player-video-tap'))); - await tester.pump(); - expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); - expect(find.byKey(const Key('player-controls-top')), findsOneWidget); - expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isTrue, + ); await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.pump(); - expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); + expect( + find.descendant( + of: find.byKey(const Key('player-play-toggle')), + matching: find.byIcon(Icons.play_arrow_rounded), + ), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isTrue, + ); + expect( + find.descendant( + of: find.byKey(const Key('player-play-toggle')), + matching: find.byIcon(Icons.pause_rounded), + ), + findsOneWidget, + ); }); testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( @@ -111,11 +142,19 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); - expect(find.byType(PlayerCenterToggle), findsNothing); + expect(find.byType(PlayerCenterToggle), findsOneWidget); - await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); await tester.pump(const Duration(seconds: 5)); await tester.pump(); @@ -129,7 +168,15 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); - expect(find.byType(PlayerCenterToggle), findsNothing); + expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); expect(find.byKey(const Key('player-video-tap')), findsOneWidget); expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); expect( @@ -137,21 +184,45 @@ void main() { const Size(36, 36), ); - await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isTrue, + ); - await tester.tap(find.byKey(const Key('player-video-tap'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('player-play-toggle')), + ) + .playing, + isFalse, + ); expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); }); @@ -181,6 +252,15 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isTrue, + ); await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); await tester.pump(); @@ -197,6 +277,7 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), const Size(36, 36), ); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); expect( find.ancestor( of: find.byKey(const Key('home-feed-exit-clear-screen')), @@ -211,21 +292,37 @@ void main() { greaterThanOrEqualTo(8), ); - await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('home-feed-title')), findsNothing); expect( find.byKey(const Key('home-feed-exit-clear-screen')), findsOneWidget, ); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); - await tester.tap(find.byKey(const Key('home-feed-video-tap'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('home-feed-title')), findsNothing); expect( find.byKey(const Key('home-feed-exit-clear-screen')), findsOneWidget, ); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isTrue, + ); await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); await tester.pump(); @@ -234,25 +331,51 @@ void main() { expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); }); - testWidgets('should_show_home_play_toggle_only_when_paused', (tester) async { + testWidgets('should_keep_home_center_toggle_in_both_play_states', ( + tester, + ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); - await tester.tap(find.byKey(const Key('home-feed-video-toggle'))); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isTrue, + ); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isFalse, + ); await tester.tap(find.byKey(const Key('home-feed-center-play'))); await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsNothing); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-center-play')), + ) + .playing, + isTrue, + ); }); - testWidgets('should_reset_home_chrome_when_swiping_to_next_page', ( + testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( tester, ) async { await _pumpApp(tester); @@ -265,9 +388,13 @@ void main() { await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-title')), findsOneWidget); - expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); - expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsNothing); + expect(find.byKey(const Key('home-feed-info-card')), findsNothing); + expect( + find.byKey(const Key('home-feed-exit-clear-screen')), + findsOneWidget, + ); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); }); } @@ -278,7 +405,7 @@ void _expectFullscreenOverlay(WidgetTester tester) { for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } - expect(find.byKey(const Key('player-play-toggle')), findsNothing); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-comments')), findsOneWidget); expect(find.byKey(const Key('player-next')), findsOneWidget); From 71e317f4ab4485a5f5d2231262e0a57e08942984 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:20:19 +0800 Subject: [PATCH 20/30] =?UTF-8?q?fix(player):=20=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E4=BB=85=E6=9A=82=E5=81=9C=E6=97=B6=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 20 +++--- test/player/player_fullscreen_test.dart | 92 +++++-------------------- 2 files changed, 29 insertions(+), 83 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index c45ff8c..f71462b 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -451,11 +451,14 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - if (widget.homeMode && !logic.chromeHidden && selected) + if (widget.homeMode && + !logic.chromeHidden && + selected && + !logic.session.userWantsPlay) Center( child: PlayerCenterToggle( key: const Key('home-feed-center-play'), - playing: logic.session.userWantsPlay, + playing: false, onTap: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), @@ -773,13 +776,14 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { onPressed: () => setState(() => _controlsVisible = true), ), ), - Center( - child: PlayerCenterToggle( - key: const Key('player-play-toggle'), - playing: widget.playing, - onTap: widget.onTogglePlay, + if (!widget.playing) + Center( + child: PlayerCenterToggle( + key: const Key('player-play-toggle'), + playing: false, + onTap: widget.onTogglePlay, + ), ), - ), ], ), ); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 7b46bdd..46737d5 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -69,7 +69,7 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('should_keep_center_toggle_in_both_play_states', (tester) async { + testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); @@ -80,18 +80,11 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('player-play-toggle')), - ) - .playing, - isTrue, - ); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); - await tester.tap(find.byKey(const Key('player-play-toggle'))); + await tester.tapAt(const Offset(80, 300)); await tester.pump(); + expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect( tester .widget( @@ -110,21 +103,7 @@ void main() { await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.pump(); - expect( - tester - .widget( - find.byKey(const Key('player-play-toggle')), - ) - .playing, - isTrue, - ); - expect( - find.descendant( - of: find.byKey(const Key('player-play-toggle')), - matching: find.byIcon(Icons.pause_rounded), - ), - findsOneWidget, - ); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( @@ -142,7 +121,7 @@ void main() { expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); - expect(find.byType(PlayerCenterToggle), findsOneWidget); + expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); @@ -188,14 +167,7 @@ void main() { await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsNothing); expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('player-play-toggle')), - ) - .playing, - isTrue, - ); + expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); @@ -252,15 +224,7 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isTrue, - ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); await tester.tap(find.byKey(const Key('home-feed-clear-screen'))); await tester.pump(); @@ -277,7 +241,7 @@ void main() { tester.getSize(find.byKey(const Key('home-feed-exit-clear-screen'))), const Size(36, 36), ); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); expect( find.ancestor( of: find.byKey(const Key('home-feed-exit-clear-screen')), @@ -299,6 +263,7 @@ void main() { find.byKey(const Key('home-feed-exit-clear-screen')), findsOneWidget, ); + expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); expect( tester .widget( @@ -315,14 +280,7 @@ void main() { find.byKey(const Key('home-feed-exit-clear-screen')), findsOneWidget, ); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isTrue, - ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); await tester.tap(find.byKey(const Key('home-feed-exit-clear-screen'))); await tester.pump(); @@ -331,25 +289,17 @@ void main() { expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-scrim')), findsOneWidget); expect(find.byKey(const Key('home-feed-exit-clear-screen')), findsNothing); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); - testWidgets('should_keep_home_center_toggle_in_both_play_states', ( + testWidgets('should_show_home_center_toggle_only_when_paused', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isTrue, - ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); @@ -364,15 +314,7 @@ void main() { await tester.tap(find.byKey(const Key('home-feed-center-play'))); await tester.pump(); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); - expect( - tester - .widget( - find.byKey(const Key('home-feed-center-play')), - ) - .playing, - isTrue, - ); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); testWidgets('should_keep_home_clear_state_when_swiping_to_next_page', ( @@ -394,7 +336,7 @@ void main() { find.byKey(const Key('home-feed-exit-clear-screen')), findsOneWidget, ); - expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); + expect(find.byKey(const Key('home-feed-center-play')), findsNothing); }); } @@ -405,7 +347,7 @@ void _expectFullscreenOverlay(WidgetTester tester) { for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(36, 36)); } - expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); + expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-comments')), findsOneWidget); expect(find.byKey(const Key('player-next')), findsOneWidget); From 5b7a8bd7750729dec9cd55c28b4984fd50e3b536 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:29:01 +0800 Subject: [PATCH 21/30] =?UTF-8?q?fix(player):=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E6=B8=85=E5=B1=8F=E7=8A=B6=E6=80=81=E5=B9=B6=E5=B7=A6=E8=B4=B4?= =?UTF-8?q?=E9=9B=86=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 43 ++++++++--------- .../player/widgets/home_feed_overlay.dart | 47 +++++++------------ test/home/home_preview_layout_test.dart | 7 +++ test/player/player_fullscreen_test.dart | 31 ++++++++++++ 4 files changed, 78 insertions(+), 50 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index f71462b..aae3c20 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -71,7 +71,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; - bool _feedChromeVisible = true; + bool _chromeVisible = true; @override void initState() { @@ -190,7 +190,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { children: [ const ColoredBox(color: AppColors.ink), ?video, - if (!logic.chromeHidden && (!widget.homeMode || _feedChromeVisible)) + if (!logic.chromeHidden && (!widget.homeMode || _chromeVisible)) IgnorePointer( key: widget.homeMode ? const Key('home-feed-scrim') : null, child: const DecoratedBox( @@ -209,7 +209,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ), ), ), - if (widget.homeMode && _feedChromeVisible && !logic.chromeHidden) + if (widget.homeMode && _chromeVisible && !logic.chromeHidden) Positioned.fill( child: GestureDetector( key: const Key('home-feed-video-toggle'), @@ -317,7 +317,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { if (logic.pip || logic.pipPreparing) const SizedBox.expand() else if (widget.homeMode) - _feedChromeVisible + _chromeVisible ? HomeFeedOverlay( l10n: l10n, drama: drama, @@ -359,7 +359,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onShare: () => logic.shareDrama(context, drama), onEpisodes: () => logic.showEpisodes(context), onDownload: () => requestEpisodeDownload(context, episode.id), - onClear: () => setState(() => _feedChromeVisible = false), + onClear: () => setState(() => _chromeVisible = false), onSearch: widget.onSearch ?? () => logic.showDiscover(context), onPip: logic.pictureInPicture.isPlatformSupported @@ -384,6 +384,9 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? () => unawaited(logic.playNextEpisode()) : null, playing: logic.session.userWantsPlay, + controlsVisible: _chromeVisible, + onClear: () => setState(() => _chromeVisible = false), + onExitClear: () => setState(() => _chromeVisible = true), onTogglePlay: () => logic.session.setUserWantsPlay(!logic.session.userWantsPlay), // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) @@ -427,7 +430,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? (position) => logic.seekTo(context, videoPort!, position) : null, ), - if (widget.homeMode && !_feedChromeVisible) + if (widget.homeMode && !_chromeVisible) Positioned.fill( child: GestureDetector( key: const Key('home-feed-video-tap'), @@ -436,7 +439,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { logic.session.setUserWantsPlay(!logic.session.userWantsPlay), ), ), - if (widget.homeMode && !_feedChromeVisible) + if (widget.homeMode && !_chromeVisible) Positioned( top: 0, right: 0, @@ -447,7 +450,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver { child: ExitClearScreenButton( key: const Key('home-feed-exit-clear-screen'), tooltip: l10n.exitClearScreen, - onPressed: () => setState(() => _feedChromeVisible = true), + onPressed: () => setState(() => _chromeVisible = true), ), ), ), @@ -599,7 +602,10 @@ class _PlaybackOverlay extends StatefulWidget { required this.liked, required this.saved, required this.playing, + required this.controlsVisible, required this.onTogglePlay, + required this.onClear, + required this.onExitClear, required this.onBack, required this.onEpisodes, required this.onComments, @@ -620,7 +626,10 @@ class _PlaybackOverlay extends StatefulWidget { final bool liked; final bool saved; final bool playing; + final bool controlsVisible; final VoidCallback onTogglePlay; + final VoidCallback onClear; + final VoidCallback onExitClear; final VoidCallback? onBack; final VoidCallback onEpisodes; final VoidCallback onComments; @@ -638,14 +647,6 @@ class _PlaybackOverlay extends StatefulWidget { } class _PlaybackOverlayState extends State<_PlaybackOverlay> { - bool _controlsVisible = true; - - void _onVideoTap() { - widget.onTogglePlay(); - } - - void _clearScreen() => setState(() => _controlsVisible = false); - @override Widget build(BuildContext context) { return SafeArea( @@ -655,10 +656,10 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { child: GestureDetector( key: const Key('player-video-tap'), behavior: HitTestBehavior.opaque, - onTap: _onVideoTap, + onTap: widget.onTogglePlay, ), ), - if (_controlsVisible) + if (widget.controlsVisible) Column( key: const Key('player-controls-visible'), children: [ @@ -766,14 +767,14 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { ), ], ), - if (!_controlsVisible) + if (!widget.controlsVisible) Positioned( top: 8, right: 12, child: ExitClearScreenButton( key: const Key('player-exit-clear-screen'), tooltip: widget.l10n.exitClearScreen, - onPressed: () => setState(() => _controlsVisible = true), + onPressed: widget.onExitClear, ), ), if (!widget.playing) @@ -828,7 +829,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-clear-screen'), tooltip: widget.l10n.clearScreen, icon: Icons.fullscreen_rounded, - onPressed: _clearScreen, + onPressed: widget.onClear, ), const SizedBox(width: 8), _TopAction( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 292d4ed..77b35da 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -167,38 +167,27 @@ class HomeFeedOverlay extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Expanded( - child: Wrap( - spacing: 6, - runSpacing: 4, - children: [ - if (genre.isNotEmpty) - _tag( - genre, - background: AppColors.blue, - foreground: AppColors.onPrimary, - ), - if (drama.category.isNotEmpty && drama.category != genre) - _tag( - drama.category, - background: const Color(0xFF333333), - foreground: const Color(0xFFDDDDDD), - ), - ], + if (genre.isNotEmpty) + _tag( + genre, + background: AppColors.blue, + foreground: AppColors.onPrimary, ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - l10n.episodeOfTotal(episode.number, drama.episodeCount), - key: const Key('home-feed-episode'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), + if (drama.category.isNotEmpty && drama.category != genre) + _tag( + drama.category, + background: const Color(0xFF333333), + foreground: const Color(0xFFDDDDDD), ), + Text( + l10n.episodeOfTotal(episode.number, drama.episodeCount), + key: const Key('home-feed-episode'), + style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9), ), ], ), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index df22998..6d267ae 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -54,6 +54,13 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget); expect(find.byKey(const Key('home-feed-episode')), findsOneWidget); + final cardRect = tester.getRect( + find.byKey(const Key('home-feed-info-card')), + ); + final episodeRect = tester.getRect( + find.byKey(const Key('home-feed-episode')), + ); + expect(episodeRect.left, lessThan(cardRect.center.dx)); expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 46737d5..b27f2a1 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -216,6 +216,37 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsNothing); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-controls-top')), findsNothing); + expect(find.byKey(const Key('player-controls-bottom')), findsNothing); + expect(find.byKey(const Key('player-exit-clear-screen')), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-controls-top')), findsOneWidget); + expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); + expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); + }); + testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); From 3a7e0bc4b72d00d30560a952be9df4da6f27db0e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:32:17 +0800 Subject: [PATCH 22/30] =?UTF-8?q?feat(player):=20=E5=80=8D=E9=80=9F?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=BA=95=E9=83=A8=E5=BC=B9=E7=AA=97=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/l10n/app_en.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_localizations.dart | 6 ++ lib/l10n/app_localizations_en.dart | 3 + lib/l10n/app_localizations_id.dart | 3 + lib/l10n/app_localizations_zh.dart | 3 + lib/l10n/app_zh.arb | 1 + lib/pages/player/player_logic.dart | 12 +++ lib/pages/player/player_view.dart | 108 ++++++++++++++++++++++-- test/player/player_fullscreen_test.dart | 62 ++++++++++++++ 10 files changed, 194 insertions(+), 6 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3d4ceef..b710ee7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -48,6 +48,7 @@ }, "clearScreen": "Clear screen", "exitClearScreen": "Exit clear screen", + "playbackSpeed": "Playback speed", "minutesAgo": "{count} min ago", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6876854..6071753 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -48,6 +48,7 @@ }, "clearScreen": "Bersihkan layar", "exitClearScreen": "Keluar dari layar bersih", + "playbackSpeed": "Kecepatan putar", "minutesAgo": "{count} mnt lalu", "@minutesAgo": { "placeholders": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 42042f4..63446d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -352,6 +352,12 @@ abstract class AppLocalizations { /// **'Exit clear screen'** String get exitClearScreen; + /// No description provided for @playbackSpeed. + /// + /// In en, this message translates to: + /// **'Playback speed'** + String get playbackSpeed; + /// No description provided for @minutesAgo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index a09195b..f777db2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -139,6 +139,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get exitClearScreen => 'Exit clear screen'; + @override + String get playbackSpeed => 'Playback speed'; + @override String minutesAgo(int count) { return '$count min ago'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a1fadb8..a417c57 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -139,6 +139,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get exitClearScreen => 'Keluar dari layar bersih'; + @override + String get playbackSpeed => 'Kecepatan putar'; + @override String minutesAgo(int count) { return '$count mnt lalu'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c58a9a5..ea78f10 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -136,6 +136,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get exitClearScreen => '退出清屏'; + @override + String get playbackSpeed => '倍速'; + @override String minutesAgo(int count) { return '$count 分钟前'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 42ebcaf..5267de7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -48,6 +48,7 @@ }, "clearScreen": "清屏", "exitClearScreen": "退出清屏", + "playbackSpeed": "倍速", "minutesAgo": "{count} 分钟前", "@minutesAgo": { "placeholders": { diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 7362e1d..321a3b6 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -68,6 +68,7 @@ class PlayerLogic extends ChangeNotifier { List sessionEpisodes = const []; int pageRequest = 0; bool muted = false; + double playbackSpeed = 1.0; bool stoppedAtFeedEnd = false; VideoPlayerPort? scrubbingPlayer; bool pip = false; @@ -341,6 +342,7 @@ class PlayerLogic extends ChangeNotifier { Future preparePlayer(Episode episode, PlayerPort player) async { if (player is! VideoPlayerPort) return; try { + await player.controller.setPlaybackSpeed(playbackSpeed); await pendingWrite; // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 if (resumePoint == null) await loadResumePoint(); @@ -360,6 +362,7 @@ class PlayerLogic extends ChangeNotifier { final index = session.currentIndex; final drama = queue.entryAt(index).drama; await player.controller.setVolume(muted ? 0 : 1); + await player.controller.setPlaybackSpeed(playbackSpeed); if (session.active != player) return; trackedPlayer = player; trackedEpisode = episode; @@ -787,6 +790,15 @@ class PlayerLogic extends ChangeNotifier { notify(); } + Future setPlaybackSpeed(double value) async { + playbackSpeed = value; + final active = session.active; + if (active is VideoPlayerPort && active.controller.value.isInitialized) { + await active.controller.setPlaybackSpeed(value); + } + notify(); + } + Future showComments(BuildContext context, Drama drama) async { session.setOverlayOpen(true); await session.playbackSettled; diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index aae3c20..a333628 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -72,6 +72,7 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends State with WidgetsBindingObserver { late final PlayerLogic logic; bool _chromeVisible = true; + double _playbackSpeed = 1.0; @override void initState() { @@ -384,6 +385,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ? () => unawaited(logic.playNextEpisode()) : null, playing: logic.session.userWantsPlay, + playbackSpeed: _playbackSpeed, + onSpeedTap: () => unawaited(_openSpeedSheet(context)), controlsVisible: _chromeVisible, onClear: () => setState(() => _chromeVisible = false), onExitClear: () => setState(() => _chromeVisible = true), @@ -469,6 +472,18 @@ class _PlayerPageState extends State with WidgetsBindingObserver { ], ); } + + Future _openSpeedSheet(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.card, + showDragHandle: true, + builder: (_) => _PlaybackSpeedSheet(selected: _playbackSpeed), + ); + if (selected == null || !mounted) return; + setState(() => _playbackSpeed = selected); + await logic.setPlaybackSpeed(selected); + } } /// 不可播放提示:文案由后端返回的 `accessType` 驱动(契约 §1.4),前端不自行推断。 @@ -602,6 +617,8 @@ class _PlaybackOverlay extends StatefulWidget { required this.liked, required this.saved, required this.playing, + required this.playbackSpeed, + required this.onSpeedTap, required this.controlsVisible, required this.onTogglePlay, required this.onClear, @@ -626,6 +643,8 @@ class _PlaybackOverlay extends StatefulWidget { final bool liked; final bool saved; final bool playing; + final double playbackSpeed; + final VoidCallback onSpeedTap; final bool controlsVisible; final VoidCallback onTogglePlay; final VoidCallback onClear; @@ -667,14 +686,28 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { Expanded( child: Stack( children: [ - const Positioned( + Positioned( right: 10, bottom: 14, - child: Text( - '1.0x', - style: TextStyle( - color: Color(0xFFDDDDDD), - fontSize: 9, + child: Material( + color: Colors.transparent, + child: InkWell( + key: const Key('player-speed'), + onTap: widget.onSpeedTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), + child: Text( + _speedLabel(widget.playbackSpeed), + style: const TextStyle( + color: Color(0xFFDDDDDD), + fontSize: 9, + ), + ), + ), ), ), ), @@ -920,6 +953,69 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { } } +const List _speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]; + +String _speedLabel(double value) => '${value.toString()}x'; + +class _PlaybackSpeedSheet extends StatelessWidget { + const _PlaybackSpeedSheet({required this.selected}); + + final double selected; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return SafeArea( + child: Column( + key: const Key('player-speed-sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 2, 20, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.playbackSpeed, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + for (final value in _speedOptions) + ListTile( + key: Key('player-speed-${value.toString()}'), + dense: true, + tileColor: value == selected + ? AppColors.blue.withValues(alpha: 0.12) + : null, + title: Text( + _speedLabel(value), + style: TextStyle( + color: value == selected ? AppColors.blue : AppColors.cream, + fontSize: 13, + fontWeight: value == selected + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + trailing: value == selected + ? const Icon( + Icons.check_rounded, + color: AppColors.blue, + size: 18, + ) + : null, + onTap: () => Navigator.pop(context, value), + ), + ], + ), + ); + } +} + class _TopAction extends StatelessWidget { const _TopAction({ super.key, diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index b27f2a1..69c5a66 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -247,6 +247,68 @@ void main() { expect(find.byKey(const Key('player-exit-clear-screen')), findsNothing); }); + testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + expect(find.text('1.0x'), findsOneWidget); + await tester.tap(find.byKey(const Key('player-speed'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); + for (final value in ['0.5', '0.75', '1.0', '1.25', '1.5', '2.0']) { + expect(find.byKey(Key('player-speed-$value')), findsOneWidget); + } + + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('player-speed-sheet')), findsNothing); + expect(find.text('1.5x'), findsOneWidget); + }); + + testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('player-speed'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-speed-1.5'))); + await tester.pumpAndSettle(); + expect(find.text('1.5x'), findsOneWidget); + + await tester.tap(find.byKey(const Key('player-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-speed')), findsNothing); + + await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); + await tester.pump(); + expect(find.byKey(const Key('player-speed')), findsOneWidget); + expect(find.text('1.5x'), findsOneWidget); + + await tester.drag(find.byType(PageView), const Offset(0, -550)); + await tester.pumpAndSettle(); + expect(find.text('1.5x'), findsOneWidget); + }); + testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); From 4d7173d035ab51416ab5e2cab7e2bd7f8ee20761 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:44:06 +0800 Subject: [PATCH 23/30] =?UTF-8?q?fix(player):=20=E8=B0=83=E6=95=B4=20rail?= =?UTF-8?q?=20=E5=80=8D=E9=80=9F=E5=BC=B9=E7=AA=97=E4=B8=8E=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/player/player_view.dart | 56 +++++++--------- .../player/widgets/home_feed_overlay.dart | 2 +- test/home/home_preview_layout_test.dart | 4 ++ test/playback/seek_progress_bar_test.dart | 2 + test/player/player_fullscreen_test.dart | 66 ++++++++++++++++--- 5 files changed, 88 insertions(+), 42 deletions(-) diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index a333628..20ac336 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -683,37 +683,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-controls-visible'), children: [ _topBar(context), - Expanded( - child: Stack( - children: [ - Positioned( - right: 10, - bottom: 14, - child: Material( - color: Colors.transparent, - child: InkWell( - key: const Key('player-speed'), - onTap: widget.onSpeedTap, - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - child: Text( - _speedLabel(widget.playbackSpeed), - style: const TextStyle( - color: Color(0xFFDDDDDD), - fontSize: 9, - ), - ), - ), - ), - ), - ), - ], - ), - ), + const Spacer(), Padding( key: const Key('player-controls-bottom'), padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), @@ -723,7 +693,7 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { key: const Key('player-progress'), valueListenable: widget.controller, semanticsLabel: widget.l10n.seekVideo, - showTimes: false, + showTimes: true, onScrubStart: widget.onScrubStart, onSeek: widget.onSeek, ), @@ -886,6 +856,28 @@ class _PlaybackOverlayState extends State<_PlaybackOverlay> { child: Column( mainAxisSize: MainAxisSize.min, children: [ + ListTile( + key: const Key('player-more-speed'), + leading: const Icon( + Icons.speed_rounded, + color: AppColors.secondary, + ), + title: Text(widget.l10n.playbackSpeed), + trailing: Text( + _speedLabel(widget.playbackSpeed), + style: const TextStyle( + color: AppColors.blue, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + onTap: () { + Navigator.pop(sheetContext); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSpeedTap(); + }); + }, + ), ListTile( key: const Key('player-more-download'), leading: const Icon( diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 77b35da..3fc5c00 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -55,7 +55,7 @@ class HomeFeedOverlay extends StatelessWidget { _header(context), Expanded( child: Align( - alignment: Alignment.centerRight, + alignment: const Alignment(1.0, 0.45), child: Padding( padding: const EdgeInsets.fromLTRB(0, 0, 12, 16), child: _rail(), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 6d267ae..753de68 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -61,6 +61,10 @@ void main() { find.byKey(const Key('home-feed-episode')), ); expect(episodeRect.left, lessThan(cardRect.center.dx)); + final railCenter = tester.getCenter(find.byKey(const Key('rail-like'))); + final railBottom = tester.getBottomLeft(find.byKey(const Key('rail-save'))); + expect(railCenter.dy, greaterThan(844 * 0.4)); + expect(railBottom.dy, lessThan(cardRect.top)); expect(find.byKey(const Key('home-feed-follow-series')), findsOneWidget); expect(find.byKey(const Key('home-feed-episodes')), findsOneWidget); for (final key in const [Key('home-feed-search'), Key('home-feed-more')]) { diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 67d3012..53ad74b 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -56,11 +56,13 @@ void main() { await tester.pump(); expect(started, 1); expect(target, isNull); + expect(find.textContaining('/ 00:12'), findsOneWidget); await gesture.up(); await tester.pumpAndSettle(); expect(target, isNotNull); expect(target!, greaterThan(const Duration(seconds: 3))); expect(target!, lessThan(const Duration(seconds: 12))); + expect(find.textContaining('/ 00:12'), findsNothing); }); testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 69c5a66..45fc167 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; @@ -260,8 +261,10 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - expect(find.text('1.0x'), findsOneWidget); - await tester.tap(find.byKey(const Key('player-speed'))); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('player-more-speed')), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more-speed'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); @@ -273,7 +276,15 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsNothing); - expect(find.text('1.5x'), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); }); testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( @@ -289,24 +300,61 @@ void main() { await tester.tap(find.byKey(const Key('detail-cta'))); await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('player-speed'))); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('player-more-speed'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); - expect(find.text('1.5x'), findsOneWidget); await tester.tap(find.byKey(const Key('player-clear-screen'))); await tester.pump(); - expect(find.byKey(const Key('player-speed')), findsNothing); + expect(find.byKey(const Key('player-more')), findsNothing); await tester.tap(find.byKey(const Key('player-exit-clear-screen'))); await tester.pump(); - expect(find.byKey(const Key('player-speed')), findsOneWidget); - expect(find.text('1.5x'), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); - expect(find.text('1.5x'), findsOneWidget); + await tester.tap(find.byKey(const Key('player-more'))); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: find.byKey(const Key('player-more-speed')), + matching: find.text('1.5x'), + ), + findsOneWidget, + ); + }); + + testWidgets('should_enable_drag_time_preview_on_player_progress', ( + tester, + ) async { + await _pumpApp(tester); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('drawer-open-series'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('detail-cta'))); + await tester.pumpAndSettle(); + + final bar = tester.widget( + find.byKey(const Key('player-progress')), + ); + expect(bar.showTimes, isTrue); }); testWidgets('should_clear_and_restore_home_feed_chrome', (tester) async { From 16175803ed96c89c2c333a14c9828784558af267 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 17:54:44 +0800 Subject: [PATCH 24/30] =?UTF-8?q?fix(discover):=20=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=8F=91=E7=8E=B0=E9=A1=B5=E9=A1=B6=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 117 ++++++++++--------- test/discover/home_all_dramas_grid_test.dart | 57 +++++++++ 2 files changed, 117 insertions(+), 57 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index f8122c6..1db6c8a 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -113,6 +113,7 @@ class _DiscoverPageState extends State { bottom: !widget.embedded, child: Column( children: [ + _header(l10n), Expanded(child: _feed(l10n, categories, allDramas, visible)), if (!widget.embedded) _bottomNav(l10n), ], @@ -137,7 +138,6 @@ class _DiscoverPageState extends State { ) => CustomScrollView( controller: logic.scroll, slivers: [ - SliverToBoxAdapter(child: _header(l10n)), SliverToBoxAdapter(child: _search()), SliverToBoxAdapter( child: Padding( @@ -222,69 +222,72 @@ class _DiscoverPageState extends State { ); Widget _header(AppLocalizations l10n) { - return Padding( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), - child: Row( - children: [ - Container( - width: 24, - height: 24, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(7), + return SizedBox( + height: 46, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(7), + ), + child: const Icon( + Icons.play_arrow_rounded, + color: AppColors.onPrimary, + size: 16, + ), ), - child: const Icon( - Icons.play_arrow_rounded, - color: AppColors.onPrimary, - size: 16, + const SizedBox(width: 8), + Text( + l10n.discoverTab, + style: const TextStyle( + fontFamily: 'Outfit', + color: AppColors.onSurface, + fontSize: 15, + fontWeight: FontWeight.w800, + ), ), - ), - const SizedBox(width: 8), - Text( - l10n.discoverTab, - style: const TextStyle( - fontFamily: 'Outfit', - color: AppColors.onSurface, - fontSize: 15, - fontWeight: FontWeight.w800, + const Spacer(), + _HeaderIcon( + key: const Key('discover-search-button'), + icon: Icons.search_rounded, + tooltip: l10n.search, + onTap: logic.openSearch, ), - ), - const Spacer(), - _HeaderIcon( - key: const Key('discover-search-button'), - icon: Icons.search_rounded, - tooltip: l10n.search, - onTap: logic.openSearch, - ), - const SizedBox(width: 8), - Semantics( - button: true, - label: widget.signedIn ? l10n.myTab : l10n.logIn, - child: InkWell( - key: const Key('discover-profile'), - customBorder: const CircleBorder(), - onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF6A5CFF), AppColors.blue], + const SizedBox(width: 8), + Semantics( + button: true, + label: widget.signedIn ? l10n.myTab : l10n.logIn, + child: InkWell( + key: const Key('discover-profile'), + customBorder: const CircleBorder(), + onTap: widget.signedIn ? widget.onCoins : widget.onSignIn, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF6A5CFF), AppColors.blue], + ), + ), + child: const Icon( + Icons.person_rounded, + color: AppColors.onSurface, + size: 18, ), - ), - child: const Icon( - Icons.person_rounded, - color: AppColors.onSurface, - size: 18, ), ), ), - ), - ], + ], + ), ), ); } diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index f7b747a..9408808 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -132,6 +132,43 @@ void main() { ); }); + testWidgets('should_keep_discover_header_fixed_when_content_scrolls', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp( + tester, + categoryRepository: _GridCategoryRepository( + discoverFeed: _tallDiscoverFeed, + ), + ); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final headerBefore = tester.getTopLeft( + find.byKey(const Key('discover-search-button')), + ); + final contentBefore = tester.getTopLeft( + find.byKey(const Key('home-category-chips')), + ); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -40)); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.byKey(const Key('discover-search-button'))), + headerBefore, + ); + expect( + tester.getTopLeft(find.byKey(const Key('home-category-chips'))).dy, + lessThan(contentBefore.dy), + ); + }); + testWidgets('should_keep_empty_messages_when_feed_has_no_dramas', ( tester, ) async { @@ -341,3 +378,23 @@ final _defaultDiscoverFeed = HomeFeed( ), ], ); + +final _tallDiscoverFeed = HomeFeed( + sections: [ + FeedSection( + code: 'hot', + title: 'Hot', + dramas: [ + for (var index = 0; index < 12; index++) + Drama( + id: 'tall-$index', + title: 'Tall Drama $index', + synopsis: 'Tall Drama $index synopsis', + posterUrl: '/files/tall-$index.png', + genre: 'Urban Romance', + detailLoaded: true, + ), + ], + ), + ], +); From 4c2384fea9252409afccaa84d3f0d6ca9530e582 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:01:37 +0800 Subject: [PATCH 25/30] =?UTF-8?q?fix(discover):=20=E5=B0=81=E9=9D=A2?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=98=BE=E7=A4=BA=E5=B9=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=9B=BE=E4=B8=8A=E6=A0=87=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discover/widgets/home_drama_grid.dart | 35 ++++--------------- test/discover/home_all_dramas_grid_test.dart | 20 +++++++++++ 2 files changed, 26 insertions(+), 29 deletions(-) diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index f2a1ee0..7197dd2 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -119,19 +119,12 @@ class _HomeDramaCard extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - RemoteImage( - drama.posterUrl, - fit: BoxFit.cover, - showFallbackIcon: true, - ), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x0D000000), Color(0xCC000000)], - stops: [0.45, 1], - ), + ColoredBox( + color: AppColors.card, + child: RemoteImage( + drama.posterUrl, + fit: BoxFit.contain, + showFallbackIcon: true, ), ), if (free) @@ -157,22 +150,6 @@ class _HomeDramaCard extends StatelessWidget { ), ), ), - Positioned( - left: 6, - right: 6, - bottom: 6, - child: Text( - drama.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 9, - fontWeight: FontWeight.w800, - shadows: [Shadow(blurRadius: 6, color: Colors.black)], - ), - ), - ), ], ), ), diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 9408808..419b6b0 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -84,6 +84,26 @@ void main() { .height, 120, ); + final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); + expect( + tester + .widget( + find.descendant(of: cover, matching: find.byType(RemoteImage)), + ) + .fit, + BoxFit.contain, + ); + expect( + find.descendant( + of: cover, + matching: find.text('City Lights & Velvet Nights'), + ), + findsNothing, + ); + expect( + find.byKey(const Key('home-drama-title-city-lights-demo')), + findsOneWidget, + ); expect( tester .widget( From 884687d10a7b05492eb50a0fb18c9a7d3f9ad4cd Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:04:00 +0800 Subject: [PATCH 26/30] =?UTF-8?q?fix(discover):=20=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8F=96=E6=B6=88=E6=90=9C=E7=B4=A2=E7=84=A6?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 1 + test/discover/home_all_dramas_grid_test.dart | 51 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index 1db6c8a..a520c43 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -302,6 +302,7 @@ class _DiscoverPageState extends State { controller: logic.search, focusNode: logic.searchFocus, onChanged: (_) => logic.onSearchChanged(), + onTapOutside: (_) => logic.searchFocus.unfocus(), textInputAction: TextInputAction.search, style: const TextStyle(color: AppColors.onSurface, fontSize: 13), decoration: InputDecoration( diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 419b6b0..b7aad49 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -295,6 +295,57 @@ void main() { ); }); + testWidgets('should_unfocus_search_without_losing_input_or_filter', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await _pumpApp(tester, categoryRepository: _GridCategoryRepository()); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('discover-search'))); + await tester.pump(); + expect( + tester + .widget(find.byKey(const Key('discover-search'))) + .focusNode! + .hasFocus, + isTrue, + ); + + await tester.enterText(find.byKey(const Key('discover-search')), 'velvet'); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + + await tester.tapAt(const Offset(70, 23)); + await tester.pump(); + + final field = tester.widget( + find.byKey(const Key('discover-search')), + ); + expect(field.focusNode!.hasFocus, isFalse); + expect(field.controller!.text, 'velvet'); + expect( + find.byKey(const Key('home-drama-card-city-lights-demo')), + findsOneWidget, + ); + expect( + find.byKey(const Key('home-drama-card-the-signal-demo')), + findsNothing, + ); + }); + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( tester, ) async { From d271c43febecdfa410c8064602d1afa85f6d786d Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:10:30 +0800 Subject: [PATCH 27/30] =?UTF-8?q?feat(discover):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=80=91=E5=B8=83=E6=B5=81=E4=B8=8E=E4=B8=8B=E6=8B=89=E5=88=B7?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/discover/discover_view.dart | 160 ++++++++++-------- .../discover/widgets/home_drama_grid.dart | 146 ++++++++++------ test/discover/home_all_dramas_grid_test.dart | 101 ++++++++--- 3 files changed, 256 insertions(+), 151 deletions(-) diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index a520c43..656adcb 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -135,90 +136,99 @@ class _DiscoverPageState extends State { CategoryController categories, List allDramas, List visible, - ) => CustomScrollView( - controller: logic.scroll, - slivers: [ - SliverToBoxAdapter(child: _search()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), - child: _categories(l10n, categories), + ) => RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshResume()]); + }, + child: CustomScrollView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _search()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), + ), ), - ), - if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _continueCard( + l10n, + logic.resumeDrama(allDramas)!, + logic.resumeRecord!, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _continueCard( - l10n, - logic.resumeDrama(allDramas)!, - logic.resumeRecord!, + child: _quickActions(l10n, categories), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), + child: Row( + children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.allCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(44, 44), + padding: EdgeInsets.zero, + ), + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), + ), + ), + ], ), ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _quickActions(l10n, categories), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), - child: Row( - children: [ - Container( - width: 3, - height: 18, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - l10n.trendingMicroDramas, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, - fontWeight: FontWeight.w800, - ), - ), - ), - TextButton( - onPressed: () => - categories.selectCode(CategoryController.allCode), - style: TextButton.styleFrom( - foregroundColor: AppColors.accent, - minimumSize: const Size(44, 44), - padding: EdgeInsets.zero, - ), - child: Text( - '${l10n.all} ›', - style: const TextStyle(fontSize: 10), - ), - ), - ], + _HomeDramaGrid( + l10n: l10n, + categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, + onOpen: (drama) => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, ), ), - ), - _HomeDramaGrid( - l10n: l10n, - categories: categories, - dramas: visible, - searching: logic.search.text.trim().isNotEmpty, - onOpen: (drama) => logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - ), - ], + ], + ), ); Widget _header(AppLocalizations l10n) { diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 7197dd2..d2bd04c 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -55,33 +55,44 @@ class _HomeDramaGrid extends StatelessWidget { } return SliverMainAxisGroup( slivers: [ - _dramaGrid(dramas), + SliverToBoxAdapter(child: _masonry(dramas)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ); } - Widget _dramaGrid(List items) => SliverPadding( - key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: 14), - sliver: SliverGrid( - key: const Key('home-all-dramas-grid'), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 164, + Widget _masonry(List items) { + return Padding( + key: const Key('home-all-dramas-section'), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + key: const Key('home-all-dramas-grid'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(items, 0)), + const SizedBox(width: 10), + Expanded(child: _column(items, 1)), + ], ), - delegate: SliverChildBuilderDelegate( - (context, index) => _HomeDramaCard( + ); + } + + Widget _column(List items, int start) { + final children = []; + for (var index = start; index < items.length; index += 2) { + children.add( + _HomeDramaCard( l10n: l10n, drama: items[index], onTap: () => onOpen(items[index]), ), - childCount: items.length, - ), - ), - ); + ); + if (index + 2 < items.length) { + children.add(const SizedBox(height: 10)); + } + } + return Column(children: children); + } Widget _fill(Widget child) => SliverFillRemaining(hasScrollBody: false, child: child); @@ -111,47 +122,36 @@ class _HomeDramaCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( + ClipRRect( key: Key('home-drama-cover-${drama.id}'), - height: 120, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: AppColors.card, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.contain, - showFallbackIcon: true, - ), - ), - if (free) - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - l10n.free, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 8, - fontWeight: FontWeight.w800, - ), + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + if (free) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ), - ], - ), + ), + ], ), ), const SizedBox(height: 5), @@ -179,3 +179,41 @@ class _HomeDramaCard extends StatelessWidget { ); } } + +class _DiscoverCoverImage extends StatelessWidget { + const _DiscoverCoverImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, + width: double.infinity, + fit: BoxFit.fitWidth, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, + ), + ), + ), + ); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b7aad49..de51edf 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -12,7 +12,6 @@ import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/widgets/remote_image.dart'; import '../support/fake_catalog.dart'; import '../support/fake_interaction_repositories.dart'; @@ -26,7 +25,7 @@ void main() { }); testWidgets( - 'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching', + 'should_show_all_dramas_in_two_column_masonry_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -57,41 +56,48 @@ void main() { ); } expect(find.text('For You'), findsNothing); - final grid = tester.widget( + final masonry = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 2); - expect(delegate.crossAxisSpacing, 10); - expect(delegate.mainAxisSpacing, 10); - expect(delegate.mainAxisExtent, 164); + expect(masonry.children, hasLength(3)); + expect((masonry.children[1] as SizedBox).width, 10); expect( tester - .widget( - find.byKey(const Key('home-all-dramas-section')), - ) + .widget(find.byKey(const Key('home-all-dramas-section'))) .padding, const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); - expect(find.byType(RemoteImage), findsWidgets); - - expect( - tester - .getSize(find.byKey(const Key('home-drama-cover-city-lights-demo'))) - .height, - 120, + final signalCard = find.byKey( + const Key('home-drama-card-the-signal-demo'), ); + final cityCard = find.byKey( + const Key('home-drama-card-city-lights-demo'), + ); + final vengeanceCard = find.byKey( + const Key('home-drama-card-vengeance-demo'), + ); + expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); + expect( + tester.getTopLeft(vengeanceCard).dx, + tester.getTopLeft(signalCard).dx, + ); + expect( + tester.getTopLeft(vengeanceCard).dy - + tester.getBottomLeft(signalCard).dy, + closeTo(10, 1), + ); + final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); + expect(tester.getSize(cover).height, greaterThan(120)); expect( tester - .widget( - find.descendant(of: cover, matching: find.byType(RemoteImage)), + .widget( + find.descendant(of: cover, matching: find.byType(Image)), ) .fit, - BoxFit.contain, + BoxFit.fitWidth, ); expect( find.descendant( @@ -104,6 +110,14 @@ void main() { find.byKey(const Key('home-drama-title-city-lights-demo')), findsOneWidget, ); + expect( + tester + .getTopLeft( + find.byKey(const Key('home-drama-title-city-lights-demo')), + ) + .dy, + greaterThan(tester.getBottomLeft(cover).dy), + ); expect( tester .widget( @@ -346,6 +360,29 @@ void main() { ); }); + testWidgets('should_refresh_feed_when_pulled_down', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final counter = _CountingCategoryRepository( + _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), + ); + await _pumpApp(tester, categoryRepository: counter); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final before = counter.categoryLoads + counter.feedLoads; + await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); + expect(find.byType(RefreshProgressIndicator), findsNothing); + }); + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( tester, ) async { @@ -469,3 +506,23 @@ final _tallDiscoverFeed = HomeFeed( ), ], ); + +class _CountingCategoryRepository implements CategoryRepository { + _CountingCategoryRepository(this.delegate); + + final CategoryRepository delegate; + int categoryLoads = 0; + int feedLoads = 0; + + @override + Future> fetchCategories() { + categoryLoads++; + return delegate.fetchCategories(); + } + + @override + Future fetchDiscoverFeed() { + feedLoads++; + return delegate.fetchDiscoverFeed(); + } +} From 501446b3a63208fdfe54274c47e94f8fa3e50253 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:19:36 +0800 Subject: [PATCH 28/30] =?UTF-8?q?chore(config):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E9=BB=98=E8=AE=A4=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 2 +- test/config/server_endpoint_test.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 122da27..c3f9f4e 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -13,7 +13,7 @@ class AppConfig { /// /// 这个地址跟着电脑所在的网络走,换网络后要用 /// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。 - static const String devHostBaseUrl = 'http://192.168.31.187:8081'; + static const String devHostBaseUrl = 'http://192.168.31.250:8081'; static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 7d97b77..1b80f8c 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -57,13 +57,13 @@ void main() { }); test('should_use_the_configured_lan_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081'); + expect(AppConfig.devHostBaseUrl, 'http://192.168.31.250:8081'); }); test('should_apply_stored_url_only_when_debug_is_enabled', () async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ - ServerEndpoint.preferenceKey: ' http://192.168.31.187:8081/ ', + ServerEndpoint.preferenceKey: ' http://192.168.31.250:8081/ ', }); final original = AppConfig.apiBaseUrl; @@ -71,7 +71,7 @@ void main() { expect(AppConfig.apiBaseUrl, original); await ServerEndpoint.load(debug: true); - expect(AppConfig.apiBaseUrl, 'http://192.168.31.187:8081'); + expect(AppConfig.apiBaseUrl, 'http://192.168.31.250:8081'); }); test('should_save_normalized_url_and_clear_override', () async { @@ -81,10 +81,10 @@ void main() { isNull, ); - expect(await ServerEndpoint.save(' http://192.168.31.187:8081/ '), isTrue); + expect(await ServerEndpoint.save(' http://192.168.31.250:8081/ '), isTrue); expect( await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), - 'http://192.168.31.187:8081', + 'http://192.168.31.250:8081', ); await ServerEndpoint.clear(); From cb2fa920ea09e766e0aaac26800a2f5d7589ea23 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:27:17 +0800 Subject: [PATCH 29/30] =?UTF-8?q?chore(config):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E9=BB=98=E8=AE=A4=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 2 +- test/config/server_endpoint_test.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index c3f9f4e..6c951a7 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -13,7 +13,7 @@ class AppConfig { /// /// 这个地址跟着电脑所在的网络走,换网络后要用 /// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。 - static const String devHostBaseUrl = 'http://192.168.31.250:8081'; + static const String devHostBaseUrl = 'http://172.18.0.7:8081'; static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 1b80f8c..9819363 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -57,13 +57,13 @@ void main() { }); test('should_use_the_configured_lan_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://192.168.31.250:8081'); + expect(AppConfig.devHostBaseUrl, 'http://172.18.0.7:8081'); }); test('should_apply_stored_url_only_when_debug_is_enabled', () async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ - ServerEndpoint.preferenceKey: ' http://192.168.31.250:8081/ ', + ServerEndpoint.preferenceKey: ' http://172.18.0.7:8081/ ', }); final original = AppConfig.apiBaseUrl; @@ -71,7 +71,7 @@ void main() { expect(AppConfig.apiBaseUrl, original); await ServerEndpoint.load(debug: true); - expect(AppConfig.apiBaseUrl, 'http://192.168.31.250:8081'); + expect(AppConfig.apiBaseUrl, 'http://172.18.0.7:8081'); }); test('should_save_normalized_url_and_clear_override', () async { @@ -81,10 +81,10 @@ void main() { isNull, ); - expect(await ServerEndpoint.save(' http://192.168.31.250:8081/ '), isTrue); + expect(await ServerEndpoint.save(' http://172.18.0.7:8081/ '), isTrue); expect( await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), - 'http://192.168.31.250:8081', + 'http://172.18.0.7:8081', ); await ServerEndpoint.clear(); From d75b69621f4b059b3943c73ccc00a8c543e633de Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 19 Oct 2026 18:31:23 +0800 Subject: [PATCH 30/30] =?UTF-8?q?chore(config):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E9=BB=98=E8=AE=A4=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/app_config.dart | 2 +- test/config/server_endpoint_test.dart | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 6c951a7..122da27 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -13,7 +13,7 @@ class AppConfig { /// /// 这个地址跟着电脑所在的网络走,换网络后要用 /// `ipconfig getifaddr en0` 重新确认。默认值只改这一行。 - static const String devHostBaseUrl = 'http://172.18.0.7:8081'; + static const String devHostBaseUrl = 'http://192.168.31.187:8081'; static const String _loopbackBaseUrl = 'http://127.0.0.1:8081'; diff --git a/test/config/server_endpoint_test.dart b/test/config/server_endpoint_test.dart index 9819363..4093ba0 100644 --- a/test/config/server_endpoint_test.dart +++ b/test/config/server_endpoint_test.dart @@ -57,13 +57,13 @@ void main() { }); test('should_use_the_configured_lan_address_for_device_default', () { - expect(AppConfig.devHostBaseUrl, 'http://172.18.0.7:8081'); + expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081'); }); test('should_apply_stored_url_only_when_debug_is_enabled', () async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ - ServerEndpoint.preferenceKey: ' http://172.18.0.7:8081/ ', + ServerEndpoint.preferenceKey: ' http://192.168.31.187:8081/ ', }); final original = AppConfig.apiBaseUrl; @@ -71,7 +71,7 @@ void main() { expect(AppConfig.apiBaseUrl, original); await ServerEndpoint.load(debug: true); - expect(AppConfig.apiBaseUrl, 'http://172.18.0.7:8081'); + expect(AppConfig.apiBaseUrl, 'http://192.168.31.187:8081'); }); test('should_save_normalized_url_and_clear_override', () async { @@ -81,10 +81,13 @@ void main() { isNull, ); - expect(await ServerEndpoint.save(' http://172.18.0.7:8081/ '), isTrue); + expect( + await ServerEndpoint.save(' http://192.168.31.187:8081/ '), + isTrue, + ); expect( await SharedPreferencesAsync().getString(ServerEndpoint.preferenceKey), - 'http://172.18.0.7:8081', + 'http://192.168.31.187:8081', ); await ServerEndpoint.clear();