From a0ed043cb8ae2d15813a2d34ac0d13a8213b5fbf Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Fri, 10 May 2024 16:08:32 +0200 Subject: [PATCH] feat: workspace settings page (#5225) * feat: my account settings page * test: amend tests * chore: remove unused code * test: remove widget tests * fix: text color on select buttons * test: clean and remove unused test helpers * feat: settings workspace page * chore: fixes after merge * fix: recent views bugfix * fix: make sure text buttons have color * test: add test for delete workspace in settings * test: remove pumpAndSettle for create workspace * test: longer pump duration * test: attempt with large pump duration * test: attempt workaround * chore: clean code * fix: missing language key * test: add one more check * test: pump * test: more pump * test: attempt pumpAndSettle * chore: code review * fix: persist single workspace on patch * fix: listen to workspace changes * chore: remove redundant builder * test: remove unstable test * fix: changes after merge * chore: changes after merge * feat: support changing cursor and selection color * chore: move members up in menu * feat: clean code and beautify dialogs * fix: fix test and make show selected font --------- Co-authored-by: Lucas.Xu --- .../assets/images/appearance/dark.png | Bin 0 -> 3746 bytes .../assets/images/appearance/light.png | Bin 0 -> 3613 bytes .../assets/images/appearance/system.png | Bin 0 -> 4130 bytes .../collaborative_workspace_test.dart | 5 +- .../desktop/settings/settings_runner.dart | 2 - .../desktop/settings/user_language_test.dart | 70 -- .../appearance_settings_test.dart | 93 -- .../desktop/uncategorized/hotkeys_test.dart | 32 +- .../desktop/uncategorized/language_test.dart | 2 +- .../integration_test/desktop_runner_3.dart | 3 - .../integration_test/shared/base.dart | 26 +- .../shared/common_operations.dart | 12 +- .../integration_test/shared/settings.dart | 23 +- .../lib/flutter/af_dropdown_menu.dart | 1042 +++++++++++++++++ .../home/mobile_home_page_header.dart | 6 + .../workspace_menu_bottom_sheet.dart | 10 +- .../font/customize_font_toolbar_item.dart | 252 +++- .../document/presentation/editor_style.dart | 9 +- .../lib/user/application/user_listener.dart | 24 +- .../screens/skip_log_in_screen.dart | 142 ++- .../levenshtein.dart | 0 .../application/appearance_defaults.dart | 7 +- .../recent/cached_recent_service.dart | 12 +- .../settings/appearance/base_appearance.dart | 3 +- .../settings/date_time/time_format_ext.dart | 13 + .../settings/settings_dialog_bloc.dart | 4 +- .../workspace/workspace_settings_bloc.dart | 153 +++ .../application/user/user_workspace_bloc.dart | 42 +- .../home/desktop_home_screen.dart | 62 +- .../home/menu/sidebar/sidebar.dart | 6 +- .../home/menu/sidebar/sidebar_setting.dart | 10 +- .../home/menu/sidebar/sidebar_workspace.dart | 14 +- .../workspace/_sidebar_workspace_icon.dart | 31 +- .../workspace/_sidebar_workspace_menu.dart | 9 +- .../settings/pages/settings_account_view.dart | 18 +- .../pages/settings_workspace_view.dart | 906 ++++++++++++++ .../settings/settings_dialog.dart | 9 +- .../shared/af_dropdown_menu_entry.dart | 43 + .../document_color_setting_button.dart | 177 ++- .../settings/shared/setting_action.dart | 51 + .../setting_list_tile.dart} | 65 +- .../shared/setting_value_dropdown.dart | 54 + .../shared/settings_actionable_input.dart | 54 + .../settings/shared/settings_body.dart | 20 +- .../shared/settings_dashed_divider.dart | 75 ++ .../settings/shared/settings_dropdown.dart | 108 ++ .../shared/settings_radio_select.dart | 74 ++ .../feature_flags/feature_flag_page.dart | 5 +- .../members/workspace_member_page.dart | 9 +- .../settings/widgets/setting_cloud.dart | 20 +- .../brightness_setting.dart | 67 -- .../settings_appearance/color_scheme.dart | 174 --- .../create_file_setting.dart | 43 - .../date_format_setting.dart | 72 -- .../direction_setting.dart | 173 --- .../document_cursor_color_setting.dart | 83 -- .../document_selection_color_setting.dart | 86 -- .../font_family_setting.dart | 247 ---- .../settings_appearance.dart | 6 - .../time_format_setting.dart | 63 - .../widgets/settings_appearance_view.dart | 76 -- .../settings_customize_shortcuts_view.dart | 7 +- .../widgets/settings_file_system_view.dart | 17 +- .../widgets/settings_language_view.dart | 119 -- .../settings/widgets/settings_menu.dart | 33 +- .../widgets/settings_notifications_view.dart | 7 +- frontend/appflowy_flutter/pubspec.yaml | 1 + .../unit_test/algorithm/levenshtein_test.dart | 2 +- .../theme_font_family_setting_test.dart | 5 +- .../windows/flutter/CMakeLists.txt | 7 +- .../flowy_icons/24x/textdirection_auto.svg | 8 + .../flowy_icons/24x/textdirection_ltr.svg | 8 + .../flowy_icons/24x/textdirection_rtl.svg | 8 + frontend/resources/translations/en.json | 73 ++ .../rust-lib/flowy-user/src/notification.rs | 1 + .../flowy-user/src/user_manager/manager.rs | 10 +- .../user_manager/manager_user_workspace.rs | 56 +- 77 files changed, 3434 insertions(+), 1825 deletions(-) create mode 100644 frontend/appflowy_flutter/assets/images/appearance/dark.png create mode 100644 frontend/appflowy_flutter/assets/images/appearance/light.png create mode 100644 frontend/appflowy_flutter/assets/images/appearance/system.png delete mode 100644 frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart delete mode 100644 frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart create mode 100644 frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart rename frontend/appflowy_flutter/lib/{workspace/presentation/settings/widgets/settings_appearance => util}/levenshtein.dart (100%) create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart rename frontend/appflowy_flutter/lib/workspace/presentation/settings/{widgets/settings_appearance => shared}/document_color_setting_button.dart (59%) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart rename frontend/appflowy_flutter/lib/workspace/presentation/settings/{widgets/settings_appearance/theme_setting_entry_template.dart => shared/setting_list_tile.dart} (56%) create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart create mode 100644 frontend/resources/flowy_icons/24x/textdirection_auto.svg create mode 100644 frontend/resources/flowy_icons/24x/textdirection_ltr.svg create mode 100644 frontend/resources/flowy_icons/24x/textdirection_rtl.svg diff --git a/frontend/appflowy_flutter/assets/images/appearance/dark.png b/frontend/appflowy_flutter/assets/images/appearance/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..f40e6a884bc4062de740de27e6a56ffca4f171b9 GIT binary patch literal 3746 zcmV;T4qfqyP)R;&--Y5nR8UQzvObi z&ONX9cmMZy{{R0u=ORF8X=!Q5WHQguat-ZP!pEn@OH#^*uD|~JcERMP)9LqU*&GN2 zo~DDnUTUC+Azpal1$pP4cM95-?d|Qdp`k%^c6Lg~aj1SN`uh4L)v5LcAs&y*($Z2f zG&Cetfl-+C$jFGKx2I!5;+a?5cc=7Ij^a4Z(PZg6mLE&WN019b8!T7I};!-l85 z63dmhwzkT~#zwWx6%!$n$Q4w%Q&UsJYZ(j%`4598v#hL4GP#=Y;o)H!3WY=>kubz@ zS-t@#mq{!tDiYQ?hmH%2baRcqQ6}3yuZiS8Llecv=swsApdSL4aS64znN)}G*?v%( zP$t~HHGPa9a-zDr`Yk#arh9!(Y~Q|}OI3NXTumc`DrK(H;B$<^SxcuhnbvbYSB%mg#hOa%Rouq@WLRHu-|<{q<2vn|uM-du~+ zWZPw!*qpM`<;6S@`rYuEGiSaVVB$QID@hUrOTxi}2W2=MmfwH$@rL%}9nS`w;3B%= z3RTX!Moh5pbvw7t|E7$+pSzyy9CCiA&~IrZhX*m(-*5Jx{ne8QU=mnX%jBvjHGlqm z`PDn_YdG}Lv7MQ8ni|Dn_y9(R1~JgzmvwAHf^EI6t*uqBtCoZ#ZEZgrCD+G97?)s| zTxGHA*RNOWlP6Cqa#<#W0p}|T-*6{|EOgM-Th*dcL*}pxhEY$!VGn>k1!T*Tk8zMi zFk7u|Az5$POJJYTb-|Q-Mcx6sVG=$(!4Rbb2R=q#f}>q9CYOT+k_P1pRaI39cEQH0 zaACL@Pt;dqX<3Omws=+=kKdla(%ETqOw5)DYzjg&UEI$~X|UE?3LKR6vJ1wszYAxcNW_5>6Vw{mwATLK)n0Z3-KIQi5noAn;%i z2-EsHiR5DmWco9h${+(nUEP&nmzTH!8KI}^6v5;rI22`xB}#h!G-O$xUWjdcwsIs1s6n=UliZ4}#ur)fU_Aw&xJ1>Q;`yBMd zM&0<-i%G}@66rlDWcsGI^)ikgJ&Nw`F3{Tzirze5rwC>*fl1Xa7<&mU2@FbFu;*aN zte4K+z=tO&qI5&X3uU|<-I%$OAnnGD$z`J9!xI!y zc4EAuqC&b2n<1JAb$H>!OfKbceS$s0NQ7;r)QE8?4ANYw^R|4P$>rRv+we?vc}S)*YO$dR zW=UZGR(ItvxprTd?!IB`lb7894{B z$?N5RTo1u`Vg)2YcQ#7b(z7I@rYe$0u0FoBxgNXrwPPw{-I^7kTnwtKDzNpn7Iby@ zW;cX_`u4Kma z3PtX+Z@6ti#IXOK~Bw zYUOnp8A+17g>m+56l>QkFIcX-rw4!e+g9}T$MEal`~mOmc@Kkw@k#4fcTS`#pO-d` z*7EoRxZUZ(8tkF1+peUzC}_6l&2OgAyM$AqqdyRI5TL5LcZn~ppjYd$M<3!GDXyN| zgcF~1VCSwqxKN0MLiGAO*s=2++-ezi#9Vsd8-ejLg+v?p5YN9y|oeuH?k2DCRWQ;^2Yz^M2spM>|a;$4`6DyZbPe zuD1vo##&8GK zW$7=*h9c>qMow-;U1$68O6wt1mWN1k1kT0cWU-gF56mzYY|8+zwM%A?Xtc+96{?J_ zHhOLlKE|M4b?t`n!s$xgu-QXIg*95SkOliF4C6&;FM&r3+GA(6?rJM${dJG|@BrgQ zXdhQg0UzeTLJwB9$I<%99`Dc=%%QSs*qA)FQd@I1slpu(%cR4HC+M!;EHA;81|f1r ziCgxjY@29Z%1QeBf2S(J#*#^96x>>rKJ&6 zQ8tYeKs$E4!G8lTM(BBLoFQ|x9N5yJrzwfiIbhnl^9bggD(6h=>+4WgSBF)rR-vQg z6z0si3TxJ^R$=tIb!#v%(2wTk1w`~rR1(1#J4_>oG51UlmCBv{sQcnXAurb5qv;91zV?Vq4fT=g78A`!CmYGvIzIe#(2NQCVr*gmio za0rn-@h%IePMtQ}Or%CjORHKlk(VM&udvlg$fH?_yrHW!sg+mfnCf_dJYC4WYyDW5zR&OjyRaY3`&KX$2d$C98$y!b%Dm z3^g_9SKN0N#c#fh;@7vRZ5ta)PN7J6>rN7B?-NeO#b@ND#0%VaE{qqUrz*A|`g ztppV!kEg+MXb|a=u-bNxe}r`P<;WC=Mzla)K(g_Rq-_bhHiF>$J25hU8PZkL@c)MKgKbIB9)q6sX-9HaLzsu~pPs_t zt=}eMrxU{gjLci2jt38o4lJn@!`S?VgJ>$R!7rK;_}0Ekw4dlfdgd$y4(vv7|C>Ze znIdy!_Cg{khCs&w>NHG34))Q*3WFXuhHu)0qFvjNDX)TvoS3v>>r~5R9m)$^R zV+j5Acl6N8RhbLvW$Q-HgvK_xdfS!>jD5%=5B>N3U~DIqgwuI~YIMlzMhBss%9P^O z5B4K?d^e)02wqM866wp+MC>l4reBHV)z?CvK8ECMe)ZCDqMb<7!$?!(DTuVdsbk7g z!EHeeJwzTV%8hSj4V;e;P#twh*3U)o@P4x3`RZYbObX7ikB|w4aUoz^up!Tt2%Sw6 zmL}WV24XmWbWZZBX2@8t(m#HMHMV5d;zvW@Lu0ayj68+36U5NXo5n6&v^uK}8=1YY zLp01onj|B!{L9%txZ)G|W}6d+=;0E;z_dD}UqVNsDDkgzc)i zi5+QMux<$5NN=o??hee@eCfGqUSRUUuzD-B9N3?&a;;XmYJMc=9d;yVzNn<7!X;Ph zvedKk-L(%7uq`<2b5*c{4C^~#I?%}LGLru6+JZ0yTS*<2c-L;46>zBY`gm?dJlUnZ zyc|BVVH!C*a?>)QE!mO=BOiq->+3tendp}S6j64L!Ln4xJ+8@BWw>hzpNmguObzI4 zT2?0~vaMEV_YD5Hi1S8PpPX*wY)R0`9_^~}d@d=t{O^oomp6hP`OZrt8H6_kXjcxTx5- z;v7WCa&NnJBksNXwqDAmKd7|S{htV)qRp?t$EQUvs{$J?|2Gf+0Sn4?bS&^#RR910 M07*qoM6N<$f=NJGUH||9 literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/assets/images/appearance/light.png b/frontend/appflowy_flutter/assets/images/appearance/light.png new file mode 100644 index 0000000000000000000000000000000000000000..49f32bf3aa4cc75c36a2edb0e533f232de712b9d GIT binary patch literal 3613 zcmV+&4&w2NP)5nG0NgInM{%X>i z#+WoES~RKJ8VmBF&??9R+bzoi%PtFu`whD@v%52WpEKv&Id{HRU6GZ&PjdI(d(OFE z&%E=T_q^}9vknB?w{KsNPN!dxWx4FuBgAyE&2`x-yXVfGb6M$mo;;K5@Xv({7yL3ugXN>6qmEr> zsuGEWs^<=`Gd@19w#&=Q{cTe>F)`t{GsjO(PP)P!xE>oDtL^Xazds(2KPsnO;k^C! z+kX>s-`TKX!?T!KaD-fJ-MZD?v}u#`!3Q5Wb#--o5QmEp#kH%+^9dRm8BtG7BIRM- z>gsAImslaxjm2V$SRoN4k=Nrn^fuWu?OEoOm6iGA8iGlvSx2K$f1l{>?frqAe?deF zHp-1Yh8Y5Fv5pX{)+CqfrluxW$WT1?_4PS(=FCwKJ2W&DAe!WHO=4?mY8jla>3EB58*KEi=(~A9a9^25+@amZ}XI`KMr`N4p z=ioD9Y%IMXjfp?W-K=mO>Nii7=IxOzIF3yo)V^6~kZI2{za;M%bFR#qhh&@NUPrfq zeLizf*>l=;=J?=!S@(#j2tly z@%MqZ@O1ZcI6T^pD;3l7A_~Dd+l#V4{{e`{;qBjx#IK%_A=Sr-9{mOV7`S~qFuvk1 z=$stEFHijrDJk1Nya#W!{1r1DU0o+p6|2Ib!-sIsJ@;~?SgQHwAQ6QiL&mhLFPzd& zq8nA@W-c31myJ46MXox;`P@I}OM)HS|Amjve}vA?W4L(nV#&5ESOl*D}ZrdSD-u`u@t81jR2IDvW2JX=NNHuLhI$Dow#k+fY{!`e0p%oh%?!}h@ z-~HYrNG6h~+E9h+nwpYr^JMs zvY9PV`#Ok?@``GN2toq#YHh-xib%IYu+d{C%#XcK}p{G`XE)w>{q<6(%@WMc zi-eXUmW0Z#TA1?oazaVT(TwGmSJt7Xehw;Q4I&Q>nYwtE$mDnu&p-bh4#`daaYyqEnvRw_uV; zh_*&ZBKN7Y1j!|-GQRfA!Z!Aa(w5KWc@ZJE7RCIy`uYa!+__WLzPo1^PM$o0%Lz%j zOP>-)O>Hd_46_ypL$Zx@?rOWSsY!({OJy7a8tiKD2s#JUT&|%fQP8S6cfc2Ae_lW~ zqgq~3Czd)S%SF*MhVmp8uE#NvvMT?=73CG!{K%t*KYtPe_T$~)z@Qia1isZ znTOUxtyp#Y?RjlTy6}R%d*8!d-?$quzq}O>Km46ir_B;92SyBQB$G2<7|dZK-Ju1D zCE4(2CuA5d6FG>Cek|l(2wX)(2C>?&2FmPEAHRB+8_Sm6s`R(Ly-hyw^+h|bjT;}r z>7H&JKhY@?@uN~5TyP|32()Lu7!p}!&Cl{jb6nZ;4LG5tu6Z%Wq(e)_wVPHRL#%qv zjQ1dj>gsB&T6M>i?dFP?F}H-LVUQ~ko{=s2p zgM}Fw*-O&b1l|utkr%IM0IesC7YK$5TYmo zS!L~(MJN@rR*^Dk5M>$n=^|^gSs&3Vkr#40e!LT}y|xWQ!=EnB%NXmGhCnB0G}!VG z9PO6*62XPK4D0={+zzhy^l1$A4@jK%b$obWKWS`Qw?H}Ah69UUENZWjaX z*UX(Oan*jg_5{8*e*u;*ZNZge4EWznev*Izbh!^{wDh%*+< zPL2zMKgOFdI#J4$yr{4J%aOt zJ0#Y~hmnd>C$9o=TADDF< z<=N>YLpIC8QQXw9{a|ISwiRnxP^9WN7k$qnQ)j;+nO2$E^ekeXNy#$FCuaPCAoL{4 zj2}!3){Dw0i`7Onsmec=#%4|g{bS`9b8T~(5Ko~HxxKx;`1t(!ymBX%#Frf@d0sOY ztr(2^MZzN5Q}C?8g{R+SHy?nSH(}~A1>V`Q9lLk$#I9ZM;P9bC*$Q#Zs!|<7aw4-U zH@a`%UNulLID6)d%Jla1^vH*Fy683dfNX1nFQ|-+j0DQsjvU4%LGf>FR)e)wI@&#R zwR`3mT{d%ujWlp67I1&*r5EwQ0}rYsBIin&)b{)T`FEzlXcuZKPb1oN9h~9)xG++S z5051flkD=I-Mg_*(&7UH17-ppYU}D0S^PRKx#=dHJaIy0fS-8cC;0s{zr_uU7Nfhn z8&$Cwx=wYWrR8Q--zffer}*RZ7hH$<*qEwYDs^8Ja)-*wI!UyV0a~AJRUe zEVzIlv1!w{6}bc_YZ&<3vH3{$9Yyl^QQh#K9s zv?!t{#>a8ry7d?p%N!aS6ssN2oT{pt`9w4|HYrj$zpTEl9;;WcMq@*xw9CBWz`AJU ziyh1uT{iQBUE_{f0}Iuu(RwYf!nSIsP%upbM*jBAH{T*d<^$^Oo13hm@Wru4IL*IC z>Z9F=id-}|H)HeW@B8)Uw;^)D0SjjhnQ4blKKavZyPQGG_17=LU3af8?t@=q1pHuH zrmO;9gCUglTASVc(Op)8qGD-@R4SlJS6@9xExCwX`z^Kh=09BU=1P&WX3bjvixX&9 z;FwEIF3&HWRMBewF$AmEFL{7%!8WPZa2YoF28=UQa0=8ca??12w8V!P7-+b*1eh{Q z9(=1conB@;c*a8h;A}-eBD9;PGRkHor%66twKXbxJN5n@W%Zkn&b#zK)L($OD) z|C*P!=vg&#`;-W3u-0GSTd;{m)(7^>LUKzZS0Y?pVAcU*wH_NQHZkE)B1*u?c@fdm zFSVGjzhij{KCxP({p?1VwJ(mmPR``uV99~$^o;L|TrCdCEro)y2-CS4@4?mb0Yf-4 zGLj)UwBRXWh9Pv*jHTN8>l(S>AN~7{id^M!@MjD|hKnI!!TtmjtHU#v?AnP=6G_Wh zb4YG6|bcmVYdonSA z5C!4?UWAbM;qdVAN|VgANo0`6%4(=`v#{;iv#0l___lY5JP4;G`Bf79n55emOWtd_ zq=RBwJ3RfehEJ|0+O^kmT>Mn`iGL;%@$(3wz$EMm$s2ZbbPS5y`!gjJ`acnDkwHzb=6h(ocC{m|D z+@gik8jTynZQ!O+n$$sH<>e?55J?U#%Z@}-wk(SxB~dRvB)%WVdvCsPZf7}nJX&(7 zla>L0z~1c6&hE_o_PcLpzHeqU5VmaDvN9fzzf7ksbcC>o`C_lz?f&Z{M~?Jp7P;?e zn)ZAmk@)wHj*ii5J#Ct%MuB$l;6Z)s)~$T5_x1G|x~^-6Vd#8}jg4t8mrI<7!(qL! zuuz+xo)*`XmquyzXf(=!xVEBnDvK`9IS&Sv%dkQ_feA(|787w63?`B9S!qNCtU?>sN8m~tRA^BdCWt>4mq(Jy@hY)=SOm&@h9sNw zl;m>WoQFcDO|F`U^|3f_!+N(N^O0o;1On#9jT=<)71JvT+2``v8lX#_A~Nb>5w1Ml#*oTW2GT{D8Z84>1gJa>`yxT88ai~M`n_kbG}c<9FY1p7K?m?$%R7^ELyT# zlIuj|Bv|Pw=w6?9CmalyVcY=+W0s0|=9yT zJ23tR_OE{hHyWb$JThZ)dwP1r;DLvuJU|#9AI~Xv6G@g(%{ov2KjTT|{jf7_5d?bO# zPWNNSk^p{iWm3eopVRT=OJ01cDT)8s=N571sT7hg#i7+{Nc|#-{9v{cHp?rKc>FJ+ zpL`F=&0Amw3n{h`@sIrz^s%Ezm2QXW4q*iu4xg?4JG4)}i|r-%ARpky%-p+oZ-DhJ z6G~PvK`a?-w8_ayea8b&3{Fjj;Z&SB)&{5Iu20;amSCxGrPT64GqAg;cuvG-Rc7nm6?IfdUjl2-f*TD`K0U^Osa>=!0 zyKR`s9%KxhuX0Br~8mk#KLLG_4K9Bo$ z{4qK^PeC_asH&>M9e3P`8x0|$EVX=)#2RGq5k7F1-fRg@re+l0bT~{p&QzR%nJ*@# zgWH*DU=H-3Yjo^!|tr#wjyD9aiarXLxA5mAiSF}Pkw~O)D_|-gkBeJewi?sa zpv59qH_aPBAh`6F^dK05V0A{Lb*?E>kTSp(I)8aY9F9PiHI2KHfud>%_3Hpg2{vZt z11|c5iH4-GZ^JO+w4Ye5g#44ir`Sr zAXle{ViEI#lwd(7&(W0=nPE+O#vwWZw&MguzL(8OP0_-t`(i9Pr>bWk+m-=yfFEInEdz3E3a(Vl!bVhS%aqk>0`V1 zI(hQn`|WLWcGKjttL*cvM0s;f+mruP$%VE26n_9CIhD{k+v_~H<8~v*lm2+sq$GN= z7ja`?H*!#xoLb9Eb!%Zv1`=h}NR*VpDOeEJG}$dO1i=$=z7Qf$!Hpg6FOMAXl^&=SAdfC{X*>qcdv7A;D-dFsT+s+E z8b+dKHC#QNNR^br43)z;`vLTc5hTm1c-IT0C?7pC4ee?llC_Ogg5ZZ%@X)~n7`QS3 zH+hd68`q$zxjB7_2vb-AJ-~0@* z&7X#+qYd%qJK@{^{WSSwgCZ|?m_}%kFwDXbjG;c@3#(>T_o;hT@jHFs%a;+^^q(o2`mll}$?w%m)j(`FgoZvO?kicMS&7vhq3FZB9mxH~%#Z`gojX%)<1 z5x1^X0_xx=<4COh7)j(Bo)!dG_i^&U`(XNmNYt)HvVJ|!j)Bp44vC88FpJ5f&8ywT z5-rTK_dNLE!!!^|Pxj-PM-ujVpcb*g12BjG3#nuc%!;pJ`SL;RqtV~k_$bQDDyUd4 z)YPn`k!vCP`}>g~_4m2Y@1T+3X^f4H+Nvl}5M-w@N|%&kaPX>_#tpdWk`B)K0q(A>c#-hq8RR}xhJLMLh|5XuPul<~+1?`58^=y>?6-q-_S-Dgk*>J7+ zlXRJDADZL6a}NQwZoSvmof+wJJR_Gk3In^c^@`BP*&{jEP(P|)_zE0_oipp?*v{bN zDNqjTrr9b&C0Fjov=9vL*<3npSxJV=C>P0C#Jr#mH0+ z7J{+INyFk?$RJqSBA{#)h(*i`QblBJ%*Mo8Yv&fmT4e{r9?4}lbUfP!AF}P2W8iRe zWrl-Ost4;>l;9fxTY}}bua>dhmS{K}nlOwyd}fcdMOeLtj6HGTcr1WkgxWPzh+Y^f z!r{p1rUA%k?63eDIj8Rz!32&D4Gjep=~cJc*1TTtHMbnUrEn<2lamu7WP&^P?Add7 zwnrJ(=ELjPHol8Rd}O@#+Rr)oG4J7isIRZ@Ys16CT5WCZkk98!sXIB}dHcr8-Di4y zy?q1MwwB)lJpI%c;Ur9wYiZA(|HX?h{^uxJBX@~jWJ^oSN*X5r8i_@%07*qoM6N<$g0<24x&QzG literal 0 HcmV?d00001 diff --git a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart index ddfd86acb1..11af75af1d 100644 --- a/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart +++ b/frontend/appflowy_flutter/integration_test/cloud/workspace/collaborative_workspace_test.dart @@ -60,9 +60,12 @@ void main() { Finder success; + final Finder items = find.byType(WorkspaceMenuItem); + // delete the newly created workspace await tester.openCollaborativeWorkspaceMenu(); - final Finder items = find.byType(WorkspaceMenuItem); + await tester.pumpUntilFound(items); + expect(items, findsNWidgets(2)); expect( tester.widget(items.last).workspace.name, diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart index 3be373537b..d76d41cd80 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/settings/settings_runner.dart @@ -1,11 +1,9 @@ import 'package:integration_test/integration_test.dart'; import 'notifications_settings_test.dart' as notifications_settings_test; -import 'user_language_test.dart' as user_language_test; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); notifications_settings_test.main(); - user_language_test.main(); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart deleted file mode 100644 index 6f56d5864e..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/settings/user_language_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:ui'; - -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('Settings: user language tests', () { - testWidgets('select language, language changed', (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.language); - - final userLanguageFinder = find.descendant( - of: find.byType(SettingsLanguageView), - matching: find.byType(LanguageSelector), - ); - - // Grab current locale - LanguageSelector userLanguage = - tester.widget(userLanguageFinder); - Locale currentLocale = userLanguage.currentLocale; - - // Open language selector - await tester.tap(userLanguageFinder); - await tester.pumpAndSettle(); - - // Select first option that isn't default - await tester.tap(find.byType(LanguageItem).at(1)); - await tester.pumpAndSettle(); - - // Make sure the new locale is not the same as previous one - userLanguage = tester.widget(userLanguageFinder); - expect( - userLanguage.currentLocale, - isNot(equals(currentLocale)), - reason: "new language shouldn't equal the previous selected language", - ); - - // Update the current locale to a new one - currentLocale = userLanguage.currentLocale; - - // Tried the same flow for the second time - // Open language selector - await tester.tap(userLanguageFinder); - await tester.pumpAndSettle(); - - // Select second option that isn't default - await tester.tap(find.byType(LanguageItem).at(2)); - await tester.pumpAndSettle(); - - // Make sure the new locale is not the same as previous one - userLanguage = tester.widget(userLanguageFinder); - expect( - userLanguage.currentLocale, - isNot(equals(currentLocale)), - reason: "new language shouldn't equal the previous selected language", - ); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart deleted file mode 100644 index 4b7848fd08..0000000000 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/appearance_settings_test.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:appflowy/util/font_family_extension.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../../shared/util.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - group('appearance settings tests', () { - testWidgets('after editing text field, button should be able to be clicked', - (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.appearance); - - final dropDown = find.byKey(ThemeFontFamilySetting.popoverKey); - await tester.tap(dropDown); - await tester.pumpAndSettle(); - - final textField = find.byKey(ThemeFontFamilySetting.textFieldKey); - await tester.tap(textField); - await tester.pumpAndSettle(); - - await tester.enterText(textField, 'Abel'); - await tester.pumpAndSettle(); - final fontFamilyButton = find.byKey(const Key('Abel')); - - expect(fontFamilyButton, findsOneWidget); - await tester.tap(fontFamilyButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - expect(find.textContaining('Abel'), findsOneWidget); - }); - - testWidgets('reset the font family', (tester) async { - await tester.initializeAppFlowy(); - - await tester.tapAnonymousSignInButton(); - await tester.expectToSeeHomePageWithGetStartedPage(); - await tester.openSettings(); - - await tester.openSettingsPage(SettingsPage.appearance); - - final dropDown = find.byKey(ThemeFontFamilySetting.popoverKey); - await tester.tap(dropDown); - await tester.pumpAndSettle(); - - final textField = find.byKey(ThemeFontFamilySetting.textFieldKey); - await tester.tap(textField); - await tester.pumpAndSettle(); - - await tester.enterText(textField, 'Abel'); - await tester.pumpAndSettle(); - final fontFamilyButton = find.byKey(const Key('Abel')); - - expect(fontFamilyButton, findsOneWidget); - await tester.tap(fontFamilyButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - final resetButton = find.byKey(ThemeFontFamilySetting.resetButtonKey); - await tester.tap(resetButton); - await tester.pumpAndSettle(); - - // just switch the page and verify that the font family was set after that - await tester.openSettingsPage(SettingsPage.files); - await tester.openSettingsPage(SettingsPage.appearance); - - expect( - find.textContaining( - DefaultAppearanceSettings.kDefaultFontFamily.fontFamilyDisplayName, - ), - findsNWidgets(2), - ); - }); - }); -} diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart index e4226b0f5f..4a38dde920 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/hotkeys_test.dart @@ -1,11 +1,13 @@ import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -23,31 +25,35 @@ void main() { await tester.expectToSeeHomePageWithGetStartedPage(); await tester.openSettings(); - await tester.openSettingsPage(SettingsPage.appearance); + await tester.openSettingsPage(SettingsPage.workspace); await tester.pumpAndSettle(); - tester.expectToSeeText( - LocaleKeys.settings_appearance_themeMode_system.tr(), - ); + final appFinder = find.byType(MaterialApp).first; + ThemeMode? themeMode = tester.widget(appFinder).themeMode; + + expect(themeMode, ThemeMode.system); await tester.tapButton( find.bySemanticsLabel( - LocaleKeys.settings_appearance_themeMode_system.tr(), + LocaleKeys.settings_workspacePage_appearance_options_light.tr(), ), ); - await tester.pumpAndSettle(); + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.light); + await tester.tapButton( find.bySemanticsLabel( - LocaleKeys.settings_appearance_themeMode_dark.tr(), + LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), ), ); + await tester.pumpAndSettle(); - await tester.pumpAndSettle(const Duration(seconds: 1)); + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.dark); await tester.tap(find.byType(SettingsDialog)); - await tester.pumpAndSettle(); await FlowyTestKeyboard.simulateKeyDownEvent( @@ -60,12 +66,10 @@ void main() { ], tester: tester, ); - await tester.pumpAndSettle(); - tester.expectToSeeText( - LocaleKeys.settings_appearance_themeMode_light.tr(), - ); + themeMode = tester.widget(appFinder).themeMode; + expect(themeMode, ThemeMode.light); }); testWidgets('show or hide home menu', (tester) async { diff --git a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart index c48fcd8028..3c07a2df5e 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/uncategorized/language_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; +import 'package:appflowy/user/presentation/screens/skip_log_in_screen.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; diff --git a/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart index 09a784d4fc..72bf8a4fae 100644 --- a/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart +++ b/frontend/appflowy_flutter/integration_test/desktop_runner_3.dart @@ -3,8 +3,6 @@ import 'package:integration_test/integration_test.dart'; import 'desktop/board/board_test_runner.dart' as board_test_runner; import 'desktop/settings/settings_runner.dart' as settings_test_runner; import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner; -import 'desktop/uncategorized/appearance_settings_test.dart' - as appearance_test_runner; import 'desktop/uncategorized/emoji_shortcut_test.dart' as emoji_shortcut_test; import 'desktop/uncategorized/empty_test.dart' as first_test; import 'desktop/uncategorized/hotkeys_test.dart' as hotkeys_test; @@ -26,7 +24,6 @@ Future runIntegration3OnDesktop() async { emoji_shortcut_test.main(); hotkeys_test.main(); emoji_shortcut_test.main(); - appearance_test_runner.main(); settings_test_runner.main(); share_markdown_test.main(); import_files_test.main(); diff --git a/frontend/appflowy_flutter/integration_test/shared/base.dart b/frontend/appflowy_flutter/integration_test/shared/base.dart index 47f5337fce..ab72247c24 100644 --- a/frontend/appflowy_flutter/integration_test/shared/base.dart +++ b/frontend/appflowy_flutter/integration_test/shared/base.dart @@ -173,27 +173,39 @@ extension AppFlowyTestBase on WidgetTester { int buttons = kPrimaryButton, bool warnIfMissed = false, int milliseconds = 500, + bool pumpAndSettle = true, }) async { await tap( finder, buttons: buttons, warnIfMissed: warnIfMissed, ); - await pumpAndSettle( - Duration(milliseconds: milliseconds), - EnginePhase.sendSemanticsUpdate, - const Duration(seconds: 5), - ); + + if (pumpAndSettle) { + await this.pumpAndSettle( + Duration(milliseconds: milliseconds), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + } } - Future tapButtonWithName(String tr, {int milliseconds = 500}) async { + Future tapButtonWithName( + String tr, { + int milliseconds = 500, + bool pumpAndSettle = true, + }) async { Finder button = find.text(tr, findRichText: true, skipOffstage: false); if (button.evaluate().isEmpty) { button = find.byWidgetPredicate( (widget) => widget is FlowyText && widget.text == tr, ); } - await tapButton(button, milliseconds: milliseconds); + await tapButton( + button, + milliseconds: milliseconds, + pumpAndSettle: pumpAndSettle, + ); } Future doubleTapAt( diff --git a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart index af3cb7d44e..08d6fd0ec6 100644 --- a/frontend/appflowy_flutter/integration_test/shared/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/shared/common_operations.dart @@ -22,7 +22,6 @@ import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab. import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart'; import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -511,8 +510,9 @@ extension CommonOperations on WidgetTester { final workspace = find.byType(SidebarWorkspace); expect(workspace, findsOneWidget); - // click it - await tapButton(workspace, milliseconds: 2000); + + await tapButton(workspace, pumpAndSettle: false); + await pump(const Duration(seconds: 5)); } Future createCollaborativeWorkspace(String name) async { @@ -527,7 +527,8 @@ extension CommonOperations on WidgetTester { // click the create button final createButton = find.byKey(createWorkspaceButtonKey); expect(createButton, findsOneWidget); - await tapButton(createButton); + await tapButton(createButton, pumpAndSettle: false); + await pump(const Duration(seconds: 5)); // see the create workspace dialog final createWorkspaceDialog = find.byType(CreateWorkspaceDialog); @@ -536,7 +537,8 @@ extension CommonOperations on WidgetTester { // input the workspace name await enterText(find.byType(TextField), name); - await tapButtonWithName(LocaleKeys.button_ok.tr()); + await tapButtonWithName(LocaleKeys.button_ok.tr(), pumpAndSettle: false); + await pump(const Duration(seconds: 5)); } // For mobile platform to launch the app in anonymous mode diff --git a/frontend/appflowy_flutter/integration_test/shared/settings.dart b/frontend/appflowy_flutter/integration_test/shared/settings.dart index cd6564b7cb..9ebfbe24e3 100644 --- a/frontend/appflowy_flutter/integration_test/shared/settings.dart +++ b/frontend/appflowy_flutter/integration_test/shared/settings.dart @@ -3,9 +3,10 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/prelude.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_setting.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -13,6 +14,7 @@ import 'package:flutter_test/flutter_test.dart'; import '../desktop/board/board_hide_groups_test.dart'; import 'base.dart'; +import 'common_operations.dart'; extension AppFlowySettings on WidgetTester { /// Open settings page @@ -77,12 +79,21 @@ extension AppFlowySettings on WidgetTester { // go to settings page and toggle enable RTL toolbar items Future toggleEnableRTLToolbarItems() async { await openSettings(); - await openSettingsPage(SettingsPage.appearance); + await openSettingsPage(SettingsPage.workspace); - final switchButton = - find.byKey(EnableRTLToolbarItemsSetting.enableRTLSwitchKey); - expect(switchButton, findsOneWidget); - await tapButton(switchButton); + final scrollable = find.findSettingsScrollable(); + await scrollUntilVisible( + find.byType(EnableRTLItemsSwitcher), + 0, + scrollable: scrollable, + ); + + final switcher = find.descendant( + of: find.byType(EnableRTLItemsSwitcher), + matching: find.byType(Toggle), + ); + + await tap(switcher); // tap anywhere to close the settings page await tapAt(Offset.zero); diff --git a/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart new file mode 100644 index 0000000000..da9f4649c3 --- /dev/null +++ b/frontend/appflowy_flutter/lib/flutter/af_dropdown_menu.dart @@ -0,0 +1,1042 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// TODO(Mathias): Make a PR in Flutter repository that enables customizing +// the dropdown menu without having to copy the entire file. +// This is a temporary solution! + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +const double _kMinimumWidth = 112.0; + +const double _kDefaultHorizontalPadding = 12.0; + +// Navigation shortcuts to move the selected menu items up or down. +final Map _kMenuTraversalShortcuts = + { + LogicalKeySet(LogicalKeyboardKey.arrowUp): const _ArrowUpIntent(), + LogicalKeySet(LogicalKeyboardKey.arrowDown): const _ArrowDownIntent(), +}; + +/// A dropdown menu that can be opened from a [TextField]. The selected +/// menu item is displayed in that field. +/// +/// This widget is used to help people make a choice from a menu and put the +/// selected item into the text input field. People can also filter the list based +/// on the text input or search one item in the menu list. +/// +/// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information, +/// such as: label, leading icon or trailing icon for each entry. The [TextField] +/// will be updated based on the selection from the menu entries. The text field +/// will stay empty if the selected entry is disabled. +/// +/// The dropdown menu can be traversed by pressing the up or down key. During the +/// process, the corresponding item will be highlighted and the text field will be updated. +/// Disabled items will be skipped during traversal. +/// +/// The menu can be scrollable if not all items in the list are displayed at once. +/// +/// {@tool dartpad} +/// This sample shows how to display outlined [AFDropdownMenu] and filled [AFDropdownMenu]. +/// +/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus. +/// The [AFDropdownMenu] uses a [TextField] as the "anchor". +/// * [TextField], which is a text input widget that uses an [InputDecoration]. +/// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [AFDropdownMenu] list. +class AFDropdownMenu extends StatefulWidget { + /// Creates a const [AFDropdownMenu]. + /// + /// The leading and trailing icons in the text field can be customized by using + /// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are + /// passed down to the [InputDecoration] properties, and will override values + /// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon]. + /// + /// Except leading and trailing icons, the text field can be configured by the + /// [InputDecorationTheme] property. The menu can be configured by the [menuStyle]. + const AFDropdownMenu({ + super.key, + this.enabled = true, + this.width, + this.menuHeight, + this.leadingIcon, + this.trailingIcon, + this.label, + this.hintText, + this.helperText, + this.errorText, + this.selectedTrailingIcon, + this.enableFilter = false, + this.enableSearch = true, + this.textStyle, + this.inputDecorationTheme, + this.menuStyle, + this.controller, + this.initialSelection, + this.onSelected, + this.requestFocusOnTap, + this.expandedInsets, + this.searchCallback, + required this.dropdownMenuEntries, + }); + + /// Determine if the [AFDropdownMenu] is enabled. + /// + /// Defaults to true. + final bool enabled; + + /// Determine the width of the [AFDropdownMenu]. + /// + /// If this is null, the width of the [AFDropdownMenu] will be the same as the width of the widest + /// menu item plus the width of the leading/trailing icon. + final double? width; + + /// Determine the height of the menu. + /// + /// If this is null, the menu will display as many items as possible on the screen. + final double? menuHeight; + + /// An optional Icon at the front of the text input field. + /// + /// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned + /// with the text in the text field. + final Widget? leadingIcon; + + /// An optional icon at the end of the text field. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_down]. + final Widget? trailingIcon; + + /// Optional widget that describes the input field. + /// + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), the label moves above, either + /// vertically adjacent to, or to the center of the input field. + /// + /// Defaults to null. + final Widget? label; + + /// Text that suggests what sort of input the field accepts. + /// + /// Defaults to null; + final String? hintText; + + /// Text that provides context about the [AFDropdownMenu]'s value, such + /// as how the value will be used. + /// + /// If non-null, the text is displayed below the input field, in + /// the same location as [errorText]. If a non-null [errorText] value is + /// specified then the helper text is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value. + final String? helperText; + + /// Text that appears below the input field and the border to show the error message. + /// + /// If non-null, the border's color animates to red and the [helperText] is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border. + final String? errorText; + + /// An optional icon at the end of the text field to indicate that the text + /// field is pressed. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_up]. + final Widget? selectedTrailingIcon; + + /// Determine if the menu list can be filtered by the text input. + /// + /// Defaults to false. + final bool enableFilter; + + /// Determine if the first item that matches the text input can be highlighted. + /// + /// Defaults to true as the search function could be commonly used. + final bool enableSearch; + + /// The text style for the [TextField] of the [AFDropdownMenu]; + /// + /// Defaults to the overall theme's [TextTheme.bodyLarge] + /// if the dropdown menu theme's value is null. + final TextStyle? textStyle; + + /// Defines the default appearance of [InputDecoration] to show around the text field. + /// + /// By default, shows a outlined text field. + final InputDecorationTheme? inputDecorationTheme; + + /// The [MenuStyle] that defines the visual attributes of the menu. + /// + /// The default width of the menu is set to the width of the text field. + final MenuStyle? menuStyle; + + /// Controls the text being edited or selected in the menu. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// The value used to for an initial selection. + /// + /// Defaults to null. + final T? initialSelection; + + /// The callback is called when a selection is made. + /// + /// Defaults to null. If null, only the text field is updated. + final ValueChanged? onSelected; + + /// Determine if the dropdown button requests focus and the on-screen virtual + /// keyboard is shown in response to a touch event. + /// + /// By default, on mobile platforms, tapping on the text field and opening + /// the menu will not cause a focus request and the virtual keyboard will not + /// appear. The default behavior for desktop platforms is for the dropdown to + /// take the focus. + /// + /// Defaults to null. Setting this field to true or false, rather than allowing + /// the implementation to choose based on the platform, can be useful for + /// applications that want to override the default behavior. + final bool? requestFocusOnTap; + + /// Descriptions of the menu items in the [AFDropdownMenu]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List> dropdownMenuEntries; + + /// Defines the menu text field's width to be equal to its parent's width + /// plus the horizontal width of the specified insets. + /// + /// If this property is null, the width of the text field will be determined + /// by the width of menu items or [AFDropdownMenu.width]. If this property is not null, + /// the text field's width will match the parent's width plus the specified insets. + /// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same + /// as its parent's width. + /// + /// The [expandedInsets]' top and bottom are ignored, only its left and right + /// properties are used. + /// + /// Defaults to null. + final EdgeInsets? expandedInsets; + + /// When [AFDropdownMenu.enableSearch] is true, this callback is used to compute + /// the index of the search result to be highlighted. + /// + /// {@tool snippet} + /// + /// In this example the `searchCallback` returns the index of the search result + /// that exactly matches the query. + /// + /// ```dart + /// DropdownMenu( + /// searchCallback: (List> entries, String query) { + /// if (query.isEmpty) { + /// return null; + /// } + /// final int index = entries.indexWhere((DropdownMenuEntry entry) => entry.label == query); + /// + /// return index != -1 ? index : null; + /// }, + /// dropdownMenuEntries: const >[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this is null and [AFDropdownMenu.enableSearch] is true, + /// the default function will return the index of the first matching result + /// which contains the contents of the text input field. + final SearchCallback? searchCallback; + + @override + State> createState() => _AFDropdownMenuState(); +} + +class _AFDropdownMenuState extends State> { + final GlobalKey _anchorKey = GlobalKey(); + final GlobalKey _leadingKey = GlobalKey(); + late List buttonItemKeys; + final MenuController _controller = MenuController(); + late bool _enableFilter; + late List> filteredEntries; + List? _initialMenu; + int? currentHighlight; + double? leadingPadding; + bool _menuHasEnabledItem = false; + TextEditingController? _localTextEditingController; + TextEditingController get _textEditingController { + return widget.controller ?? + (_localTextEditingController ??= TextEditingController()); + } + + @override + void initState() { + super.initState(); + _enableFilter = widget.enableFilter; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _textEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed( + offset: filteredEntries[index].label.length, + ), + ); + } + refreshLeadingPadding(); + } + + @override + void dispose() { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + super.dispose(); + } + + @override + void didUpdateWidget(AFDropdownMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (widget.controller != null) { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + } + } + if (oldWidget.enableSearch != widget.enableSearch) { + if (!widget.enableSearch) { + currentHighlight = null; + } + } + if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { + currentHighlight = null; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + } + if (oldWidget.leadingIcon != widget.leadingIcon) { + refreshLeadingPadding(); + } + if (oldWidget.initialSelection != widget.initialSelection) { + final int index = filteredEntries.indexWhere( + (DropdownMenuEntry entry) => entry.value == widget.initialSelection, + ); + if (index != -1) { + _textEditingController.value = TextEditingValue( + text: filteredEntries[index].label, + selection: TextSelection.collapsed( + offset: filteredEntries[index].label.length, + ), + ); + } + } + } + + bool canRequestFocus() { + if (widget.requestFocusOnTap != null) { + return widget.requestFocusOnTap!; + } + + switch (Theme.of(context).platform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + return false; + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + return true; + } + } + + void refreshLeadingPadding() { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + setState(() { + leadingPadding = getWidth(_leadingKey); + }); + }, + debugLabel: 'DropdownMenu.refreshLeadingPadding', + ); + } + + void scrollToHighlight() { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + final BuildContext? highlightContext = + buttonItemKeys[currentHighlight!].currentContext; + if (highlightContext != null) { + Scrollable.ensureVisible(highlightContext); + } + }, + debugLabel: 'DropdownMenu.scrollToHighlight', + ); + } + + double? getWidth(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox box = context.findRenderObject()! as RenderBox; + return box.hasSize ? box.size.width : null; + } + return null; + } + + List> filter( + List> entries, + TextEditingController textEditingController, + ) { + final String filterText = textEditingController.text.toLowerCase(); + return entries + .where( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(filterText), + ) + .toList(); + } + + int? search( + List> entries, + TextEditingController textEditingController, + ) { + final String searchText = textEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return null; + } + final int index = entries.indexWhere( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(searchText), + ); + + return index != -1 ? index : null; + } + + List _buildButtons( + List> filteredEntries, + TextDirection textDirection, { + int? focusedIndex, + bool enableScrollToHighlight = true, + }) { + final List result = []; + for (int i = 0; i < filteredEntries.length; i++) { + final DropdownMenuEntry entry = filteredEntries[i]; + + // By default, when the text field has a leading icon but a menu entry doesn't + // have one, the label of the entry should have extra padding to be aligned + // with the text in the text input field. When both the text field and the + // menu entry have leading icons, the menu entry should remove the extra + // paddings so its leading icon will be aligned with the leading icon of + // the text field. + final double padding = entry.leadingIcon == null + ? (leadingPadding ?? _kDefaultHorizontalPadding) + : _kDefaultHorizontalPadding; + final ButtonStyle defaultStyle; + switch (textDirection) { + case TextDirection.rtl: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: _kDefaultHorizontalPadding, + right: padding, + ), + ); + case TextDirection.ltr: + defaultStyle = MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: padding, + right: _kDefaultHorizontalPadding, + ), + ); + } + + ButtonStyle effectiveStyle = entry.style ?? defaultStyle; + final Color focusedBackgroundColor = effectiveStyle.foregroundColor + ?.resolve({MaterialState.focused}) ?? + Theme.of(context).colorScheme.onSurface; + + Widget label = entry.labelWidget ?? Text(entry.label); + if (widget.width != null) { + final double horizontalPadding = padding + _kDefaultHorizontalPadding; + label = ConstrainedBox( + constraints: + BoxConstraints(maxWidth: widget.width! - horizontalPadding), + child: label, + ); + } + + // Simulate the focused state because the text field should always be focused + // during traversal. If the menu item has a custom foreground color, the "focused" + // color will also change to foregroundColor.withOpacity(0.12). + effectiveStyle = entry.enabled && i == focusedIndex + ? effectiveStyle.copyWith( + backgroundColor: MaterialStatePropertyAll( + focusedBackgroundColor.withOpacity(0.12), + ), + ) + : effectiveStyle; + + final Widget menuItemButton = Padding( + padding: const EdgeInsets.only(bottom: 6), + child: MenuItemButton( + key: enableScrollToHighlight ? buttonItemKeys[i] : null, + style: effectiveStyle, + leadingIcon: entry.leadingIcon, + trailingIcon: entry.trailingIcon, + onPressed: entry.enabled + ? () { + _textEditingController.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + currentHighlight = widget.enableSearch ? i : null; + widget.onSelected?.call(entry.value); + } + : null, + requestFocusOnHover: false, + child: label, + ), + ); + result.add(menuItemButton); + } + + return result; + } + + void handleUpKeyInvoke(_) { + setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= 0; + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _textEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handleDownKeyInvoke(_) { + setState(() { + if (!_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + currentHighlight ??= -1; + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _textEditingController.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handlePressed(MenuController controller) { + if (controller.isOpen) { + currentHighlight = null; + controller.close(); + } else { + // close to open + if (_textEditingController.text.isNotEmpty) { + _enableFilter = false; + } + controller.open(); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + _initialMenu ??= _buildButtons( + widget.dropdownMenuEntries, + textDirection, + enableScrollToHighlight: false, + ); + final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); + + if (_enableFilter) { + filteredEntries = + filter(widget.dropdownMenuEntries, _textEditingController); + } + + if (widget.enableSearch) { + if (widget.searchCallback != null) { + currentHighlight = widget.searchCallback! + .call(filteredEntries, _textEditingController.text); + } else { + currentHighlight = search(filteredEntries, _textEditingController); + } + if (currentHighlight != null) { + scrollToHighlight(); + } + } + + final List menu = _buildButtons( + filteredEntries, + textDirection, + focusedIndex: currentHighlight, + ); + + final TextStyle? effectiveTextStyle = + widget.textStyle ?? theme.textStyle ?? defaults.textStyle; + + MenuStyle? effectiveMenuStyle = + widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!; + + final double? anchorWidth = getWidth(_anchorKey); + if (widget.width != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: MaterialStatePropertyAll(Size(widget.width!, 0.0)), + ); + } else if (anchorWidth != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: MaterialStatePropertyAll(Size(anchorWidth, 0.0)), + ); + } + + if (widget.menuHeight != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + maximumSize: MaterialStatePropertyAll( + Size(double.infinity, widget.menuHeight!), + ), + ); + } + final InputDecorationTheme effectiveInputDecorationTheme = + widget.inputDecorationTheme ?? + theme.inputDecorationTheme ?? + defaults.inputDecorationTheme!; + + final MouseCursor effectiveMouseCursor = + canRequestFocus() ? SystemMouseCursors.text : SystemMouseCursors.click; + + Widget menuAnchor = MenuAnchor( + style: effectiveMenuStyle, + controller: _controller, + menuChildren: menu, + crossAxisUnconstrained: false, + builder: ( + BuildContext context, + MenuController controller, + Widget? child, + ) { + assert(_initialMenu != null); + final Widget trailingButton = Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + splashRadius: 1, + isSelected: controller.isOpen, + icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), + selectedIcon: + widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), + onPressed: () { + handlePressed(controller); + }, + ), + ); + + final Widget leadingButton = Padding( + padding: const EdgeInsets.all(8.0), + child: widget.leadingIcon ?? const SizedBox(), + ); + + final Widget textField = TextField( + key: _anchorKey, + mouseCursor: effectiveMouseCursor, + canRequestFocus: canRequestFocus(), + enableInteractiveSelection: canRequestFocus(), + textAlignVertical: TextAlignVertical.center, + style: effectiveTextStyle, + controller: _textEditingController, + onEditingComplete: () { + if (currentHighlight != null) { + final DropdownMenuEntry entry = + filteredEntries[currentHighlight!]; + if (entry.enabled) { + _textEditingController.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + widget.onSelected?.call(entry.value); + } + } else { + widget.onSelected?.call(null); + } + if (!widget.enableSearch) { + currentHighlight = null; + } + controller.close(); + }, + onTap: () { + handlePressed(controller); + }, + onChanged: (String text) { + controller.open(); + setState(() { + filteredEntries = widget.dropdownMenuEntries; + _enableFilter = widget.enableFilter; + }); + }, + decoration: InputDecoration( + enabled: widget.enabled, + label: widget.label, + hintText: widget.hintText, + helperText: widget.helperText, + errorText: widget.errorText, + prefixIcon: widget.leadingIcon != null + ? Container(key: _leadingKey, child: widget.leadingIcon) + : null, + suffixIcon: trailingButton, + ).applyDefaults(effectiveInputDecorationTheme), + ); + + if (widget.expandedInsets != null) { + // If [expandedInsets] is not null, the width of the text field should depend + // on its parent width. So we don't need to use `_DropdownMenuBody` to + // calculate the children's width. + return textField; + } + + return _DropdownMenuBody( + width: widget.width, + children: [ + textField, + for (final Widget item in _initialMenu!) item, + trailingButton, + leadingButton, + ], + ); + }, + ); + + if (widget.expandedInsets != null) { + menuAnchor = Container( + alignment: AlignmentDirectional.topStart, + padding: widget.expandedInsets?.copyWith(top: 0.0, bottom: 0.0), + child: menuAnchor, + ); + } + + return Shortcuts( + shortcuts: _kMenuTraversalShortcuts, + child: Actions( + actions: >{ + _ArrowUpIntent: CallbackAction<_ArrowUpIntent>( + onInvoke: handleUpKeyInvoke, + ), + _ArrowDownIntent: CallbackAction<_ArrowDownIntent>( + onInvoke: handleDownKeyInvoke, + ), + }, + child: menuAnchor, + ), + ); + } +} + +class _ArrowUpIntent extends Intent { + const _ArrowUpIntent(); +} + +class _ArrowDownIntent extends Intent { + const _ArrowDownIntent(); +} + +class _DropdownMenuBody extends MultiChildRenderObjectWidget { + const _DropdownMenuBody({ + super.children, + this.width, + }); + + final double? width; + + @override + _RenderDropdownMenuBody createRenderObject(BuildContext context) { + return _RenderDropdownMenuBody( + width: width, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderDropdownMenuBody renderObject, + ) { + renderObject.width = width; + } +} + +class _DropdownMenuBodyParentData extends ContainerBoxParentData {} + +class _RenderDropdownMenuBody extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderDropdownMenuBody({ + double? width, + }) : _width = width; + + double? get width => _width; + double? _width; + set width(double? value) { + if (_width == value) { + return; + } + _width = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _DropdownMenuBodyParentData) { + child.parentData = _DropdownMenuBodyParentData(); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + while (child != null) { + if (child == firstChild) { + child.layout(innerConstraints, parentUsesSize: true); + maxHeight ??= child.size.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + child.layout(innerConstraints, parentUsesSize: true); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, child.size.width); + maxHeight ??= child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + size = constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: width ?? computeMaxIntrinsicWidth(constraints.maxWidth), + maxHeight: computeMaxIntrinsicHeight(constraints.maxHeight), + ); + + while (child != null) { + if (child == firstChild) { + final Size childSize = child.getDryLayout(innerConstraints); + maxHeight ??= childSize.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + final Size childSize = child.getDryLayout(innerConstraints); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, childSize.width); + maxHeight ??= childSize.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + return constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMinIntrinsicWidth(height); + if (child == lastChild) { + width += maxIntrinsicWidth; + } + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height); + // Add the width of leading Icon. + if (child == lastChild) { + width += maxIntrinsicWidth; + } + // Add the width of trailing Icon. + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMinIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMinIntrinsicHeight(height)); + } + return width; + } + + @override + double computeMaxIntrinsicHeight(double height) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMaxIntrinsicHeight(height)); + } + return width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } +} + +// Hand coded defaults. These will be updated once we have tokens/spec. +class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { + _DropdownMenuDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + @override + TextStyle? get textStyle => _theme.textTheme.bodyLarge; + + @override + MenuStyle get menuStyle { + return const MenuStyle( + minimumSize: MaterialStatePropertyAll(Size(_kMinimumWidth, 0.0)), + maximumSize: MaterialStatePropertyAll(Size.infinite), + visualDensity: VisualDensity.standard, + ); + } + + @override + InputDecorationTheme get inputDecorationTheme { + return const InputDecorationTheme(border: OutlineInputBorder()); + } +} diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart index 86a2c0dc51..1759d32aaf 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/mobile_home_page_header.dart @@ -127,6 +127,12 @@ class _MobileWorkspace extends StatelessWidget { workspace: currentWorkspace, iconSize: 26, enableEdit: false, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + currentWorkspace.workspaceId, + result.emoji, + ), + ), ), ), const HSpace(8), diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart index 69d34d26b3..4745b248c3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/home/workspaces/workspace_menu_bottom_sheet.dart @@ -1,12 +1,14 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; // Only works on mobile. @@ -105,6 +107,12 @@ class _WorkspaceMenuItem extends StatelessWidget { enableEdit: false, iconSize: 26, workspace: workspace, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), + ), ), trailing: workspace.workspaceId == currentWorkspace.workspaceId ? const FlowySvg( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart index ab64e4adeb..c610be4dbf 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart @@ -1,13 +1,28 @@ +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/shared/google_fonts_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/util/levenshtein.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_value_dropdown.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; final customizeFontToolbarItem = ToolbarItem( id: 'editor.font', @@ -16,10 +31,12 @@ final customizeFontToolbarItem = ToolbarItem( builder: (context, editorState, highlightColor, _) { final selection = editorState.selection!; final popoverController = PopoverController(); + final String? currentFontFamily = editorState + .getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.fontFamily); return MouseRegion( cursor: SystemMouseCursors.click, child: FontFamilyDropDown( - currentFontFamily: '', + currentFontFamily: currentFontFamily ?? '', offset: const Offset(0, 12), popoverController: popoverController, onOpen: () => keepEditorFocusNotifier.increase(), @@ -35,8 +52,11 @@ final customizeFontToolbarItem = ToolbarItem( Log.error('Failed to set font family: $e'); } }, - onResetFont: () async => editorState - .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}), + onResetFont: () async { + popoverController.close(); + await editorState + .formatDelta(selection, {AppFlowyRichTextKeys.fontFamily: null}); + }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: FlowyTooltip( @@ -52,3 +72,227 @@ final customizeFontToolbarItem = ToolbarItem( ); }, ); + +class ThemeFontFamilySetting extends StatefulWidget { + const ThemeFontFamilySetting({ + super.key, + required this.currentFontFamily, + }); + + final String currentFontFamily; + static Key textFieldKey = const Key('FontFamilyTextField'); + static Key resetButtonkey = const Key('FontFamilyResetButton'); + static Key popoverKey = const Key('FontFamilyPopover'); + + @override + State createState() => _ThemeFontFamilySettingState(); +} + +class _ThemeFontFamilySettingState extends State { + @override + Widget build(BuildContext context) { + return SettingListTile( + label: LocaleKeys.settings_appearance_fontFamily_label.tr(), + resetButtonKey: ThemeFontFamilySetting.resetButtonkey, + onResetRequested: () { + context.read().resetFontFamily(); + context + .read() + .syncFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); + }, + trailing: [ + FontFamilyDropDown(currentFontFamily: widget.currentFontFamily), + ], + ); + } +} + +class FontFamilyDropDown extends StatefulWidget { + const FontFamilyDropDown({ + super.key, + required this.currentFontFamily, + this.onOpen, + this.onClose, + this.onFontFamilyChanged, + this.child, + this.popoverController, + this.offset, + this.showResetButton = false, + this.onResetFont, + }); + + final String currentFontFamily; + final VoidCallback? onOpen; + final VoidCallback? onClose; + final void Function(String fontFamily)? onFontFamilyChanged; + final Widget? child; + final PopoverController? popoverController; + final Offset? offset; + final bool showResetButton; + final VoidCallback? onResetFont; + + @override + State createState() => _FontFamilyDropDownState(); +} + +class _FontFamilyDropDownState extends State { + final List availableFonts = [ + defaultFontFamily, + ...GoogleFonts.asMap().keys, + ]; + final ValueNotifier query = ValueNotifier(''); + + @override + void dispose() { + query.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final currentValue = widget.currentFontFamily.fontFamilyDisplayName; + return SettingValueDropDown( + popoverKey: ThemeFontFamilySetting.popoverKey, + popoverController: widget.popoverController, + currentValue: currentValue, + onClose: () { + query.value = ''; + widget.onClose?.call(); + }, + offset: widget.offset, + child: widget.child, + popupBuilder: (_) { + widget.onOpen?.call(); + return CustomScrollView( + shrinkWrap: true, + slivers: [ + if (widget.showResetButton) + SliverPersistentHeader( + delegate: _ResetFontButton(onPressed: widget.onResetFont), + pinned: true, + ), + SliverPadding( + padding: const EdgeInsets.only(right: 8), + sliver: SliverToBoxAdapter( + child: FlowyTextField( + key: ThemeFontFamilySetting.textFieldKey, + hintText: + LocaleKeys.settings_appearance_fontFamily_search.tr(), + autoFocus: false, + debounceDuration: const Duration(milliseconds: 300), + onChanged: (value) { + query.value = value; + }, + ), + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 4), + ), + ValueListenableBuilder( + valueListenable: query, + builder: (context, value, child) { + var displayed = availableFonts; + if (value.isNotEmpty) { + displayed = availableFonts + .where( + (font) => font + .toLowerCase() + .contains(value.toLowerCase().toString()), + ) + .sorted((a, b) => levenshtein(a, b)) + .toList(); + } + return SliverFixedExtentList.builder( + itemBuilder: (context, index) => _fontFamilyItemButton( + context, + getGoogleFontSafely(displayed[index]), + ), + itemCount: displayed.length, + itemExtent: 32, + ); + }, + ), + ], + ); + }, + ); + } + + Widget _fontFamilyItemButton( + BuildContext context, + TextStyle style, + ) { + final buttonFontFamily = + style.fontFamily?.parseFontFamilyName() ?? defaultFontFamily; + return Tooltip( + message: buttonFontFamily, + waitDuration: const Duration(milliseconds: 150), + child: SizedBox( + key: ValueKey(buttonFontFamily), + height: 32, + child: FlowyButton( + onHover: (_) => FocusScope.of(context).unfocus(), + text: FlowyText.medium( + buttonFontFamily.fontFamilyDisplayName, + fontFamily: buttonFontFamily, + ), + rightIcon: + buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() + ? const FlowySvg(FlowySvgs.check_s) + : null, + onTap: () { + if (widget.onFontFamilyChanged != null) { + widget.onFontFamilyChanged!(buttonFontFamily); + } else { + if (widget.currentFontFamily.parseFontFamilyName() != + buttonFontFamily) { + context + .read() + .setFontFamily(buttonFontFamily); + context + .read() + .syncFontFamily(buttonFontFamily); + } + } + PopoverContainer.of(context).close(); + }, + ), + ), + ); + } +} + +class _ResetFontButton extends SliverPersistentHeaderDelegate { + _ResetFontButton({this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return Padding( + padding: const EdgeInsets.only(right: 8, bottom: 8.0), + child: FlowyTextButton( + LocaleKeys.document_toolbar_resetToDefaultFont.tr(), + fontColor: AFThemeExtension.of(context).textColor, + fontHoverColor: Theme.of(context).colorScheme.onSurface, + fontSize: 12, + onPressed: onPressed, + ), + ); + } + + @override + double get maxExtent => 35; + + @override + double get minExtent => 35; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + true; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index 4aba56c15a..89d283f410 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -1,5 +1,8 @@ import 'dart:math'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; @@ -14,8 +17,6 @@ import 'package:appflowy/workspace/application/settings/appearance/appearance_cu import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:collection/collection.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -48,9 +49,9 @@ class EditorStyleCustomizer { return EditorStyle.desktop( padding: padding, cursorColor: appearance.cursorColor ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor(context), + DefaultAppearanceSettings.getDefaultCursorColor(context), selectionColor: appearance.selectionColor ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor(context), + DefaultAppearanceSettings.getDefaultSelectionColor(context), defaultTextDirection: appearance.defaultTextDirection, textStyleConfiguration: TextStyleConfiguration( text: baseTextStyle(fontFamily).copyWith( diff --git a/frontend/appflowy_flutter/lib/user/application/user_listener.dart b/frontend/appflowy_flutter/lib/user/application/user_listener.dart index ece9cef8a8..b3e7d6fbe0 100644 --- a/frontend/appflowy_flutter/lib/user/application/user_listener.dart +++ b/frontend/appflowy_flutter/lib/user/application/user_listener.dart @@ -16,7 +16,10 @@ import 'package:appflowy_backend/rust_stream.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flowy_infra/notifier.dart'; -typedef DidUserWorkspaceUpdateCallback = void Function( +typedef DidUpdateUserWorkspaceCallback = void Function( + UserWorkspacePB workspace, +); +typedef DidUpdateUserWorkspacesCallback = void Function( RepeatedUserWorkspacePB workspaces, ); typedef UserProfileNotifyValue = FlowyResult; @@ -31,11 +34,19 @@ class UserListener { UserNotificationParser? _userParser; StreamSubscription? _subscription; PublishNotifier? _profileNotifier = PublishNotifier(); - DidUserWorkspaceUpdateCallback? didUpdateUserWorkspaces; + + /// Update notification about _all_ of the users workspaces + /// + DidUpdateUserWorkspacesCallback? didUpdateUserWorkspaces; + + /// Update notification about _one_ workspace + /// + DidUpdateUserWorkspaceCallback? didUpdateUserWorkspace; void start({ void Function(UserProfileNotifyValue)? onProfileUpdated, void Function(RepeatedUserWorkspacePB)? didUpdateUserWorkspaces, + void Function(UserWorkspacePB)? didUpdateUserWorkspace, }) { if (onProfileUpdated != null) { _profileNotifier?.addPublishListener(onProfileUpdated); @@ -45,6 +56,10 @@ class UserListener { this.didUpdateUserWorkspaces = didUpdateUserWorkspaces; } + if (didUpdateUserWorkspace != null) { + this.didUpdateUserWorkspace = didUpdateUserWorkspace; + } + _userParser = UserNotificationParser( id: _userProfile.id.toString(), callback: _userNotificationCallback, @@ -81,6 +96,11 @@ class UserListener { }, ); break; + case user.UserNotification.DidUpdateUserWorkspace: + result.map( + (r) => didUpdateUserWorkspace?.call(UserWorkspacePB.fromBuffer(r)), + ); + break; default: break; } diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart index b125abf93c..3b3ad9707e 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart @@ -10,7 +10,6 @@ import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/presentation/router.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -36,9 +35,7 @@ class _SkipLogInScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: const _SkipLoginMoveWindow(), - body: Center( - child: _renderBody(context), - ), + body: Center(child: _renderBody(context)), ); } @@ -73,9 +70,7 @@ class _SkipLogInScreenState extends State { SizedBox( width: size.width * 0.7, child: FolderWidget( - createFolderCallback: () async { - _didCustomizeFolder = true; - }, + createFolderCallback: () async => _didCustomizeFolder = true, ), ), const Spacer(), @@ -88,24 +83,16 @@ class _SkipLogInScreenState extends State { Future _autoRegister(BuildContext context) async { final result = await getIt().signUpAsGuest(); result.fold( - (user) { - getIt().goHomeScreen(context, user); - }, - (error) { - Log.error(error); - }, + (user) => getIt().goHomeScreen(context, user), + (error) => Log.error(error), ); } - Future _relaunchAppAndAutoRegister() async { - await runAppFlowy(isAnon: true); - } + Future _relaunchAppAndAutoRegister() async => runAppFlowy(isAnon: true); } class SkipLoginPageFooter extends StatelessWidget { - const SkipLoginPageFooter({ - super.key, - }); + const SkipLoginPageFooter({super.key}); @override Widget build(BuildContext context) { @@ -135,9 +122,7 @@ class SkipLoginPageFooter extends StatelessWidget { } class SubscribeButtons extends StatelessWidget { - const SubscribeButtons({ - super.key, - }); + const SubscribeButtons({super.key}); @override Widget build(BuildContext context) { @@ -168,10 +153,7 @@ class SubscribeButtons extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - FlowyText.regular( - LocaleKeys.and.tr(), - fontSize: FontSizes.s12, - ), + FlowyText.regular(LocaleKeys.and.tr(), fontSize: FontSizes.s12), FlowyTextButton( LocaleKeys.subscribeNewsletterText.tr(), padding: const EdgeInsets.symmetric(horizontal: 4.0), @@ -190,9 +172,7 @@ class SubscribeButtons extends StatelessWidget { } class LanguageSelectorOnWelcomePage extends StatelessWidget { - const LanguageSelectorOnWelcomePage({ - super.key, - }); + const LanguageSelectorOnWelcomePage({super.key}); @override Widget build(BuildContext context) { @@ -205,24 +185,16 @@ class LanguageSelectorOnWelcomePage extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, children: [ - const FlowySvg( - FlowySvgs.ethernet_m, - size: Size.square(20), - ), + const FlowySvg(FlowySvgs.ethernet_m, size: Size.square(20)), const HSpace(4), Builder( builder: (context) { final currentLocale = context.watch().state.locale; - return FlowyText( - languageFromLocale(currentLocale), - ); + return FlowyText(languageFromLocale(currentLocale)); }, ), - const FlowySvg( - FlowySvgs.drop_menu_hide_m, - size: Size.square(20), - ), + const FlowySvg(FlowySvgs.drop_menu_hide_m, size: Size.square(20)), ], ), ), @@ -231,15 +203,68 @@ class LanguageSelectorOnWelcomePage extends StatelessWidget { if (easyLocalization == null) { return const SizedBox.shrink(); } - final allLocales = easyLocalization.supportedLocales; + return LanguageItemsListView( - allLocales: allLocales, + allLocales: easyLocalization.supportedLocales, ); }, ); } } +class LanguageItemsListView extends StatelessWidget { + const LanguageItemsListView({super.key, required this.allLocales}); + + final List allLocales; + + @override + Widget build(BuildContext context) { + // get current locale from cubit + final state = context.watch().state; + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.builder( + itemCount: allLocales.length, + itemBuilder: (context, index) { + final locale = allLocales[index]; + return LanguageItem(locale: locale, currentLocale: state.locale); + }, + ), + ); + } +} + +class LanguageItem extends StatelessWidget { + const LanguageItem({ + super.key, + required this.locale, + required this.currentLocale, + }); + + final Locale locale; + final Locale currentLocale; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + languageFromLocale(locale), + ), + rightIcon: + currentLocale == locale ? const FlowySvg(FlowySvgs.check_s) : null, + onTap: () { + if (currentLocale != locale) { + context.read().setLocale(context, locale); + } + PopoverContainer.of(context).close(); + }, + ), + ); + } +} + class GoButton extends StatelessWidget { const GoButton({super.key, required this.onPressed}); @@ -248,10 +273,7 @@ class GoButton extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => AnonUserBloc() - ..add( - const AnonUserEvent.initial(), - ), + create: (context) => AnonUserBloc()..add(const AnonUserEvent.initial()), child: BlocListener( listener: (context, state) async { if (state.openedAnonUser != null) { @@ -265,7 +287,6 @@ class GoButton extends StatelessWidget { : LocaleKeys.signIn_continueAnonymousUser.tr(); final textWidget = Row( - // mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: FlowyText.medium( @@ -274,22 +295,6 @@ class GoButton extends StatelessWidget { fontSize: 14, ), ), - // Tooltip( - // message: LocaleKeys.settings_menu_configServerGuide.tr(), - // child: Container( - // width: 30.0, - // decoration: const BoxDecoration( - // shape: BoxShape.circle, - // ), - // child: Center( - // child: Icon( - // Icons.help, - // color: Colors.white, - // weight: 2, - // ), - // ), - // ), - // ), ], ); @@ -325,15 +330,8 @@ class _SkipLoginMoveWindow extends StatelessWidget const _SkipLoginMoveWindow(); @override - Widget build(BuildContext context) { - return const Row( - children: [ - Expanded( - child: MoveWindowDetector(), - ), - ], - ); - } + Widget build(BuildContext context) => + const Row(children: [Expanded(child: MoveWindowDetector())]); @override Size get preferredSize => const Size.fromHeight(55.0); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart b/frontend/appflowy_flutter/lib/util/levenshtein.dart similarity index 100% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart rename to frontend/appflowy_flutter/lib/util/levenshtein.dart diff --git a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart index b3cb390e8e..d900afd6eb 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/appearance_defaults.dart @@ -1,6 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; /// A class for the default appearance settings for the app class DefaultAppearanceSettings { @@ -9,11 +10,11 @@ class DefaultAppearanceSettings { static const kDefaultThemeName = "Default"; static const kDefaultTheme = BuiltInTheme.defaultTheme; - static Color getDefaultDocumentCursorColor(BuildContext context) { + static Color getDefaultCursorColor(BuildContext context) { return Theme.of(context).colorScheme.primary; } - static Color getDefaultDocumentSelectionColor(BuildContext context) { + static Color getDefaultSelectionColor(BuildContext context) { return Theme.of(context).colorScheme.primary.withOpacity(0.2); } } diff --git a/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart index 8407ed841f..bfd4d654a3 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/recent/cached_recent_service.dart @@ -47,11 +47,13 @@ class CachedRecentService { Future> updateRecentViews( List viewIds, bool addInRecent, - ) async { - return FolderEventUpdateRecentViews( - UpdateRecentViewPayloadPB(viewIds: viewIds, addInRecent: addInRecent), - ).send(); - } + ) async => + FolderEventUpdateRecentViews( + UpdateRecentViewPayloadPB( + viewIds: viewIds, + addInRecent: addInRecent, + ), + ).send(); Future> _readRecentViews() => FolderEventReadRecentViews().send(); diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart index f2c6141407..2fba33263f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appearance/base_appearance.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/shared/google_fonts_extension.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme.dart'; -import 'package:flutter/material.dart'; // the default font family is empty, so we can use the default font family of the platform // the system will choose the default font family of the platform diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart new file mode 100644 index 0000000000..5da3caa5b9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/date_time/time_format_ext.dart @@ -0,0 +1,13 @@ +import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; +import 'package:easy_localization/easy_localization.dart'; + +extension TimeFormatter on UserTimeFormatPB { + DateFormat get toFormat => _toFormat[this]!; + + String formatTime(DateTime date) => toFormat.format(date); +} + +final _toFormat = { + UserTimeFormatPB.TwelveHour: DateFormat.Hm(), + UserTimeFormatPB.TwentyFourHour: DateFormat.jm(), +}; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart index 906ccb78f2..17a9936398 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/settings_dialog_bloc.dart @@ -11,11 +11,9 @@ part 'settings_dialog_bloc.freezed.dart'; enum SettingsPage { // NEW account, + workspace, // OLD - appearance, - language, files, - // user, notifications, cloud, shortcuts, diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart new file mode 100644 index 0000000000..5c02ea6b11 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/workspace/workspace_settings_bloc.dart @@ -0,0 +1,153 @@ +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; +import 'package:appflowy_result/appflowy_result.dart'; +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:protobuf/protobuf.dart'; + +part 'workspace_settings_bloc.freezed.dart'; + +class WorkspaceSettingsBloc + extends Bloc { + WorkspaceSettingsBloc() : super(WorkspaceSettingsState.initial()) { + on( + (event, emit) async { + await event.when( + initial: (userProfile, workspace) async { + _userService = UserBackendService(userId: userProfile.id); + + try { + final currentWorkspace = + await _userService!.getCurrentWorkspace().getOrThrow(); + + final workspaces = + await _userService!.getWorkspaces().getOrThrow(); + if (workspaces.isEmpty) { + workspaces.add( + UserWorkspacePB.create() + ..workspaceId = currentWorkspace.id + ..name = currentWorkspace.name + ..createdAtTimestamp = currentWorkspace.createTime, + ); + } + + final currentWorkspaceInList = workspaces.firstWhereOrNull( + (e) => e.workspaceId == currentWorkspace.id, + ) ?? + workspaces.firstOrNull; + + // We emit here because the next event might take longer. + emit(state.copyWith(workspace: currentWorkspaceInList)); + + if (currentWorkspaceInList == null) { + return; + } + + final members = await _getWorkspaceMembers( + currentWorkspaceInList.workspaceId, + ); + + final role = members + .firstWhereOrNull((e) => e.email == userProfile.email) + ?.role ?? + AFRolePB.Guest; + + emit(state.copyWith(members: members, myRole: role)); + } catch (e) { + Log.error('Failed to get or create current workspace'); + } + }, + updateWorkspaceName: (name) async { + final request = RenameWorkspacePB( + workspaceId: state.workspace?.workspaceId, + newName: name, + ); + final result = await UserEventRenameWorkspace(request).send(); + + state.workspace!.freeze(); + final update = state.workspace!.rebuild((p0) => p0.name = name); + + result.fold( + (_) => emit(state.copyWith(workspace: update)), + (e) => Log.error('Failed to rename workspace: $e'), + ); + }, + updateWorkspaceIcon: (icon) async { + if (state.workspace == null) { + return null; + } + + final request = ChangeWorkspaceIconPB() + ..workspaceId = state.workspace!.workspaceId + ..newIcon = icon; + final result = await UserEventChangeWorkspaceIcon(request).send(); + + result.fold( + (_) { + state.workspace!.freeze(); + final newWorkspace = + state.workspace!.rebuild((p0) => p0.icon = icon); + + return emit(state.copyWith(workspace: newWorkspace)); + }, + (e) => Log.error('Failed to update workspace icon: $e'), + ); + }, + deleteWorkspace: () async => + emit(state.copyWith(deleteWorkspace: true)), + leaveWorkspace: () async => + emit(state.copyWith(leaveWorkspace: true)), + ); + }, + ); + } + + UserBackendService? _userService; + + Future> _getWorkspaceMembers( + String workspaceId, + ) async { + final data = QueryWorkspacePB()..workspaceId = workspaceId; + final result = await UserEventGetWorkspaceMember(data).send(); + return result.fold( + (s) => s.items, + (e) { + Log.error('Failed to read workspace members: $e'); + return []; + }, + ); + } +} + +@freezed +class WorkspaceSettingsEvent with _$WorkspaceSettingsEvent { + const factory WorkspaceSettingsEvent.initial({ + required UserProfilePB userProfile, + @Default(null) UserWorkspacePB? workspace, + }) = Initial; + + // Workspace itself + const factory WorkspaceSettingsEvent.updateWorkspaceName(String name) = + UpdateWorkspaceName; + const factory WorkspaceSettingsEvent.updateWorkspaceIcon(String icon) = + UpdateWorkspaceIcon; + const factory WorkspaceSettingsEvent.deleteWorkspace() = DeleteWorkspace; + const factory WorkspaceSettingsEvent.leaveWorkspace() = LeaveWorkspace; +} + +@freezed +class WorkspaceSettingsState with _$WorkspaceSettingsState { + const factory WorkspaceSettingsState({ + @Default(null) UserWorkspacePB? workspace, + @Default([]) List members, + @Default(AFRolePB.Guest) AFRolePB myRole, + @Default(false) bool deleteWorkspace, + @Default(false) bool leaveWorkspace, + }) = _WorkspaceSettingsState; + + factory WorkspaceSettingsState.initial() => const WorkspaceSettingsState(); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart index a85cd3ca34..c0a885c19b 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/user/user_workspace_bloc.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/user/application/user_listener.dart'; @@ -10,7 +12,6 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:protobuf/protobuf.dart'; @@ -27,11 +28,18 @@ class UserWorkspaceBloc extends Bloc { (event, emit) async { await event.when( initial: () async { - _listener - ..didUpdateUserWorkspaces = (workspaces) { - add(UserWorkspaceEvent.updateWorkspaces(workspaces)); - } - ..start(); + _listener.start( + didUpdateUserWorkspaces: (workspaces) => + add(UserWorkspaceEvent.updateWorkspaces(workspaces)), + didUpdateUserWorkspace: (workspace) { + // If currentWorkspace is updated, eg. Icon or Name, we should notify + // the UI to render the updated information. + final currentWorkspace = state.currentWorkspace; + if (currentWorkspace?.workspaceId == workspace.workspaceId) { + add(UserWorkspaceEvent.updateCurrentWorkspace(workspace)); + } + }, + ); final result = await _fetchWorkspaces(); final currentWorkspace = result.$1; @@ -337,6 +345,25 @@ class UserWorkspaceBloc extends Bloc { ), ); }, + updateCurrentWorkspace: (workspace) async { + final workspaces = [...state.workspaces]; + final index = workspaces + .indexWhere((e) => e.workspaceId == workspace.workspaceId); + if (index != -1) { + workspaces[index] = workspace; + } + + emit( + state.copyWith( + currentWorkspace: workspace, + workspaces: workspaces + ..sort( + (a, b) => + a.createdAtTimestamp.compareTo(b.createdAtTimestamp), + ), + ), + ); + }, ); }, ); @@ -413,6 +440,9 @@ class UserWorkspaceEvent with _$UserWorkspaceEvent { const factory UserWorkspaceEvent.updateWorkspaces( RepeatedUserWorkspacePB workspaces, ) = UpdateWorkspaces; + const factory UserWorkspaceEvent.updateCurrentWorkspace( + UserWorkspacePB workspace, + ) = UpdateCurrentWorkspace; } enum UserWorkspaceActionType { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart index efc79f0d59..e7b74ef4e9 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/desktop_home_screen.dart @@ -88,40 +88,38 @@ class DesktopHomeScreen extends StatelessWidget { FavoriteBloc()..add(const FavoriteEvent.initial()), ), ], - child: HomeHotKeys( - userProfile: userProfile, - child: Scaffold( - floatingActionButton: enableMemoryLeakDetect - ? const FloatingActionButton( - onPressed: dumpMemoryLeak, - child: Icon(Icons.memory), - ) - : null, - body: BlocListener( - listenWhen: (p, c) => p.latestView != c.latestView, - listener: (context, state) { - final view = state.latestView; - if (view != null) { - // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null. - // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash. - final currentPageManager = - context.read().state.currentPageManager; + child: Scaffold( + floatingActionButton: enableMemoryLeakDetect + ? const FloatingActionButton( + onPressed: dumpMemoryLeak, + child: Icon(Icons.memory), + ) + : null, + body: BlocListener( + listenWhen: (p, c) => p.latestView != c.latestView, + listener: (context, state) { + final view = state.latestView; + if (view != null) { + // Only open the last opened view if the [TabsState.currentPageManager] current opened plugin is blank and the last opened view is not null. + // All opened widgets that display on the home screen are in the form of plugins. There is a list of built-in plugins defined in the [PluginType] enum, including board, grid and trash. + final currentPageManager = + context.read().state.currentPageManager; - if (currentPageManager.plugin.pluginType == - PluginType.blank) { - getIt().add( - TabsEvent.openPlugin(plugin: view.plugin()), - ); - } + if (currentPageManager.plugin.pluginType == + PluginType.blank) { + getIt().add( + TabsEvent.openPlugin(plugin: view.plugin()), + ); } - }, - child: BlocBuilder( - buildWhen: (previous, current) => previous != current, - builder: (context, state) => BlocProvider( - create: (_) => UserWorkspaceBloc(userProfile: userProfile) - ..add( - const UserWorkspaceEvent.initial(), - ), + } + }, + child: BlocBuilder( + buildWhen: (previous, current) => previous != current, + builder: (context, state) => BlocProvider( + create: (_) => UserWorkspaceBloc(userProfile: userProfile) + ..add(const UserWorkspaceEvent.initial()), + child: HomeHotKeys( + userProfile: userProfile, child: FlowyContainer( Theme.of(context).colorScheme.surface, child: _buildBody(context, userProfile, workspaceSetting), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart index 8d798ef853..1e5aec2f0e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar.dart @@ -136,9 +136,9 @@ class HomeSideBar extends StatelessWidget { workspaceSetting.workspaceId, ), ); - context.read().add( - const FavoriteEvent.fetchFavorites(), - ); + context + .read() + .add(const FavoriteEvent.fetchFavorites()); } }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart index 45698ebabf..0e2d020f67 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_setting.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy/workspace/presentation/home/hotkeys.dart'; import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart'; import 'package:appflowy_backend/log.dart'; @@ -65,9 +66,14 @@ class UserSettingButton extends StatelessWidget { void showSettingsDialog(BuildContext context, UserProfilePB userProfile) => showDialog( context: context, - builder: (dialogContext) => BlocProvider.value( + builder: (dialogContext) => MultiBlocProvider( key: _settingsDialogKey, - value: BlocProvider.of(dialogContext), + providers: [ + BlocProvider.value( + value: BlocProvider.of(dialogContext), + ), + BlocProvider.value(value: context.read()), + ], child: SettingsDialog( userProfile, didLogout: () async { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart index 115968796c..141d7d7f4c 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_workspace.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart'; @@ -14,14 +16,10 @@ import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class SidebarWorkspace extends StatefulWidget { - const SidebarWorkspace({ - super.key, - required this.userProfile, - }); + const SidebarWorkspace({super.key, required this.userProfile}); final UserProfilePB userProfile; @@ -197,6 +195,12 @@ class SidebarSwitchWorkspaceButton extends StatelessWidget { workspace: currentWorkspace, iconSize: 20, enableEdit: false, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + currentWorkspace.workspaceId, + result.emoji, + ), + ), ), ), const HSpace(6), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart index ffc5083db8..100b8d6099 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart @@ -1,25 +1,26 @@ import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/base/icon/icon_picker.dart'; import 'package:appflowy/util/color_generator/color_generator.dart'; -import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; class WorkspaceIcon extends StatefulWidget { const WorkspaceIcon({ super.key, + required this.workspace, required this.enableEdit, required this.iconSize, - required this.workspace, + required this.onSelected, }); final UserWorkspacePB workspace; final double iconSize; final bool enableEdit; + final void Function(EmojiPickerResult) onSelected; @override State createState() => _WorkspaceIconState(); @@ -45,7 +46,7 @@ class _WorkspaceIconState extends State { height: max(widget.iconSize, 26), decoration: BoxDecoration( color: ColorGenerator(widget.workspace.name).toColor(), - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(8), ), child: FlowyText( widget.workspace.name.isEmpty @@ -55,6 +56,7 @@ class _WorkspaceIconState extends State { color: Colors.black, ), ); + if (widget.enableEdit) { child = AppFlowyPopover( offset: const Offset(0, 8), @@ -62,19 +64,12 @@ class _WorkspaceIconState extends State { direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(360, 380)), clickHandler: PopoverClickHandler.gestureDetector, - popupBuilder: (BuildContext popoverContext) { - return FlowyIconPicker( - onSelected: (result) { - context.read().add( - UserWorkspaceEvent.updateWorkspaceIcon( - widget.workspace.workspaceId, - result.emoji, - ), - ); - controller.close(); - }, - ); - }, + popupBuilder: (_) => FlowyIconPicker( + onSelected: (result) { + widget.onSelected(result); + controller.close(); + }, + ), child: MouseRegion( cursor: SystemMouseCursors.click, child: child, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart index 65830eddd7..efc4372ee8 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; @@ -11,7 +13,6 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @visibleForTesting @@ -152,6 +153,12 @@ class WorkspaceMenuItem extends StatelessWidget { workspace: workspace, iconSize: 26, enableEdit: true, + onSelected: (result) => context.read().add( + UserWorkspaceEvent.updateWorkspaceIcon( + workspace.workspaceId, + result.emoji, + ), + ), ), ), ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart index 5eae1ef34c..9ee584e5dc 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_account_view.dart @@ -10,8 +10,6 @@ import 'package:appflowy/workspace/application/user/settings_user_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_input_field.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_third_party_login.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; @@ -58,11 +56,9 @@ class _SettingsAccountViewState extends State { child: BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_accountPage_title.tr(), + description: LocaleKeys.settings_accountPage_description.tr(), children: [ - SettingsHeader( - title: LocaleKeys.settings_accountPage_title.tr(), - description: LocaleKeys.settings_accountPage_description.tr(), - ), SettingsCategory( title: LocaleKeys.settings_accountPage_general_title.tr(), children: [ @@ -140,7 +136,6 @@ class _SettingsAccountViewState extends State { // ), // ], // ), - const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_accountPage_keys_title.tr(), children: [ @@ -174,7 +169,6 @@ class _SettingsAccountViewState extends State { ), ], ), - const SettingsCategorySpacer(), SettingsCategory( title: LocaleKeys.settings_accountPage_login_title.tr(), children: [ @@ -409,10 +403,10 @@ class _UserProfileSettingState extends State { width: 360, margin: const EdgeInsets.symmetric(horizontal: 12), child: FlowyIconPicker( - onSelected: (result) { - context.read().add( - SettingsUserEvent.updateUserIcon(iconUrl: result.emoji), - ); + onSelected: (r) { + context + .read() + .add(SettingsUserEvent.updateUserIcon(iconUrl: r.emoji)); Navigator.of(dialogContext).pop(); }, ), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart new file mode 100644 index 0000000000..c1d167844a --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_workspace_view.dart @@ -0,0 +1,906 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/shared/af_role_pb_extension.dart'; +import 'package:appflowy/util/font_family_extension.dart'; +import 'package:appflowy/workspace/application/appearance_defaults.dart'; +import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; +import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; +import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart'; +import 'package:appflowy/workspace/application/settings/workspace/workspace_settings_bloc.dart'; +import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/document_color_setting_button.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_actionable_input.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dashed_divider.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_dropdown.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_radio_select.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/single_setting_action.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; +import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/language.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; +import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +class SettingsWorkspaceView extends StatefulWidget { + const SettingsWorkspaceView({super.key, required this.userProfile}); + + final UserProfilePB userProfile; + + @override + State createState() => _SettingsWorkspaceViewState(); +} + +class _SettingsWorkspaceViewState extends State { + final TextEditingController _workspaceNameController = + TextEditingController(); + + @override + void dispose() { + _workspaceNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => WorkspaceSettingsBloc() + ..add(WorkspaceSettingsEvent.initial(userProfile: widget.userProfile)), + child: BlocConsumer( + listener: (context, state) { + if ((state.workspace?.name ?? '') != _workspaceNameController.text) { + _workspaceNameController.text = state.workspace?.name ?? ''; + } + + if (state.deleteWorkspace) { + context.read().add( + UserWorkspaceEvent.deleteWorkspace( + state.workspace!.workspaceId, + ), + ); + Navigator.of(context).pop(); + } + if (state.leaveWorkspace) { + context.read().add( + UserWorkspaceEvent.leaveWorkspace( + state.workspace!.workspaceId, + ), + ); + Navigator.of(context).pop(); + } + }, + builder: (context, state) { + return SettingsBody( + title: LocaleKeys.settings_workspacePage_title.tr(), + description: LocaleKeys.settings_workspacePage_description.tr(), + children: [ + // We don't allow changing workspace name/icon for local/offline + if (state.workspace != null && + widget.userProfile.authenticator != + AuthenticatorPB.Local) ...[ + SettingsCategory( + title: LocaleKeys.settings_workspacePage_workspaceName_title + .tr(), + children: [ + SettingsActionableInput( + controller: _workspaceNameController, + onSave: (value) => _saveWorkspaceName( + context, + current: state.workspace!.name, + name: value, + ), + actions: [ + SizedBox( + height: 48, + child: FlowyTextButton( + LocaleKeys.button_save.tr(), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + fontWeight: FontWeight.w600, + radius: BorderRadius.circular(12), + fillColor: Theme.of(context).colorScheme.primary, + hoverColor: const Color(0xFF005483), + fontHoverColor: Colors.white, + onPressed: () => _saveWorkspaceName( + context, + current: state.workspace!.name, + name: _workspaceNameController.text, + ), + ), + ), + ], + ), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_workspaceIcon_title + .tr(), + description: LocaleKeys + .settings_workspacePage_workspaceIcon_description + .tr(), + children: [ + _WorkspaceIconSetting(workspace: state.workspace!), + ], + ), + ], + SettingsCategory( + title: LocaleKeys.settings_workspacePage_appearance_title.tr(), + children: const [AppearanceSelector()], + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_theme_title.tr(), + description: + LocaleKeys.settings_workspacePage_theme_description.tr(), + children: const [ + _ThemeDropdown(), + SettingsDashedDivider(), + _DocumentCursorColorSetting(), + _DocumentSelectionColorSetting(), + ], + ), + SettingsCategory( + title: + LocaleKeys.settings_workspacePage_workspaceFont_title.tr(), + children: const [_FontSelectorDropdown()], + ), + SettingsCategory( + title: + LocaleKeys.settings_workspacePage_textDirection_title.tr(), + children: const [ + _TextDirectionSelect(), + EnableRTLItemsSwitcher(), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_layoutDirection_title + .tr(), + children: const [_LayoutDirectionSelect()], + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_dateTime_title.tr(), + children: [ + const _DateTimeFormatLabel(), + const _TimeFormatSwitcher(), + SettingsDashedDivider( + color: Theme.of(context).colorScheme.outline, + ), + const _DateFormatDropdown(), + ], + ), + SettingsCategory( + title: LocaleKeys.settings_workspacePage_language_title.tr(), + children: const [LanguageDropdown()], + ), + if (state.workspace != null && + widget.userProfile.authenticator != + AuthenticatorPB.Local) ...[ + SingleSettingAction( + label: LocaleKeys.settings_workspacePage_manageWorkspace_title + .tr(), + fontSize: 16, + fontWeight: FontWeight.w600, + onPressed: () => SettingsAlertDialog( + title: state.myRole.isOwner + ? LocaleKeys + .settings_workspacePage_deleteWorkspacePrompt_title + .tr() + : LocaleKeys + .settings_workspacePage_leaveWorkspacePrompt_title + .tr(), + subtitle: state.myRole.isOwner + ? LocaleKeys + .settings_workspacePage_deleteWorkspacePrompt_content + .tr() + : LocaleKeys + .settings_workspacePage_leaveWorkspacePrompt_content + .tr(), + isDangerous: true, + confirm: () { + context.read().add( + state.myRole.isOwner + ? const WorkspaceSettingsEvent.deleteWorkspace() + : const WorkspaceSettingsEvent.leaveWorkspace(), + ); + Navigator.of(context).pop(); + }, + ).show(context), + isDangerous: true, + buttonLabel: state.myRole.isOwner + ? LocaleKeys + .settings_workspacePage_manageWorkspace_deleteWorkspace + .tr() + : LocaleKeys + .settings_workspacePage_manageWorkspace_leaveWorkspace + .tr(), + ), + ], + ], + ); + }, + ), + ); + } + + void _saveWorkspaceName( + BuildContext context, { + required String current, + required String name, + }) { + if (name.isNotEmpty && name != current) { + context.read().add( + WorkspaceSettingsEvent.updateWorkspaceName( + _workspaceNameController.text, + ), + ); + + if (context.mounted) { + showSnackBarMessage( + context, + LocaleKeys.settings_workspacePage_workspaceName_savedMessage.tr(), + ); + } + } + } +} + +@visibleForTesting +class LanguageDropdown extends StatelessWidget { + const LanguageDropdown({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SettingsDropdown( + key: const Key('LanguageDropdown'), + expandWidth: false, + onChanged: (locale) => context + .read() + .setLocale(context, locale), + selectedOption: state.locale, + options: EasyLocalization.of(context)! + .supportedLocales + .map( + (locale) => buildDropdownMenuEntry( + context, + selectedValue: state.locale, + value: locale, + label: languageFromLocale(locale), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _WorkspaceIconSetting extends StatelessWidget { + const _WorkspaceIconSetting({required this.workspace}); + + final UserWorkspacePB workspace; + + @override + Widget build(BuildContext context) { + return Container( + height: 64, + width: 64, + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outline), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.all(1), + child: WorkspaceIcon( + workspace: workspace, + iconSize: workspace.icon.isNotEmpty == true ? 46 : 20, + enableEdit: true, + onSelected: (r) => context + .read() + .add(WorkspaceSettingsEvent.updateWorkspaceIcon(r.emoji)), + ), + ), + ); + } +} + +class _TextDirectionSelect extends StatelessWidget { + const _TextDirectionSelect(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final selectedItem = state.textDirection ?? AppFlowyTextDirection.auto; + + return SettingsRadioSelect( + onChanged: (item) => context + .read() + .setTextDirection(item.value), + items: [ + SettingsRadioItem( + value: AppFlowyTextDirection.ltr, + icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), + label: LocaleKeys.settings_workspacePage_textDirection_leftToRight + .tr(), + isSelected: selectedItem == AppFlowyTextDirection.ltr, + ), + SettingsRadioItem( + value: AppFlowyTextDirection.rtl, + icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), + label: LocaleKeys.settings_workspacePage_textDirection_rightToLeft + .tr(), + isSelected: selectedItem == AppFlowyTextDirection.rtl, + ), + SettingsRadioItem( + value: AppFlowyTextDirection.auto, + icon: const FlowySvg(FlowySvgs.textdirection_auto_m), + label: LocaleKeys.settings_workspacePage_textDirection_auto.tr(), + isSelected: selectedItem == AppFlowyTextDirection.auto, + ), + ], + ); + }, + ); + } +} + +@visibleForTesting +class EnableRTLItemsSwitcher extends StatelessWidget { + const EnableRTLItemsSwitcher({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText.regular( + LocaleKeys.settings_workspacePage_textDirection_enableRTLItems.tr(), + fontSize: 16, + ), + ), + const HSpace(16), + Toggle( + style: ToggleStyle.big, + value: context + .watch() + .state + .enableRtlToolbarItems, + onChanged: (value) => context + .read() + .setEnableRTLToolbarItems(!value), + ), + ], + ); + } +} + +class _LayoutDirectionSelect extends StatelessWidget { + const _LayoutDirectionSelect(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return SettingsRadioSelect( + onChanged: (item) => context + .read() + .setLayoutDirection(item.value), + items: [ + SettingsRadioItem( + value: LayoutDirection.ltrLayout, + icon: const FlowySvg(FlowySvgs.textdirection_ltr_m), + label: LocaleKeys + .settings_workspacePage_layoutDirection_leftToRight + .tr(), + isSelected: state.layoutDirection == LayoutDirection.ltrLayout, + ), + SettingsRadioItem( + value: LayoutDirection.rtlLayout, + icon: const FlowySvg(FlowySvgs.textdirection_rtl_m), + label: LocaleKeys + .settings_workspacePage_layoutDirection_rightToLeft + .tr(), + isSelected: state.layoutDirection == LayoutDirection.rtlLayout, + ), + ], + ); + }, + ); + } +} + +class _DateFormatDropdown extends StatelessWidget { + const _DateFormatDropdown(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_dateFormat_label + .tr(), + fontSize: 16, + ), + const VSpace(8), + SettingsDropdown( + key: const Key('DateFormatDropdown'), + expandWidth: false, + onChanged: (format) => context + .read() + .setDateFormat(format), + selectedOption: state.dateFormat, + options: UserDateFormatPB.values + .map( + (format) => buildDropdownMenuEntry( + context, + value: format, + label: _formatLabel(format), + ), + ) + .toList(), + ), + ], + ), + ); + }, + ); + } + + String _formatLabel(UserDateFormatPB format) => switch (format) { + UserDateFormatPB.Locally => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_local.tr(), + UserDateFormatPB.US => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_us.tr(), + UserDateFormatPB.ISO => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_iso.tr(), + UserDateFormatPB.Friendly => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_friendly.tr(), + UserDateFormatPB.DayMonthYear => + LocaleKeys.settings_workspacePage_dateTime_dateFormat_dmy.tr(), + _ => "Unknown format", + }; +} + +class _DateTimeFormatLabel extends StatelessWidget { + const _DateTimeFormatLabel(); + + @override + Widget build(BuildContext context) { + final now = DateTime.now(); + + return BlocBuilder( + builder: (context, state) { + return FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_example.tr( + args: [ + state.dateFormat.formatDate(now, false), + state.timeFormat.formatTime(now), + now.timeZoneName, + ], + ), + fontSize: 16, + color: AFThemeExtension.of(context).secondaryTextColor, + ); + }, + ); + } +} + +class _TimeFormatSwitcher extends StatelessWidget { + const _TimeFormatSwitcher(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FlowyText.regular( + LocaleKeys.settings_workspacePage_dateTime_24HourTime.tr(), + fontSize: 16, + ), + ), + const HSpace(16), + Toggle( + style: ToggleStyle.big, + value: context.watch().state.timeFormat == + UserTimeFormatPB.TwentyFourHour, + onChanged: (value) => + context.read().setTimeFormat( + value + ? UserTimeFormatPB.TwelveHour + : UserTimeFormatPB.TwentyFourHour, + ), + ), + ], + ); + } +} + +class _ThemeDropdown extends StatelessWidget { + const _ThemeDropdown(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => DynamicPluginBloc()..add(DynamicPluginEvent.load()), + child: BlocBuilder( + buildWhen: (_, current) => current is Ready, + builder: (context, state) { + final appearance = context.watch().state; + final isLightMode = Theme.of(context).brightness == Brightness.light; + + final customThemes = state.whenOrNull( + ready: (ps) => ps.map((p) => p.theme).whereType(), + ); + + return SettingsDropdown( + key: const Key('ThemeSelectorDropdown'), + actions: [ + SettingAction( + tooltip: 'Upload a custom theme', + icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(16)), + onPressed: () => Dialogs.show( + context, + child: BlocProvider.value( + value: context.read(), + child: const FlowyDialog( + constraints: BoxConstraints(maxHeight: 300), + child: ThemeUploadWidget(), + ), + ), + ).then((val) { + if (val != null) { + showSnackBarMessage( + context, + LocaleKeys.settings_appearance_themeUpload_uploadSuccess + .tr(), + ); + } + }), + ), + SettingAction( + icon: const FlowySvg(FlowySvgs.restore_s), + label: LocaleKeys.settings_common_reset.tr(), + onPressed: () => context + .read() + .setTheme(AppTheme.builtins.first.themeName), + ), + ], + onChanged: (theme) => + context.read().setTheme(theme), + selectedOption: appearance.appTheme.themeName, + options: [ + ...AppTheme.builtins.map( + (t) { + final theme = isLightMode ? t.lightTheme : t.darkTheme; + + return buildDropdownMenuEntry( + context, + selectedValue: appearance.appTheme.themeName, + value: t.themeName, + label: t.themeName, + leadingWidget: _ThemeLeading(color: theme.sidebarBg), + ); + }, + ), + ...?customThemes?.map( + (t) { + final theme = isLightMode ? t.lightTheme : t.darkTheme; + + return buildDropdownMenuEntry( + context, + selectedValue: appearance.appTheme.themeName, + value: t.themeName, + label: t.themeName, + leadingWidget: _ThemeLeading(color: theme.sidebarBg), + trailingWidget: FlowyIconButton( + icon: const FlowySvg(FlowySvgs.delete_s), + iconColorOnHover: Theme.of(context).colorScheme.onSurface, + onPressed: () { + context.read().add( + DynamicPluginEvent.removePlugin( + name: t.themeName, + ), + ); + + if (appearance.appTheme.themeName == t.themeName) { + context + .read() + .setTheme(AppTheme.builtins.first.themeName); + } + }, + ), + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _ThemeLeading extends StatelessWidget { + const _ThemeLeading({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: Corners.s4Border, + border: Border.all(color: Theme.of(context).colorScheme.outline), + ), + ); + } +} + +@visibleForTesting +class AppearanceSelector extends StatelessWidget { + const AppearanceSelector({super.key}); + + @override + Widget build(BuildContext context) { + final themeMode = context.read().state.themeMode; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...ThemeMode.values.map( + (t) => Padding( + padding: const EdgeInsets.only(right: 16), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => + context.read().setThemeMode(t), + child: FlowyHover( + style: HoverStyle.transparent( + foregroundColorOnHover: + AFThemeExtension.of(context).textColor, + ), + child: Column( + children: [ + Container( + width: 88, + height: 72, + decoration: BoxDecoration( + border: Border.all( + color: t == themeMode + ? Theme.of(context).colorScheme.onSecondary + : Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s4Border, + image: DecorationImage( + fit: BoxFit.cover, + image: AssetImage( + 'assets/images/appearance/${t.name.toLowerCase()}.png', + ), + ), + ), + ), + const VSpace(6), + FlowyText.regular(getLabel(t), textAlign: TextAlign.center), + ], + ), + ), + ), + ), + ), + ], + ); + } + + String getLabel(ThemeMode t) => switch (t) { + ThemeMode.system => + LocaleKeys.settings_workspacePage_appearance_options_system.tr(), + ThemeMode.light => + LocaleKeys.settings_workspacePage_appearance_options_light.tr(), + ThemeMode.dark => + LocaleKeys.settings_workspacePage_appearance_options_dark.tr(), + }; +} + +class _FontSelectorDropdown extends StatelessWidget { + const _FontSelectorDropdown(); + + @override + Widget build(BuildContext context) { + final appearance = context.watch().state; + return SettingsDropdown( + key: const Key('FontSelectorDropdown'), + actions: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context + .read() + .setFontFamily(defaultFontFamily), + child: SizedBox( + height: 26, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + const FlowySvg(FlowySvgs.restore_s), + const HSpace(4), + FlowyText.regular(LocaleKeys.settings_common_reset.tr()), + ], + ), + ), + ), + ), + ), + ], + onChanged: (font) => + context.read().setFontFamily(font), + selectedOption: appearance.font, + options: [defaultFontFamily, ...GoogleFonts.asMap().keys] + .map( + (font) => buildDropdownMenuEntry( + context, + selectedValue: appearance.font, + value: font, + label: font.fontFamilyDisplayName, + ), + ) + .toList(), + ); + } +} + +class _DocumentCursorColorSetting extends StatelessWidget { + const _DocumentCursorColorSetting(); + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); + return BlocBuilder( + builder: (context, state) { + return SettingListTile( + label: label, + resetButtonKey: const Key('DocumentCursorColorResetButton'), + onResetRequested: () => context + ..read().resetDocumentCursorColor() + ..read().syncCursorColor(null), + trailing: [ + DocumentColorSettingButton( + key: const Key('DocumentCursorColorSettingButton'), + currentColor: state.cursorColor ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + previewWidgetBuilder: (color) => _CursorColorValueWidget( + cursorColor: color ?? + DefaultAppearanceSettings.getDefaultCursorColor(context), + ), + dialogTitle: label, + onApply: (color) => context + ..read().setDocumentCursorColor(color) + ..read().syncCursorColor(color), + ), + ], + ); + }, + ); + } +} + +class _CursorColorValueWidget extends StatelessWidget { + const _CursorColorValueWidget({required this.cursorColor}); + + final Color cursorColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(color: cursorColor, width: 2, height: 16), + FlowyText( + LocaleKeys.appName.tr(), + // To avoid the text color changes when it is hovered in dark mode + color: Theme.of(context).colorScheme.onBackground, + ), + ], + ); + } +} + +class _DocumentSelectionColorSetting extends StatelessWidget { + const _DocumentSelectionColorSetting(); + + @override + Widget build(BuildContext context) { + final label = + LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); + + return BlocBuilder( + builder: (context, state) { + return SettingListTile( + label: label, + resetButtonKey: const Key('DocumentSelectionColorResetButton'), + onResetRequested: () => context + ..read().resetDocumentSelectionColor() + ..read().syncSelectionColor(null), + trailing: [ + DocumentColorSettingButton( + currentColor: state.selectionColor ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + previewWidgetBuilder: (color) => _SelectionColorValueWidget( + selectionColor: color ?? + DefaultAppearanceSettings.getDefaultSelectionColor(context), + ), + dialogTitle: label, + onApply: (c) => context + ..read().setDocumentSelectionColor(c) + ..read().syncSelectionColor(c), + ), + ], + ); + }, + ); + } +} + +class _SelectionColorValueWidget extends StatelessWidget { + const _SelectionColorValueWidget({required this.selectionColor}); + + final Color selectionColor; + + @override + Widget build(BuildContext context) { + // To avoid the text color changes when it is hovered in dark mode + final textColor = Theme.of(context).colorScheme.onBackground; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + color: selectionColor, + child: FlowyText( + LocaleKeys.settings_appearance_documentSettings_app.tr(), + color: textColor, + ), + ), + FlowyText( + LocaleKeys.settings_appearance_documentSettings_flowy.tr(), + color: textColor, + ), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index a3b34d524f..2a6e2bfbee 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -3,12 +3,11 @@ import 'package:flutter/material.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart'; +import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_page.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -78,10 +77,8 @@ class SettingsDialog extends StatelessWidget { didLogout: didLogout, didLogin: dismissDialog, ); - case SettingsPage.appearance: - return const SettingsAppearanceView(); - case SettingsPage.language: - return const SettingsLanguageView(); + case SettingsPage.workspace: + return SettingsWorkspaceView(userProfile: user); case SettingsPage.files: return const SettingsFileSystemView(); case SettingsPage.notifications: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart new file mode 100644 index 0000000000..ef4c374239 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/af_dropdown_menu_entry.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +DropdownMenuEntry buildDropdownMenuEntry( + BuildContext context, { + required T value, + required String label, + T? selectedValue, + Widget? leadingWidget, + Widget? trailingWidget, +}) { + return DropdownMenuEntry( + style: ButtonStyle( + foregroundColor: + MaterialStatePropertyAll(Theme.of(context).colorScheme.primary), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + ), + minimumSize: const MaterialStatePropertyAll(Size(double.infinity, 29)), + maximumSize: const MaterialStatePropertyAll(Size(double.infinity, 29)), + ), + value: value, + label: label, + leadingIcon: leadingWidget, + labelWidget: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: FlowyText.medium(label, fontSize: 14, textAlign: TextAlign.start), + ), + trailingIcon: Row( + children: [ + if (trailingWidget != null) ...[ + trailingWidget, + const HSpace(8), + ], + value == selectedValue + ? const FlowySvg(FlowySvgs.check_s) + : const SizedBox.shrink(), + ], + ), + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart similarity index 59% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart index 203d42e1f5..d770e2e3bb 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/document_color_setting_button.dart @@ -1,13 +1,14 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/util/color_to_hex_string.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_alert_dialog.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/utils/hex_opacity_string_extension.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; -class DocumentColorSettingButton extends StatelessWidget { +class DocumentColorSettingButton extends StatefulWidget { const DocumentColorSettingButton({ super.key, required this.currentColor, @@ -27,41 +28,53 @@ class DocumentColorSettingButton extends StatelessWidget { final void Function(Color selectedColorOnDialog) onApply; + @override + State createState() => + _DocumentColorSettingButtonState(); +} + +class _DocumentColorSettingButtonState + extends State { + late Color newColor = widget.currentColor; + @override Widget build(BuildContext context) { return FlowyButton( margin: const EdgeInsets.all(8), - text: previewWidgetBuilder.call(currentColor), + text: widget.previewWidgetBuilder.call(widget.currentColor), hoverColor: Theme.of(context).colorScheme.secondaryContainer, expandText: false, - onTap: () => Dialogs.show( - context, - child: _DocumentColorSettingDialog( - currentColor: currentColor, - previewWidgetBuilder: previewWidgetBuilder, - dialogTitle: dialogTitle, - onApply: onApply, - ), - ), + onTap: () => SettingsAlertDialog( + title: widget.dialogTitle, + confirm: () { + widget.onApply(newColor); + Navigator.of(context).pop(); + }, + children: [ + _DocumentColorSettingDialog( + formKey: GlobalKey(), + currentColor: widget.currentColor, + previewWidgetBuilder: widget.previewWidgetBuilder, + onChanged: (color) => newColor = color, + ), + ], + ).show(context), ); } } class _DocumentColorSettingDialog extends StatefulWidget { const _DocumentColorSettingDialog({ + required this.formKey, required this.currentColor, required this.previewWidgetBuilder, - required this.dialogTitle, - required this.onApply, + required this.onChanged, }); + final GlobalKey formKey; final Color currentColor; - final Widget Function(Color?) previewWidgetBuilder; - - final String dialogTitle; - - final void Function(Color selectedColorOnDialog) onApply; + final void Function(Color selectedColor) onChanged; @override State<_DocumentColorSettingDialog> createState() => @@ -76,16 +89,16 @@ class DocumentColorSettingDialogState late String currentColorHexString; late TextEditingController hexController; late TextEditingController opacityController; - final _formKey = GlobalKey(debugLabel: 'colorSettingForm'); void updateSelectedColor() { - if (_formKey.currentState!.validate()) { + if (widget.formKey.currentState!.validate()) { setState(() { final colorValue = int.tryParse( hexController.text.combineHexWithOpacity(opacityController.text), ); // colorValue has been validated in the _ColorSettingTextField for hex value and it won't be null as this point selectedColorOnDialog = Color(colorValue!); + widget.onChanged(selectedColorOnDialog!); }); } } @@ -112,74 +125,43 @@ class DocumentColorSettingDialogState @override Widget build(BuildContext context) { - return FlowyDialog( - constraints: const BoxConstraints(maxWidth: 360, maxHeight: 320), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const Spacer(), - FlowyText(widget.dialogTitle), - const VSpace(8), - SizedBox( - width: 100, - height: 40, - child: Center( - child: widget.previewWidgetBuilder( - selectedColorOnDialog, - ), - ), + return Column( + children: [ + SizedBox( + width: 100, + height: 40, + child: Center( + child: widget.previewWidgetBuilder( + selectedColorOnDialog, ), - const VSpace(8), - SizedBox( - height: 160, - child: Form( - key: _formKey, - child: Column( - children: [ - _ColorSettingTextField( - controller: hexController, - labelText: LocaleKeys.editor_hexValue.tr(), - hintText: '6fc9e7', - onFieldSubmitted: (_) => updateSelectedColor(), - validator: (hexValue) => validateHexValue( - hexValue, - opacityController.text, - ), - ), - const VSpace(8), - _ColorSettingTextField( - controller: opacityController, - labelText: LocaleKeys.editor_opacity.tr(), - hintText: '50', - onFieldSubmitted: (_) => updateSelectedColor(), - validator: (value) => validateOpacityValue(value), - ), - ], - ), - ), - ), - const VSpace(8), - RoundedTextButton( - title: LocaleKeys.settings_appearance_documentSettings_apply.tr(), - width: 100, - height: 30, - onPressed: () { - if (_formKey.currentState!.validate()) { - if (selectedColorOnDialog != null && - selectedColorOnDialog != widget.currentColor) { - widget.onApply.call(selectedColorOnDialog!); - } - } else { - // error message will be shown below the text field - return; - } - Navigator.of(context).pop(); - }, - ), - ], + ), ), - ), + const VSpace(8), + Form( + key: widget.formKey, + child: Column( + children: [ + _ColorSettingTextField( + controller: hexController, + labelText: LocaleKeys.editor_hexValue.tr(), + hintText: '6fc9e7', + onChanged: (_) => updateSelectedColor(), + onFieldSubmitted: (_) => updateSelectedColor(), + validator: (v) => validateHexValue(v, opacityController.text), + ), + const VSpace(8), + _ColorSettingTextField( + controller: opacityController, + labelText: LocaleKeys.editor_opacity.tr(), + hintText: '50', + onChanged: (_) => updateSelectedColor(), + onFieldSubmitted: (_) => updateSelectedColor(), + validator: (value) => validateOpacityValue(value), + ), + ], + ), + ), + ], ); } } @@ -190,14 +172,15 @@ class _ColorSettingTextField extends StatelessWidget { required this.labelText, required this.hintText, required this.onFieldSubmitted, - required this.validator, + this.onChanged, + this.validator, }); final TextEditingController controller; final String labelText; final String hintText; - final void Function(String) onFieldSubmitted; + final void Function(String)? onChanged; final String? Function(String?)? validator; @override @@ -209,17 +192,14 @@ class _ColorSettingTextField extends StatelessWidget { labelText: labelText, hintText: hintText, border: OutlineInputBorder( - borderSide: BorderSide( - color: style.colorScheme.outline, - ), + borderSide: BorderSide(color: style.colorScheme.outline), ), enabledBorder: OutlineInputBorder( - borderSide: BorderSide( - color: style.colorScheme.outline, - ), + borderSide: BorderSide(color: style.colorScheme.outline), ), ), style: style.textTheme.bodyMedium, + onChanged: onChanged, onFieldSubmitted: onFieldSubmitted, validator: validator, autovalidateMode: AutovalidateMode.onUserInteraction, @@ -227,10 +207,7 @@ class _ColorSettingTextField extends StatelessWidget { } } -String? validateHexValue( - String? hexValue, - String opacityValue, -) { +String? validateHexValue(String? hexValue, String opacityValue) { if (hexValue == null || hexValue.isEmpty) { return LocaleKeys.settings_appearance_documentSettings_hexEmptyError.tr(); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart new file mode 100644 index 0000000000..41c677f7bd --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_action.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class SettingAction extends StatelessWidget { + const SettingAction({ + super.key, + required this.onPressed, + required this.icon, + this.label, + this.tooltip, + }); + + final VoidCallback onPressed; + final Widget icon; + final String? label; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final iconWidget = tooltip != null && tooltip!.isNotEmpty + ? FlowyTooltip(message: tooltip, child: icon) + : icon; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed, + child: SizedBox( + height: 26, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + iconWidget, + if (label != null) ...[ + const HSpace(4), + FlowyText.regular(label!), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart similarity index 56% rename from frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart rename to frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart index 2a4a76cdf9..c2d049733a 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_list_tile.dart @@ -1,12 +1,12 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flutter/material.dart'; -class FlowySettingListTile extends StatelessWidget { - const FlowySettingListTile({ +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingListTile extends StatelessWidget { + const SettingListTile({ super.key, this.resetTooltipText, this.resetButtonKey, @@ -67,54 +67,3 @@ class FlowySettingListTile extends StatelessWidget { ); } } - -class FlowySettingValueDropDown extends StatefulWidget { - const FlowySettingValueDropDown({ - super.key, - required this.currentValue, - required this.popupBuilder, - this.popoverKey, - this.onClose, - this.child, - this.popoverController, - this.offset, - }); - - final String currentValue; - final Key? popoverKey; - final Widget Function(BuildContext) popupBuilder; - final void Function()? onClose; - final Widget? child; - final PopoverController? popoverController; - final Offset? offset; - - @override - State createState() => - _FlowySettingValueDropDownState(); -} - -class _FlowySettingValueDropDownState extends State { - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - key: widget.popoverKey, - controller: widget.popoverController, - direction: PopoverDirection.bottomWithCenterAligned, - popupBuilder: widget.popupBuilder, - constraints: const BoxConstraints( - minWidth: 80, - maxWidth: 160, - maxHeight: 400, - ), - offset: widget.offset, - onClose: widget.onClose, - child: widget.child ?? - FlowyTextButton( - widget.currentValue, - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart new file mode 100644 index 0000000000..12bf1c1480 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/setting_value_dropdown.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingValueDropDown extends StatefulWidget { + const SettingValueDropDown({ + super.key, + required this.currentValue, + required this.popupBuilder, + this.popoverKey, + this.onClose, + this.child, + this.popoverController, + this.offset, + }); + + final String currentValue; + final Key? popoverKey; + final Widget Function(BuildContext) popupBuilder; + final void Function()? onClose; + final Widget? child; + final PopoverController? popoverController; + final Offset? offset; + + @override + State createState() => _SettingValueDropDownState(); +} + +class _SettingValueDropDownState extends State { + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: widget.popoverKey, + controller: widget.popoverController, + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: widget.popupBuilder, + constraints: const BoxConstraints( + minWidth: 80, + maxWidth: 160, + maxHeight: 400, + ), + offset: widget.offset, + onClose: widget.onClose, + child: widget.child ?? + FlowyTextButton( + widget.currentValue, + fontColor: Theme.of(context).colorScheme.onBackground, + fillColor: Colors.transparent, + onPressed: () {}, + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart new file mode 100644 index 0000000000..6f92696f28 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_actionable_input.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingsActionableInput extends StatelessWidget { + const SettingsActionableInput({ + super.key, + required this.controller, + this.focusNode, + this.placeholder, + this.onSave, + this.actions = const [], + }); + + final TextEditingController controller; + final FocusNode? focusNode; + final String? placeholder; + final Function(String)? onSave; + final List actions; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + child: SizedBox( + height: 48, + child: FlowyTextField( + controller: controller, + focusNode: focusNode, + hintText: placeholder, + autoFocus: false, + isDense: false, + suffixIconConstraints: + BoxConstraints.tight(const Size(23 + 18, 24)), + textStyle: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + onSubmitted: onSave, + ), + ), + ), + if (actions.isNotEmpty) ...[ + const HSpace(8), + SeparatedRow( + separatorBuilder: () => const HSpace(16), + children: actions, + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart index b1ade9c118..54593e1bd0 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_body.dart @@ -1,11 +1,19 @@ import 'package:flutter/material.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + class SettingsBody extends StatelessWidget { const SettingsBody({ super.key, + required this.title, + this.description, required this.children, }); + final String title; + final String? description; final List children; @override @@ -14,8 +22,18 @@ class SettingsBody extends StatelessWidget { physics: const ClampingScrollPhysics(), padding: const EdgeInsets.all(24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: children, + children: [ + SettingsHeader(title: title, description: description), + Flexible( + child: SeparatedColumn( + separatorBuilder: () => const SettingsCategorySpacer(), + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart new file mode 100644 index 0000000000..6a8405dc56 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dashed_divider.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +/// Renders a dashed divider +/// +/// The length of each dash is the same as the gap. +/// +class SettingsDashedDivider extends StatelessWidget { + const SettingsDashedDivider({ + super.key, + this.color, + this.height, + this.strokeWidth = 1.0, + this.gap = 3.0, + this.direction = Axis.horizontal, + }); + + // The color of the divider, defaults to the theme's divider color + final Color? color; + + // The height of the divider, this will surround the divider equally + final double? height; + + // Thickness of the divider + final double strokeWidth; + + // Gap between the dashes + final double gap; + + // Direction of the divider + final Axis direction; + + @override + Widget build(BuildContext context) { + final double padding = + height != null && height! > 0 ? (height! - strokeWidth) / 2 : 0; + + return LayoutBuilder( + builder: (context, constraints) { + final items = _calculateItems(constraints); + return Padding( + padding: EdgeInsets.symmetric( + vertical: direction == Axis.horizontal ? padding : 0, + horizontal: direction == Axis.vertical ? padding : 0, + ), + child: Wrap( + direction: direction, + children: List.generate( + items, + (index) => Container( + margin: EdgeInsets.only( + right: direction == Axis.horizontal ? gap : 0, + bottom: direction == Axis.vertical ? gap : 0, + ), + width: direction == Axis.horizontal ? gap : strokeWidth, + height: direction == Axis.vertical ? gap : strokeWidth, + decoration: BoxDecoration( + color: color ?? Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(1.0), + ), + ), + ), + ), + ); + }, + ); + } + + int _calculateItems(BoxConstraints constraints) { + final double totalLength = direction == Axis.horizontal + ? constraints.maxWidth + : constraints.maxHeight; + + return (totalLength / (gap * 2)).floor(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart new file mode 100644 index 0000000000..67c0dc4cf9 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_dropdown.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/flutter/af_dropdown_menu.dart'; +import 'package:collection/collection.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; + +class SettingsDropdown extends StatefulWidget { + const SettingsDropdown({ + super.key, + required this.selectedOption, + required this.options, + this.onChanged, + this.actions, + this.expandWidth = true, + }); + + final T selectedOption; + final List> options; + final void Function(T)? onChanged; + final List? actions; + final bool expandWidth; + + @override + State> createState() => _SettingsDropdownState(); +} + +class _SettingsDropdownState extends State> { + late final TextEditingController controller = TextEditingController( + text: widget.selectedOption is String + ? widget.selectedOption as String + : widget.options + .firstWhereOrNull((e) => e.value == widget.selectedOption) + ?.label ?? + '', + ); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: AFDropdownMenu( + controller: controller, + expandedInsets: widget.expandWidth ? EdgeInsets.zero : null, + initialSelection: widget.selectedOption, + dropdownMenuEntries: widget.options, + menuStyle: MenuStyle( + maximumSize: + const MaterialStatePropertyAll(Size(double.infinity, 250)), + elevation: const MaterialStatePropertyAll(10), + shadowColor: + MaterialStatePropertyAll(Colors.black.withOpacity(0.4)), + backgroundColor: MaterialStatePropertyAll( + Theme.of(context).cardColor, + ), + padding: const MaterialStatePropertyAll( + EdgeInsets.symmetric(horizontal: 6, vertical: 8), + ), + alignment: Alignment.bottomLeft, + visualDensity: VisualDensity.compact, + ), + inputDecorationTheme: InputDecorationTheme( + contentPadding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 18, + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + borderRadius: Corners.s8Border, + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + borderRadius: Corners.s8Border, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + borderRadius: Corners.s8Border, + ), + ), + onSelected: (v) async { + v != null ? widget.onChanged?.call(v) : null; + }, + ), + ), + if (widget.actions?.isNotEmpty == true) ...[ + const HSpace(16), + SeparatedRow( + separatorBuilder: () => const HSpace(8), + children: widget.actions!, + ), + ], + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart new file mode 100644 index 0000000000..91d780ceda --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/shared/settings_radio_select.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; + +class SettingsRadioItem { + const SettingsRadioItem({ + required this.value, + required this.label, + required this.isSelected, + this.icon, + }); + + final T value; + final String label; + final bool isSelected; + final Widget? icon; +} + +class SettingsRadioSelect extends StatelessWidget { + const SettingsRadioSelect({ + super.key, + required this.items, + required this.onChanged, + this.selectedItem, + }); + + final List> items; + final void Function(SettingsRadioItem) onChanged; + final SettingsRadioItem? selectedItem; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 24, + runSpacing: 8, + children: items + .map( + (i) => GestureDetector( + onTap: () => onChanged(i), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 14, + height: 14, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AFThemeExtension.of(context).textColor, + ), + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: i.isSelected + ? AFThemeExtension.of(context).textColor + : Colors.transparent, + shape: BoxShape.circle, + ), + ), + ), + const HSpace(8), + if (i.icon != null) ...[i.icon!, const HSpace(4)], + FlowyText.regular(i.label, fontSize: 14), + ], + ), + ), + ) + .toList(), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart index a1fb8257ec..43f9438ff7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/feature_flags/feature_flag_page.dart @@ -3,8 +3,6 @@ import 'package:flutter/material.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; class FeatureFlagsPage extends StatelessWidget { @@ -15,15 +13,14 @@ class FeatureFlagsPage extends StatelessWidget { @override Widget build(BuildContext context) { return SettingsBody( + title: 'Feature flags', children: [ - const SettingsHeader(title: 'Feature flags'), SeparatedColumn( children: FeatureFlag.data.entries .where((e) => e.key != FeatureFlag.unknown) .map((e) => _FeatureFlagItem(featureFlag: e.key)) .toList(), ), - const SettingsCategorySpacer(), FlowyTextButton( 'Restart the app to apply changes', fontSize: 16.0, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart index 64f5914e7c..e104879752 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/members/workspace_member_page.dart @@ -1,10 +1,11 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/af_role_pb_extension.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart'; @@ -16,7 +17,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:string_validator/string_validator.dart'; @@ -34,11 +34,8 @@ class WorkspaceMembersPage extends StatelessWidget { listener: _showResultDialog, builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_appearance_members_title.tr(), children: [ - // title - SettingsHeader( - title: LocaleKeys.settings_appearance_members_title.tr(), - ), if (state.myRole.canInvite) const _InviteMember(), if (state.myRole.canInvite && state.members.isNotEmpty) const SettingsCategorySpacer(), diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart index 8d6b48976e..99d272d122 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart @@ -9,7 +9,6 @@ import 'package:appflowy/mobile/presentation/widgets/widgets.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/cloud_setting_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/setting_local_cloud.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -41,10 +40,8 @@ class SettingCloud extends StatelessWidget { child: BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_menu_cloudSettings.tr(), children: [ - SettingsHeader( - title: LocaleKeys.settings_menu_cloudSettings.tr(), - ), if (Env.enableCustomCloud) Row( children: [ @@ -55,17 +52,12 @@ class SettingCloud extends StatelessWidget { ), CloudTypeSwitcher( cloudType: state.cloudType, - onSelected: (newCloudType) { - context.read().add( - CloudSettingEvent.updateCloudType( - newCloudType, - ), - ); - }, + onSelected: (type) => context + .read() + .add(CloudSettingEvent.updateCloudType(type)), ), ], ), - const VSpace(8), _viewFromCloudType(state.cloudType), ], ); @@ -73,9 +65,7 @@ class SettingCloud extends StatelessWidget { ), ); } else { - return const Center( - child: CircularProgressIndicator(), - ); + return const Center(child: CircularProgressIndicator()); } }, ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart deleted file mode 100644 index aaa7004014..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/brightness_setting.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'dart:io'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/util/theme_mode_extension.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'theme_setting_entry_template.dart'; - -class BrightnessSetting extends StatelessWidget { - const BrightnessSetting({required this.currentThemeMode, super.key}); - - final ThemeMode currentThemeMode; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_themeMode_label.tr(), - hint: hintText, - onResetRequested: context.read().resetThemeMode, - trailing: [ - FlowySettingValueDropDown( - currentValue: currentThemeMode.labelText, - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _themeModeItemButton(context, ThemeMode.light), - _themeModeItemButton(context, ThemeMode.dark), - _themeModeItemButton(context, ThemeMode.system), - ], - ), - ), - ], - ); - } - - String get hintText => - '${LocaleKeys.settings_files_change.tr()} ${LocaleKeys.settings_appearance_themeMode_label.tr()} : ${Platform.isMacOS ? '⌘+Shift+L' : 'Ctrl+Shift+L'}'; - - Widget _themeModeItemButton( - BuildContext context, - ThemeMode themeMode, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(themeMode.labelText), - rightIcon: currentThemeMode == themeMode - ? const FlowySvg( - FlowySvgs.check_s, - ) - : null, - onTap: () { - if (currentThemeMode != themeMode) { - context.read().setThemeMode(themeMode); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart deleted file mode 100644 index bf91f92097..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/color_scheme.dart +++ /dev/null @@ -1,174 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; -import 'package:flowy_infra/plugins/bloc/dynamic_plugin_event.dart'; -import 'package:flowy_infra/plugins/bloc/dynamic_plugin_state.dart'; -import 'package:flowy_infra/theme.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/dialog/styled_dialogs.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class ColorSchemeSetting extends StatelessWidget { - const ColorSchemeSetting({ - super.key, - required this.currentTheme, - required this.bloc, - }); - - final String currentTheme; - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_theme.tr(), - onResetRequested: context.read().resetTheme, - trailing: [ - ColorSchemeUploadPopover(currentTheme: currentTheme, bloc: bloc), - ColorSchemeUploadOverlayButton(bloc: bloc), - ], - ); - } -} - -class ColorSchemeUploadOverlayButton extends StatelessWidget { - const ColorSchemeUploadOverlayButton({super.key, required this.bloc}); - - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return FlowyIconButton( - width: 24, - icon: FlowySvg( - FlowySvgs.folder_m, - size: const Size.square(16), - color: Theme.of(context).iconTheme.color, - ), - iconColorOnHover: Theme.of(context).colorScheme.onPrimary, - hoverColor: Theme.of(context).colorScheme.secondaryContainer, - tooltipText: LocaleKeys.settings_appearance_themeUpload_uploadTheme.tr(), - onPressed: () => Dialogs.show( - context, - child: BlocProvider.value( - value: bloc, - child: const FlowyDialog( - constraints: BoxConstraints(maxHeight: 300), - child: ThemeUploadWidget(), - ), - ), - ).then((value) { - if (value == null) return; - showSnackBarMessage( - context, - LocaleKeys.settings_appearance_themeUpload_uploadSuccess.tr(), - ); - }), - ); - } -} - -class ColorSchemeUploadPopover extends StatelessWidget { - const ColorSchemeUploadPopover({ - super.key, - required this.currentTheme, - required this.bloc, - }); - - final String currentTheme; - final DynamicPluginBloc bloc; - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - child: FlowyTextButton( - currentTheme, - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - popupBuilder: (BuildContext context) { - return IntrinsicWidth( - child: BlocBuilder( - bloc: bloc..add(DynamicPluginEvent.load()), - buildWhen: (previous, current) => current is Ready, - builder: (context, state) { - return state.maybeWhen( - ready: (plugins) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...AppTheme.builtins.map( - (theme) => _themeItemButton(context, theme.themeName), - ), - if (plugins.isNotEmpty) ...[ - const Divider(), - ...plugins - .map((plugin) => plugin.theme) - .whereType() - .map( - (theme) => _themeItemButton( - context, - theme.themeName, - false, - ), - ), - ], - ], - ), - orElse: () => const SizedBox.shrink(), - ); - }, - ), - ); - }, - ); - } - - Widget _themeItemButton( - BuildContext context, - String theme, [ - bool isBuiltin = true, - ]) { - return SizedBox( - height: 32, - child: Row( - children: [ - Expanded( - child: FlowyButton( - text: FlowyText.medium(theme), - rightIcon: currentTheme == theme - ? const FlowySvg( - FlowySvgs.check_s, - ) - : null, - onTap: () { - if (currentTheme != theme) { - context.read().setTheme(theme); - } - PopoverContainer.of(context).close(); - }, - ), - ), - // when the custom theme is not the current theme, show the remove button - if (!isBuiltin && currentTheme != theme) - FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - ), - width: 20, - onPressed: () => - bloc.add(DynamicPluginEvent.removePlugin(name: theme)), - ), - ], - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart deleted file mode 100644 index d45bc44b27..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/prelude.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -bool _prevSetting = false; - -class CreateFileSettings extends StatelessWidget { - CreateFileSettings({ - super.key, - }); - - final cubit = CreateFileSettingsCubit(_prevSetting); - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: - LocaleKeys.settings_appearance_showNamingDialogWhenCreatingPage.tr(), - trailing: [ - BlocProvider.value( - value: cubit, - child: BlocBuilder( - builder: (context, state) { - _prevSetting = state; - return Switch( - value: state, - splashRadius: 0, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (value) { - cubit.toggle(value: value); - _prevSetting = value; - }, - ); - }, - ), - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart deleted file mode 100644 index 3f78e35478..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'theme_setting_entry_template.dart'; - -class DateFormatSetting extends StatelessWidget { - const DateFormatSetting({ - super.key, - required this.currentFormat, - }); - - final UserDateFormatPB currentFormat; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_dateFormat_label.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _formatLabel(currentFormat), - popupBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _formatItem(context, UserDateFormatPB.Locally), - _formatItem(context, UserDateFormatPB.US), - _formatItem(context, UserDateFormatPB.ISO), - _formatItem(context, UserDateFormatPB.Friendly), - _formatItem(context, UserDateFormatPB.DayMonthYear), - ], - ), - ), - ], - ); - - Widget _formatItem(BuildContext context, UserDateFormatPB format) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_formatLabel(format)), - rightIcon: - currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, - onTap: () { - if (currentFormat != format) { - context.read().setDateFormat(format); - } - }, - ), - ); - } - - String _formatLabel(UserDateFormatPB format) { - switch (format) { - case (UserDateFormatPB.Locally): - return LocaleKeys.settings_appearance_dateFormat_local.tr(); - case (UserDateFormatPB.US): - return LocaleKeys.settings_appearance_dateFormat_us.tr(); - case (UserDateFormatPB.ISO): - return LocaleKeys.settings_appearance_dateFormat_iso.tr(); - case (UserDateFormatPB.Friendly): - return LocaleKeys.settings_appearance_dateFormat_friendly.tr(); - case (UserDateFormatPB.DayMonthYear): - return LocaleKeys.settings_appearance_dateFormat_dmy.tr(); - default: - return ""; - } - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart deleted file mode 100644 index 1f21cfa4bb..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/direction_setting.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'theme_setting_entry_template.dart'; - -class LayoutDirectionSetting extends StatelessWidget { - const LayoutDirectionSetting({ - super.key, - required this.currentLayoutDirection, - }); - - final LayoutDirection currentLayoutDirection; - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_layoutDirection_label.tr(), - hint: LocaleKeys.settings_appearance_layoutDirection_hint.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _layoutDirectionLabelText(currentLayoutDirection), - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _layoutDirectionItemButton(context, LayoutDirection.ltrLayout), - _layoutDirectionItemButton(context, LayoutDirection.rtlLayout), - ], - ), - ), - ], - ); - } - - Widget _layoutDirectionItemButton( - BuildContext context, - LayoutDirection direction, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_layoutDirectionLabelText(direction)), - rightIcon: currentLayoutDirection == direction - ? const FlowySvg(FlowySvgs.check_s) - : null, - onTap: () { - if (currentLayoutDirection != direction) { - context - .read() - .setLayoutDirection(direction); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } - - String _layoutDirectionLabelText(LayoutDirection direction) { - switch (direction) { - case (LayoutDirection.ltrLayout): - return LocaleKeys.settings_appearance_layoutDirection_ltr.tr(); - case (LayoutDirection.rtlLayout): - return LocaleKeys.settings_appearance_layoutDirection_rtl.tr(); - default: - return ''; - } - } -} - -class TextDirectionSetting extends StatelessWidget { - const TextDirectionSetting({ - super.key, - required this.currentTextDirection, - }); - - final AppFlowyTextDirection? currentTextDirection; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_textDirection_label.tr(), - hint: LocaleKeys.settings_appearance_textDirection_hint.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _textDirectionLabelText(currentTextDirection), - popupBuilder: (context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _textDirectionItemButton(context, null), - _textDirectionItemButton(context, AppFlowyTextDirection.ltr), - _textDirectionItemButton(context, AppFlowyTextDirection.rtl), - _textDirectionItemButton(context, AppFlowyTextDirection.auto), - ], - ), - ), - ], - ); - - Widget _textDirectionItemButton( - BuildContext context, - AppFlowyTextDirection? textDirection, - ) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_textDirectionLabelText(textDirection)), - rightIcon: currentTextDirection == textDirection - ? const FlowySvg(FlowySvgs.check_s) - : null, - onTap: () { - if (currentTextDirection != textDirection) { - context - .read() - .setTextDirection(textDirection); - context - .read() - .syncDefaultTextDirection(textDirection?.name); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } - - String _textDirectionLabelText(AppFlowyTextDirection? textDirection) { - switch (textDirection) { - case (AppFlowyTextDirection.ltr): - return LocaleKeys.settings_appearance_textDirection_ltr.tr(); - case (AppFlowyTextDirection.rtl): - return LocaleKeys.settings_appearance_textDirection_rtl.tr(); - case (AppFlowyTextDirection.auto): - return LocaleKeys.settings_appearance_textDirection_auto.tr(); - default: - return LocaleKeys.settings_appearance_textDirection_fallback.tr(); - } - } -} - -class EnableRTLToolbarItemsSetting extends StatelessWidget { - const EnableRTLToolbarItemsSetting({ - super.key, - }); - - static const enableRTLSwitchKey = ValueKey('enable_rtl_toolbar_items_switch'); - - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_enableRTLToolbarItems.tr(), - trailing: [ - Switch( - key: enableRTLSwitchKey, - value: context - .read() - .state - .enableRtlToolbarItems, - splashRadius: 0, - activeColor: Theme.of(context).colorScheme.primary, - onChanged: (value) { - context - .read() - .setEnableRTLToolbarItems(value); - }, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart deleted file mode 100644 index c0eb42bf4a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_cursor_color_setting.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class DocumentCursorColorSetting extends StatelessWidget { - const DocumentCursorColorSetting({ - super.key, - required this.currentCursorColor, - }); - - final Color currentCursorColor; - - @override - Widget build(BuildContext context) { - final label = - LocaleKeys.settings_appearance_documentSettings_cursorColor.tr(); - return FlowySettingListTile( - label: label, - resetButtonKey: const Key('DocumentCursorColorResetButton'), - onResetRequested: () { - context.read().resetDocumentCursorColor(); - context.read().syncCursorColor(null); - }, - trailing: [ - DocumentColorSettingButton( - key: const Key('DocumentCursorColorSettingButton'), - currentColor: currentCursorColor, - previewWidgetBuilder: (color) => _CursorColorValueWidget( - cursorColor: color ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor( - context, - ), - ), - dialogTitle: label, - onApply: (selectedColorOnDialog) { - context - .read() - .setDocumentCursorColor(selectedColorOnDialog); - // update the state of document appearance cubit with latest cursor color - context - .read() - .syncCursorColor(selectedColorOnDialog); - }, - ), - ], - ); - } -} - -class _CursorColorValueWidget extends StatelessWidget { - const _CursorColorValueWidget({ - required this.cursorColor, - }); - - final Color cursorColor; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - color: cursorColor, - width: 2, - height: 16, - ), - FlowyText( - LocaleKeys.appName.tr(), - // To avoid the text color changes when it is hovered in dark mode - color: Theme.of(context).colorScheme.onBackground, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart deleted file mode 100644 index 89e3e18a63..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/document_selection_color_setting.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/document_color_setting_button.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class DocumentSelectionColorSetting extends StatelessWidget { - const DocumentSelectionColorSetting({ - super.key, - required this.currentSelectionColor, - }); - - final Color currentSelectionColor; - - @override - Widget build(BuildContext context) { - final label = - LocaleKeys.settings_appearance_documentSettings_selectionColor.tr(); - - return FlowySettingListTile( - label: label, - resetButtonKey: const Key('DocumentSelectionColorResetButton'), - onResetRequested: () { - context.read().resetDocumentSelectionColor(); - context.read().syncSelectionColor(null); - }, - trailing: [ - DocumentColorSettingButton( - currentColor: currentSelectionColor, - previewWidgetBuilder: (color) => _SelectionColorValueWidget( - selectionColor: color ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor( - context, - ), - ), - dialogTitle: label, - onApply: (selectedColorOnDialog) { - context - .read() - .setDocumentSelectionColor(selectedColorOnDialog); - // update the state of document appearance cubit with latest selection color - context - .read() - .syncSelectionColor(selectedColorOnDialog); - }, - ), - ], - ); - } -} - -class _SelectionColorValueWidget extends StatelessWidget { - const _SelectionColorValueWidget({ - required this.selectionColor, - }); - - final Color selectionColor; - - @override - Widget build(BuildContext context) { - // To avoid the text color changes when it is hovered in dark mode - final textColor = Theme.of(context).colorScheme.onBackground; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - color: selectionColor, - child: FlowyText( - LocaleKeys.settings_appearance_documentSettings_app.tr(), - color: textColor, - ), - ), - FlowyText( - LocaleKeys.settings_appearance_documentSettings_flowy.tr(), - color: textColor, - ), - ], - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart deleted file mode 100644 index 48a761da75..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart +++ /dev/null @@ -1,247 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; -import 'package:appflowy/shared/google_fonts_extension.dart'; -import 'package:appflowy/util/font_family_extension.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:collection/collection.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/theme_extension.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:google_fonts/google_fonts.dart'; - -import 'levenshtein.dart'; -import 'theme_setting_entry_template.dart'; - -class ThemeFontFamilySetting extends StatefulWidget { - const ThemeFontFamilySetting({ - super.key, - required this.currentFontFamily, - }); - - final String currentFontFamily; - static Key textFieldKey = const Key('FontFamilyTextField'); - static Key resetButtonKey = const Key('FontFamilyResetButton'); - static Key popoverKey = const Key('FontFamilyPopover'); - - @override - State createState() => _ThemeFontFamilySettingState(); -} - -class _ThemeFontFamilySettingState extends State { - @override - Widget build(BuildContext context) { - return FlowySettingListTile( - label: LocaleKeys.settings_appearance_fontFamily_label.tr(), - resetButtonKey: ThemeFontFamilySetting.resetButtonKey, - onResetRequested: () { - context.read().resetFontFamily(); - context - .read() - .syncFontFamily(DefaultAppearanceSettings.kDefaultFontFamily); - }, - trailing: [ - FontFamilyDropDown( - currentFontFamily: widget.currentFontFamily, - ), - ], - ); - } -} - -class FontFamilyDropDown extends StatefulWidget { - const FontFamilyDropDown({ - super.key, - required this.currentFontFamily, - this.onOpen, - this.onClose, - this.onFontFamilyChanged, - this.child, - this.popoverController, - this.offset, - this.showResetButton = false, - this.onResetFont, - }); - - final String currentFontFamily; - final VoidCallback? onOpen; - final VoidCallback? onClose; - final void Function(String fontFamily)? onFontFamilyChanged; - final Widget? child; - final PopoverController? popoverController; - final Offset? offset; - final bool showResetButton; - final VoidCallback? onResetFont; - - @override - State createState() => _FontFamilyDropDownState(); -} - -class _FontFamilyDropDownState extends State { - final List availableFonts = [ - defaultFontFamily, - ...GoogleFonts.asMap().keys, - ]; - final ValueNotifier query = ValueNotifier(''); - - @override - void dispose() { - query.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final currentValue = widget.currentFontFamily.fontFamilyDisplayName; - return FlowySettingValueDropDown( - popoverKey: ThemeFontFamilySetting.popoverKey, - popoverController: widget.popoverController, - currentValue: currentValue, - onClose: () { - query.value = ''; - widget.onClose?.call(); - }, - offset: widget.offset, - child: widget.child, - popupBuilder: (_) { - widget.onOpen?.call(); - return CustomScrollView( - shrinkWrap: true, - slivers: [ - if (widget.showResetButton) - SliverPersistentHeader( - delegate: _ResetFontButton( - onPressed: widget.onResetFont, - ), - pinned: true, - ), - SliverPadding( - padding: const EdgeInsets.only(right: 8), - sliver: SliverToBoxAdapter( - child: FlowyTextField( - key: ThemeFontFamilySetting.textFieldKey, - hintText: - LocaleKeys.settings_appearance_fontFamily_search.tr(), - autoFocus: false, - debounceDuration: const Duration(milliseconds: 300), - onChanged: (value) { - query.value = value; - }, - ), - ), - ), - const SliverToBoxAdapter( - child: SizedBox(height: 4), - ), - ValueListenableBuilder( - valueListenable: query, - builder: (context, value, child) { - var displayed = availableFonts; - if (value.isNotEmpty) { - displayed = availableFonts - .where( - (font) => font - .toLowerCase() - .contains(value.toLowerCase().toString()), - ) - .sorted((a, b) => levenshtein(a, b)) - .toList(); - } - return SliverFixedExtentList.builder( - itemBuilder: (context, index) => _fontFamilyItemButton( - context, - getGoogleFontSafely(displayed[index]), - ), - itemCount: displayed.length, - itemExtent: 32, - ); - }, - ), - ], - ); - }, - ); - } - - Widget _fontFamilyItemButton( - BuildContext context, - TextStyle style, - ) { - final buttonFontFamily = - style.fontFamily?.parseFontFamilyName() ?? defaultFontFamily; - return Tooltip( - message: buttonFontFamily, - waitDuration: const Duration(milliseconds: 150), - child: SizedBox( - key: ValueKey(buttonFontFamily), - height: 32, - child: FlowyButton( - onHover: (_) => FocusScope.of(context).unfocus(), - text: FlowyText.medium( - buttonFontFamily.fontFamilyDisplayName, - fontFamily: buttonFontFamily, - ), - rightIcon: - buttonFontFamily == widget.currentFontFamily.parseFontFamilyName() - ? const FlowySvg(FlowySvgs.check_s) - : null, - onTap: () { - if (widget.onFontFamilyChanged != null) { - widget.onFontFamilyChanged!(buttonFontFamily); - } else { - if (widget.currentFontFamily.parseFontFamilyName() != - buttonFontFamily) { - context - .read() - .setFontFamily(buttonFontFamily); - context - .read() - .syncFontFamily(buttonFontFamily); - } - } - PopoverContainer.of(context).close(); - }, - ), - ), - ); - } -} - -class _ResetFontButton extends SliverPersistentHeaderDelegate { - _ResetFontButton({this.onPressed}); - - final VoidCallback? onPressed; - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return Padding( - padding: const EdgeInsets.only(right: 8, bottom: 8.0), - child: FlowyTextButton( - LocaleKeys.document_toolbar_resetToDefaultFont.tr(), - fontColor: AFThemeExtension.of(context).textColor, - fontHoverColor: Theme.of(context).colorScheme.onSurface, - fontSize: 12, - onPressed: onPressed, - ), - ); - } - - @override - double get maxExtent => 35; - - @override - double get minExtent => 35; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => - true; -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart deleted file mode 100644 index d4385631d6..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/settings_appearance.dart +++ /dev/null @@ -1,6 +0,0 @@ -export 'brightness_setting.dart'; -export 'font_family_setting.dart'; -export 'color_scheme.dart'; -export 'direction_setting.dart'; -export 'document_cursor_color_setting.dart'; -export 'document_selection_color_setting.dart'; diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart deleted file mode 100644 index e4ffff7461..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/date_time.pbenum.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'theme_setting_entry_template.dart'; - -class TimeFormatSetting extends StatelessWidget { - const TimeFormatSetting({ - super.key, - required this.currentFormat, - }); - - final UserTimeFormatPB currentFormat; - - @override - Widget build(BuildContext context) => FlowySettingListTile( - label: LocaleKeys.settings_appearance_timeFormat_label.tr(), - trailing: [ - FlowySettingValueDropDown( - currentValue: _formatLabel(currentFormat), - popupBuilder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - _formatItem(context, UserTimeFormatPB.TwentyFourHour), - _formatItem(context, UserTimeFormatPB.TwelveHour), - ], - ), - ), - ], - ); - - Widget _formatItem(BuildContext context, UserTimeFormatPB format) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium(_formatLabel(format)), - rightIcon: - currentFormat == format ? const FlowySvg(FlowySvgs.check_s) : null, - onTap: () { - if (currentFormat != format) { - context.read().setTimeFormat(format); - } - }, - ), - ); - } - - String _formatLabel(UserTimeFormatPB format) { - switch (format) { - case (UserTimeFormatPB.TwentyFourHour): - return LocaleKeys.settings_appearance_timeFormat_twentyFourHour.tr(); - case (UserTimeFormatPB.TwelveHour): - return LocaleKeys.settings_appearance_timeFormat_twelveHour.tr(); - default: - return ""; - } - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart deleted file mode 100644 index c41e5704f0..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_appearance_view.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/appearance_defaults.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/create_file_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/date_format_setting.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/time_format_setting.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/plugins/bloc/dynamic_plugin_bloc.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import 'settings_appearance/settings_appearance.dart'; - -class SettingsAppearanceView extends StatelessWidget { - const SettingsAppearanceView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => DynamicPluginBloc(), - child: BlocBuilder( - builder: (context, state) { - return SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_appearance.tr()), - ColorSchemeSetting( - currentTheme: state.appTheme.themeName, - bloc: context.read(), - ), - BrightnessSetting( - currentThemeMode: state.themeMode, - ), - const Divider(), - ThemeFontFamilySetting( - currentFontFamily: state.font, - ), - const Divider(), - DocumentCursorColorSetting( - currentCursorColor: state.documentCursorColor ?? - DefaultAppearanceSettings.getDefaultDocumentCursorColor( - context, - ), - ), - DocumentSelectionColorSetting( - currentSelectionColor: state.documentSelectionColor ?? - DefaultAppearanceSettings.getDefaultDocumentSelectionColor( - context, - ), - ), - const Divider(), - LayoutDirectionSetting( - currentLayoutDirection: state.layoutDirection, - ), - TextDirectionSetting( - currentTextDirection: state.textDirection, - ), - const EnableRTLToolbarItemsSetting(), - const Divider(), - DateFormatSetting( - currentFormat: state.dateFormat, - ), - TimeFormatSetting( - currentFormat: state.timeFormat, - ), - const Divider(), - CreateFileSettings(), - ], - ); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart index 00383e547d..ed9b4dcd89 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart @@ -6,7 +6,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/strin import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; @@ -22,10 +21,8 @@ class SettingsShortcutsView extends StatelessWidget { create: (_) => ShortcutsCubit(SettingsShortcutService())..fetchShortcuts(), child: SettingsBody( + title: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), children: [ - SettingsHeader( - title: LocaleKeys.settings_shortcuts_shortcutsLabel.tr(), - ), BlocBuilder( builder: (_, state) => switch (state.status) { ShortcutsStatus.initial || @@ -110,8 +107,8 @@ class ShortcutsListTile extends StatelessWidget { ), FlowyTextButton( shortcutEvent.command, - fillColor: Colors.transparent, fontColor: AFThemeExtension.of(context).textColor, + fillColor: Colors.transparent, onPressed: () => showKeyListenerDialog(context), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart index 047cfa62cb..7ca8eb3458 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_file_system_view.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_category_spacer.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/files/setting_file_import_appflowy_data_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_export_file_widget.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/files/settings_file_cache_widget.dart'; @@ -17,16 +16,16 @@ class SettingsFileSystemView extends StatelessWidget { @override Widget build(BuildContext context) { return SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_files.tr()), - const SettingsFileLocationCustomizer(), - const SettingsCategorySpacer(), + title: LocaleKeys.settings_menu_files.tr(), + children: const [ + SettingsFileLocationCustomizer(), + SettingsCategorySpacer(), if (kDebugMode) ...[ - const SettingsExportFileWidget(), + SettingsExportFileWidget(), ], - const ImportAppFlowyData(), - const SettingsCategorySpacer(), - const SettingsFileCacheWidget(), + ImportAppFlowyData(), + SettingsCategorySpacer(), + SettingsFileCacheWidget(), ], ); } diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart deleted file mode 100644 index 32ab4db5f3..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_language_view.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:appflowy/generated/flowy_svgs.g.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; -import 'package:appflowy_popover/appflowy_popover.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/language.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SettingsLanguageView extends StatelessWidget { - const SettingsLanguageView({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) => SettingsBody( - children: [ - SettingsHeader(title: LocaleKeys.settings_menu_language.tr()), - Row( - children: [ - Expanded( - child: FlowyText.medium( - LocaleKeys.settings_menu_language.tr(), - ), - ), - LanguageSelector(currentLocale: state.locale), - ], - ), - ], - ), - ); - } -} - -class LanguageSelector extends StatelessWidget { - const LanguageSelector({super.key, required this.currentLocale}); - - final Locale currentLocale; - - @override - Widget build(BuildContext context) { - return AppFlowyPopover( - direction: PopoverDirection.bottomWithRightAligned, - child: FlowyTextButton( - languageFromLocale(currentLocale), - fontColor: Theme.of(context).colorScheme.onBackground, - fillColor: Colors.transparent, - onPressed: () {}, - ), - popupBuilder: (BuildContext context) { - final allLocales = EasyLocalization.of(context)!.supportedLocales; - return LanguageItemsListView(allLocales: allLocales); - }, - ); - } -} - -class LanguageItemsListView extends StatelessWidget { - const LanguageItemsListView({ - super.key, - required this.allLocales, - }); - - final List allLocales; - - @override - Widget build(BuildContext context) { - // get current locale from cubit - final state = context.watch().state; - return ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400), - child: ListView.builder( - itemBuilder: (context, index) { - final locale = allLocales[index]; - return LanguageItem( - locale: locale, - currentLocale: state.locale, - ); - }, - itemCount: allLocales.length, - ), - ); - } -} - -class LanguageItem extends StatelessWidget { - const LanguageItem({ - super.key, - required this.locale, - required this.currentLocale, - }); - - final Locale locale; - final Locale currentLocale; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 32, - child: FlowyButton( - text: FlowyText.medium( - languageFromLocale(locale), - ), - rightIcon: - currentLocale == locale ? const FlowySvg(FlowySvgs.check_s) : null, - onTap: () { - if (currentLocale != locale) { - context.read().setLocale(context, locale); - } - PopoverContainer.of(context).close(); - }, - ), - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index c1b26df9b2..901f930e94 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -55,19 +55,22 @@ class SettingsMenu extends StatelessWidget { changeSelectedPage: changeSelectedPage, ), SettingsMenuElement( - page: SettingsPage.appearance, + page: SettingsPage.workspace, selectedPage: currentPage, - label: LocaleKeys.settings_menu_appearance.tr(), - icon: const Icon(Icons.brightness_4), - changeSelectedPage: changeSelectedPage, - ), - SettingsMenuElement( - page: SettingsPage.language, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_language.tr(), - icon: const Icon(Icons.translate), + label: LocaleKeys.settings_workspacePage_menuLabel.tr(), + icon: const FlowySvg(FlowySvgs.settings_workplace_m), changeSelectedPage: changeSelectedPage, ), + if (FeatureFlag.membersSettings.isOn && + userProfile.authenticator == + AuthenticatorPB.AppFlowyCloud) + SettingsMenuElement( + page: SettingsPage.member, + selectedPage: currentPage, + label: LocaleKeys.settings_appearance_members_label.tr(), + icon: const Icon(Icons.people), + changeSelectedPage: changeSelectedPage, + ), SettingsMenuElement( page: SettingsPage.files, selectedPage: currentPage, @@ -96,16 +99,6 @@ class SettingsMenu extends StatelessWidget { icon: const Icon(Icons.cut), changeSelectedPage: changeSelectedPage, ), - if (FeatureFlag.membersSettings.isOn && - userProfile.authenticator == - AuthenticatorPB.AppFlowyCloud) - SettingsMenuElement( - page: SettingsPage.member, - selectedPage: currentPage, - label: LocaleKeys.settings_appearance_members_label.tr(), - icon: const Icon(Icons.people), - changeSelectedPage: changeSelectedPage, - ), if (kDebugMode) SettingsMenuElement( // no need to translate this page diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart index a930b55edf..1b3250ee55 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_notifications_view.dart @@ -2,9 +2,8 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/notifications/notification_settings_cubit.dart'; +import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart'; import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart'; -import 'package:appflowy/workspace/presentation/settings/shared/settings_header.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/theme_setting_entry_template.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -16,9 +15,9 @@ class SettingsNotificationsView extends StatelessWidget { return BlocBuilder( builder: (context, state) { return SettingsBody( + title: LocaleKeys.settings_menu_notifications.tr(), children: [ - SettingsHeader(title: LocaleKeys.settings_menu_notifications.tr()), - FlowySettingListTile( + SettingListTile( label: LocaleKeys.settings_notifications_enableNotifications_label .tr(), hint: LocaleKeys.settings_notifications_enableNotifications_hint diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 76c16ec993..93b9db3fb1 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -240,6 +240,7 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/images/ + - assets/images/appearance/ - assets/images/built_in_cover_images/ - assets/flowy_icons/ - assets/flowy_icons/16x/ diff --git a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart index a92ca4a3e7..2ccc3dad7a 100644 --- a/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart +++ b/frontend/appflowy_flutter/test/unit_test/algorithm/levenshtein_test.dart @@ -1,4 +1,4 @@ -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/levenshtein.dart'; +import 'package:appflowy/util/levenshtein.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart index d8639aa600..19c36a8b59 100644 --- a/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart +++ b/frontend/appflowy_flutter/test/widget_test/theme_font_family_setting_test.dart @@ -1,11 +1,12 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/font/customize_font_toolbar_item.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/appearance/base_appearance.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance/font_family_setting.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; diff --git a/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt index 8ec917b8c3..c1a3afe639 100644 --- a/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt +++ b/frontend/appflowy_flutter/windows/flutter/CMakeLists.txt @@ -9,6 +9,11 @@ include(${EPHEMERAL_DIR}/generated_config.cmake) # https://github.com/flutter/flutter/issues/57146. set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + # === Flutter Library === set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") @@ -92,7 +97,7 @@ add_custom_command( COMMAND ${CMAKE_COMMAND} -E env ${FLUTTER_TOOL_ENVIRONMENT} "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - windows-x64 $ + ${FLUTTER_TARGET_PLATFORM} $ VERBATIM ) add_custom_target(flutter_assemble DEPENDS diff --git a/frontend/resources/flowy_icons/24x/textdirection_auto.svg b/frontend/resources/flowy_icons/24x/textdirection_auto.svg new file mode 100644 index 0000000000..aed6289787 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_auto.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/textdirection_ltr.svg b/frontend/resources/flowy_icons/24x/textdirection_ltr.svg new file mode 100644 index 0000000000..9fc05cf0ba --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_ltr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/flowy_icons/24x/textdirection_rtl.svg b/frontend/resources/flowy_icons/24x/textdirection_rtl.svg new file mode 100644 index 0000000000..62d87cef77 --- /dev/null +++ b/frontend/resources/flowy_icons/24x/textdirection_rtl.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b1b35ee0d4..1c4943797b 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -347,6 +347,79 @@ "logoutLabel": "Log out" } }, + "workspacePage": { + "menuLabel": "Workspace", + "title": "Workspace", + "description": "Customize your workspace appearance, theme, font, text layout, date-/time-format, and language.", + "workspaceName": { + "title": "Workspace name", + "savedMessage": "Saved workspace name" + }, + "workspaceIcon": { + "title": "Workspace icon", + "description": "Customize your workspace appearance, theme, font, text layout, date, time, and language." + }, + "appearance": { + "title": "Appearance", + "description": "Customize your workspace appearance, theme, font, text layout, date, time, and language.", + "options": { + "system": "Auto", + "light": "Light", + "dark": "Dark" + } + }, + "theme": { + "title": "Theme", + "description": "Select a preset theme, or upload your own custom theme." + }, + "workspaceFont": { + "title": "Workspace font" + }, + "textDirection": { + "title": "Text direction", + "leftToRight": "Left to right", + "rightToLeft": "Right to left", + "auto": "Auto", + "enableRTLItems": "Enable RTL toolbar items" + }, + "layoutDirection": { + "title": "Layout direction", + "leftToRight": "Left to right", + "rightToLeft": "Right to left" + }, + "dateTime": { + "title": "Date & time", + "example": "{} at {} ({})", + "24HourTime": "24-hour time", + "dateFormat": { + "label": "Date format", + "local": "Local", + "us": "US", + "iso": "ISO", + "friendly": "Friendly", + "dmy": "D/M/Y" + } + }, + "language": { + "title": "Language" + }, + "deleteWorkspacePrompt": { + "title": "Delete workspace", + "content": "Are you sure you want to delete this workspace? This action cannot be undone." + }, + "leaveWorkspacePrompt": { + "title": "Leave workspace", + "content": "Are you sure you want to leave this workspace? You will lose access to all pages and data within it." + }, + "manageWorkspace": { + "title": "Manage workspace", + "leaveWorkspace": "Leave workspace", + "deleteWorkspace": "Delete workspace" + } + }, + "common": { + "reset": "Reset" + }, "menu": { "appearance": "Appearance", "language": "Language", diff --git a/frontend/rust-lib/flowy-user/src/notification.rs b/frontend/rust-lib/flowy-user/src/notification.rs index 4b6792c8d4..a77e0fc8aa 100644 --- a/frontend/rust-lib/flowy-user/src/notification.rs +++ b/frontend/rust-lib/flowy-user/src/notification.rs @@ -13,6 +13,7 @@ pub(crate) enum UserNotification { DidUpdateUserProfile = 2, DidUpdateUserWorkspaces = 3, DidUpdateCloudConfig = 4, + DidUpdateUserWorkspace = 5, } impl std::convert::From for i32 { diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs index 878eb364ae..1e7093a3cd 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager.rs @@ -39,11 +39,13 @@ use crate::services::collab_interact::{CollabInteract, DefaultCollabInteract}; use crate::services::sqlite_sql::user_sql::{select_user_profile, UserTable, UserTableChangeset}; use crate::user_manager::manager_user_encryption::validate_encryption_sign; -use crate::user_manager::manager_user_workspace::save_user_workspaces; +use crate::user_manager::manager_user_workspace::save_all_user_workspaces; use crate::user_manager::user_login_state::UserAuthProcess; use crate::{errors::FlowyError, notification::*}; use flowy_user_pub::session::Session; +use super::manager_user_workspace::save_user_workspace; + pub struct UserManager { pub(crate) cloud_services: Arc, pub(crate) store_preferences: Arc, @@ -708,7 +710,7 @@ impl UserManager { self.set_anon_user(session.clone()); } - save_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; + save_all_user_workspaces(uid, self.db_connection(uid)?, response.user_workspaces())?; info!( "Save new user profile to disk, authenticator: {:?}", authenticator @@ -779,13 +781,13 @@ impl UserManager { } // Save the old user workspace setting. - save_user_workspaces( + save_user_workspace( old_user.session.user_id, self .authenticate_user .database .get_connection(old_user.session.user_id)?, - &[old_user.session.user_workspace.clone()], + &old_user.session.user_workspace.clone(), )?; Ok(()) } diff --git a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs index a6021c2ce3..8e30c43df7 100644 --- a/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs +++ b/frontend/rust-lib/flowy-user/src/user_manager/manager_user_workspace.rs @@ -14,7 +14,7 @@ use flowy_user_pub::entities::{ }; use lib_dispatch::prelude::af_spawn; -use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB}; +use crate::entities::{RepeatedUserWorkspacePB, ResetWorkspacePB, UserWorkspacePB}; use crate::migrations::AnonUser; use crate::notification::{send_notification, UserNotification}; use crate::services::data_import::{ @@ -239,7 +239,14 @@ impl UserManager { user_workspace.icon = new_workspace_icon.to_string(); } - save_user_workspaces(uid, conn, &[user_workspace]) + let _ = save_user_workspace(uid, conn, &user_workspace); + + let payload: UserWorkspacePB = user_workspace.clone().into(); + send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspace) + .payload(payload) + .send(); + + Ok(()) } #[instrument(level = "info", skip(self), err)] @@ -371,7 +378,7 @@ impl UserManager { af_spawn(async move { if let Ok(new_user_workspaces) = service.get_all_workspace(uid).await { if let Ok(conn) = pool.get() { - let _ = save_user_workspaces(uid, conn, &new_user_workspaces); + let _ = save_all_user_workspaces(uid, conn, &new_user_workspaces); let repeated_workspace_pbs = RepeatedUserWorkspacePB::from(new_user_workspaces); send_notification(&uid.to_string(), UserNotification::DidUpdateUserWorkspaces) .payload(repeated_workspace_pbs) @@ -403,7 +410,48 @@ impl UserManager { } } -pub fn save_user_workspaces( +/// This method is used to save one user workspace to the SQLite database +/// +/// If the workspace is already persisted in the database, it will be overridden. +/// +/// Consider using [save_all_user_workspaces] if you need to override all workspaces of the user. +/// +pub fn save_user_workspace( + uid: i64, + mut conn: DBConnection, + user_workspace: &UserWorkspace, +) -> FlowyResult<()> { + conn.immediate_transaction(|conn| { + let user_workspace = UserWorkspaceTable::try_from((uid, user_workspace))?; + let affected_rows = diesel::update( + user_workspace_table::dsl::user_workspace_table + .filter(user_workspace_table::id.eq(&user_workspace.id)), + ) + .set(( + user_workspace_table::name.eq(&user_workspace.name), + user_workspace_table::created_at.eq(&user_workspace.created_at), + user_workspace_table::database_storage_id.eq(&user_workspace.database_storage_id), + user_workspace_table::icon.eq(&user_workspace.icon), + )) + .execute(conn)?; + + if affected_rows == 0 { + diesel::insert_into(user_workspace_table::table) + .values(user_workspace) + .execute(conn)?; + } + + Ok::<(), FlowyError>(()) + }) +} + +/// This method is used to save the user workspaces (plural) to the SQLite database +/// +/// The workspaces provided in [user_workspaces] will override the existing workspaces in the database. +/// +/// Consider using [save_user_workspace] if you only need to save a single workspace. +/// +pub fn save_all_user_workspaces( uid: i64, mut conn: DBConnection, user_workspaces: &[UserWorkspace],