From 29ceb51177010f13cc9e8aad2bed57466af1103d Mon Sep 17 00:00:00 2001 From: Bram Verhulst Date: Wed, 28 Oct 2026 02:11:32 +0100 Subject: [PATCH] feat: New Open Tab dialog with search and optional customer email. Drinks are now grouped per order. Payments trigger cha-ching and money rain. --- assets/Cha-Ching.mp3 | Bin 0 -> 26748 bytes docs/server-tabs-source-of-truth.md | 39 +- lib/app/router.dart | 3 +- lib/database/app_database.dart | 35 +- lib/database/app_database.g.dart | 232 ++++++++- lib/l10n/app_en.arb | 16 +- lib/l10n/app_localizations.dart | 60 +++ lib/l10n/app_localizations_en.dart | 38 ++ lib/l10n/app_localizations_nl.dart | 44 +- lib/l10n/app_nl.arb | 18 +- lib/models/bar_tab.dart | 12 +- lib/models/closed_tab_item.dart | 6 +- lib/models/server_tab.dart | 14 +- lib/models/tab_event.dart | 25 +- lib/models/tab_item_purchase.dart | 10 +- lib/services/bar_tab_service.dart | 205 ++++++-- lib/services/kooltap_api_client.dart | 4 +- lib/utils/history_grouping.dart | 48 -- lib/utils/tab_orders.dart | 219 ++++++++ lib/viewmodels/bar_screen_view_model.dart | 115 ++++- lib/views/admin_screen_view.dart | 17 - lib/views/bar/bar_screen_view.dart | 307 ++++++----- lib/views/bar/selected_tab_details.dart | 104 +++- lib/views/bar/tab_panel.dart | 111 ++-- lib/views/dialogs/close_tab_dialog.dart | 17 + lib/views/dialogs/new_tab_dialog.dart | 63 --- lib/views/dialogs/open_tab_dialog.dart | 328 ++++++++++++ lib/views/widgets/closed_tab_card.dart | 233 +-------- lib/views/widgets/money_rain.dart | 135 +++++ lib/views/widgets/open_tab_history_card.dart | 127 +---- lib/views/widgets/order_divider.dart | 77 +++ lib/views/widgets/order_history_list.dart | 105 ++++ pubspec.lock | 64 +++ pubspec.yaml | 4 +- test/bar_screen_view_model_test.dart | 106 +++- test/database_migration_test.dart | 16 +- test/history_grouping_test.dart | 35 -- test/money_rain_test.dart | 34 ++ test/tab_events_test.dart | 3 + test/tab_orders_test.dart | 509 +++++++++++++++++++ test/tab_panel_index_test.dart | 45 +- 41 files changed, 2771 insertions(+), 812 deletions(-) create mode 100644 assets/Cha-Ching.mp3 delete mode 100644 lib/utils/history_grouping.dart create mode 100644 lib/utils/tab_orders.dart delete mode 100644 lib/views/dialogs/new_tab_dialog.dart create mode 100644 lib/views/dialogs/open_tab_dialog.dart create mode 100644 lib/views/widgets/money_rain.dart create mode 100644 lib/views/widgets/order_divider.dart create mode 100644 lib/views/widgets/order_history_list.dart delete mode 100644 test/history_grouping_test.dart create mode 100644 test/money_rain_test.dart create mode 100644 test/tab_orders_test.dart diff --git a/assets/Cha-Ching.mp3 b/assets/Cha-Ching.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d204198a63e5ff02d893b4bf9ac8c9acbb02f4d1 GIT binary patch literal 26748 zcmdqI^;aCt6ED1rF7EE`?iLnzcMI+gfgnj(+zIaP8azQlaCZ%sAOQjdf8|RkZWVa}M9_aks&8y;49~IQ>3^rrzMjJFUV?nUW3v2z zH~ino*xADoUXBA#{2-8%DF}>$0)b#-6BCn@)6z0Av9t5?3JFO`DJZC_YH1l7nweQz z+S$3d`1k|{hK5E(B_?KMq&{l7XooS2crsO@o>xXxcX#*w8ob{o~z1T5@9p#acX)nD@<*9$Da{exAHZPgl! zgdd7uxPY+hZ8F!1MDT&c+@{~hAWo9s|E`B_+1sed;r zq^WIgF+wLi!e^FOlvhy-_g42GZD?sFCn}5idbG_oYmtaKF@ZQlN)-j2w)spsU6B#L z6ZsQ#m6j0?WakC@%!Au?SZ(s~! z)PrY<{Da-D45^^a*51FDpAW^dhpm`N-%zo=Q76*F$o9P)sNzs9iks)aRn{J4mFIH8 z7@`g7%@lL|csoPV9*oJx*Sz7q8qq%L_4lgipRXnG5BNEF_2uETUIc02z*VsfGqlYF z0Gc#!73_rN{gj3DEtq^*z)%o=5GjiwZg*W6$dq{LQbBxfZjb9`|FF6c-JzRiFYoyw>EpW1T=!GbuOBDPkkB}b@}v{{l;(>SAEP7=#MAp5KIq?l z4|y^7{0RYyeitx3e=w}qA33S5 zzYQE^JySag`45$F3IVSEmk(m{Xzz5mR92|k)dpRm>DXt1AxxHWE(KmbiDDqX{4E~xI z%jfvo_d=)$z%TK*)b)mb@tGLs855)v)rhywz7;yQFp0rX3C2(=Q4zn&@ot~auKOwlqiIB9SCy% z-kK#Kf8FCvlJelHyKiI&<&`O!sP03Kt&bk@I7z|^w+iry&ru(ofAAmYgq_q4Yv()F zoBYu*7=n$k@o{gIK8XY;+2_}=6*-zL)UZeRZx&`=LCZ5n7XK>HTHKw()r6q#&D56B z>$swO08l&6HMiIIAs@>|r1it)wL(CCE5=J02Sr#sk#wZC0e8+>nT>=Ky(kNPDnC(L z60?){MRv+TVba8qV~D+1f#y(<7tSA-_nNZtllU)V*uX#*A0Lge zMtv+is+ycsrPwNSwcDn2Dn_1>(~n;TnTQp?7*BDR2wS6oU`W4#rXa`&r=WJ(38mH_ zmF6~O;e#;MD_au56Q|-KZ=LTw4+iS*25#cWZ}$&uT`7Oge$cy}?amW(xjMDaZ(J|9 zpW;8WF`MQW57v?p>|OLX3uij2xDoj=;#|<4MOP+a%75!#V;)UecXl0E4Ehn10z-lc z-3FEJ#|VB7Xg3dr@;*_{u(b=V=I8Kv!jbh%^R%zVUt1C*ti#w*|BEo3(y@}9sgyfc zll6GG3PM^}GE-=Ch0Qa{5P}F;jJr)9-iRy#U+YK3Y2pCh8Su|ClRIMopU=>2p z1wtevqA#cRtj1%+3Q1u*6g(;dE zQK4&YGFf&@`#QNvuN3vT9<60Rh_xukx{0p)aeIZt zNZSwzz6n{Pq)(O&e^ickF`1JrWr;@x_d&fqtlYEU8(xNrMHQ$U##`X6 z_rF2$HG$y%vI~leY508r%o%6WqbVyGiTv>guB3sufj9iU04oTIw1$uBkDwJl04#Zl zXqyW&y=Z3QE>DfAGQ!f8E-(}R$&%{B#*pQRr5F(rK{4X&rONbw3FHD3s^s%O``YEN zzqYZ98C&bntNrfENLtt{N2r#KGTN7#Vo>T^4(i)ApirE_Ew)Zy(DB2mj&vS5_;#8V zyyN-#UjL;Fw2QWl04iq3+xPgs8A3Ltn$+;GtKIpX z>&k0he61I?UdvADX9`(TBfpRrTtaH80;(9H3%F&P{MA3;EkCZBH;Z{|oH`Oo87py# z%N&uD-L_m~>6`@neO9OIOQz!W2>NN00>|=YFt7M<8k(c^zszg1k2amS+ z=yF84>l0#BneBNJ3n>KNoZYh2W&dM>I2Fbu-Ni-d%ZqswkvCW>?JOM8&BE;1&qF;p44-R)jJ_%244!sy}P#eqqCBb5PX2s-5Qc>W1!pGNNR9z=|P4$FX zOe$M=fZGV9q5chrCd)GIp{mpY1G`Pk&M{!%BaIQ!6sqr1fR*=tSlkepU=4mc5i~EtRu8Ls>F*`y`aiLz6&M!3}QNr5aKA zjMtDLYrcK{Nu9Nm7iCV}S|@d)S|3Vd)r>c&oGGtGI-N8`YG_aev7>2IrqvAR=Q|$u z40wF~!LL(KH`&spTIKv(^V%DYppyU}w#^#Gx0@E9ZA>p-N82kf9Wi})U6ag6EL>^A zDbM_K5dDZHhZZ3@0tO-^yd3`9r=rsG(TxM?x*27?2Nh2g@E7;EK36iDolb&jyJ9S4 zy5mTo4>pQNDIg;Kqo(3RS27_6o{^Yj2nU>J>-H9=VHX|y&32mSgTw_$t zfVQC))DXC}$ou3==s39i9cNKYSXj_O*OxfsQhfXV59i{G8I76vhi}fNf4)~m1)V2m z>-mS0TWW>ct(mT``W=60?Y&9!2ri0SaOk-T7qY)!qQLt!^waR0)6iAG;T;}zx?MlZ z0O7;|l~O4?iMlAlH0hxS*MCS@2O$gDn)za?^;wSw{gkYUz^9A!oGPD>6)*Vo^(OXQ z8hLP(nO?5I5?EXRW^-Tmd4-J4=)I=~Lxw6D#bVBDn~-UdvZ@!~4LSMIwj4w#x{6{TT( zB^GdG(N1ATJW#jp_wI}R%7=z=i)lTAc?uLhj0LK`<=1x3Mz6mycS=U3Ygc9FMwxKs=hx@p|P zgkB`kCg^!VAgFvvcnC5`9pqK%jCp+Xk1segV_z=(?SupF^NX*`?%S6yKquHh9fU&` z)vsH>gCuv@7e^IYy*8J2-ja|G;&evM`?i~cqSwa685^v%DrH8r$wXPEr zK#mJzCZSPKr#C)c5G>IsevIwn%0x47dBEuU(D3;qi6kU@Q$sz6ktSsNalwi1I6R?S56(scaELP%)$qe z9sM;}7@L8(He0u$o)16oba}tE zHoZO}F=tJ92@=LDM+H>nsS9Pw13e~XZC)lKnsE_FIYexx$fUyA*1WZ$7`8j$se~ZR z=hZJ69k3aS;=Y1gARp)5IgDRYn7!g_PQ)a3_Dw2Y0P} z8@|JrvL#8Wd!@V{J|lh|I&qXw79+Pa$x>m!DY>5qdwCbs{b{1Uk*^c!ZKBdF!yx&U znn4=kuso{&!Y3^m!wn{f@}NP3+aMkZ$0fDBmRDss55&r2=dMIv`uR+ae)zaWndQ11 z)54eaOVTTnb$?g27OWL31Mm~Y&bgGd=>+jLdYK*t@QOe{7~Kx755>#OTQHG%xX=)A~BNEHzW-0%vMnX zD-h#O717}Py>s^+sGEiB9#X?$HsymUAEZ?m=zClDSjz+H#+Q)4h?;L2^YbQOD2jm3 zjrUwOQ>!VYX&O?&#IWqYA&(JoXHv1M~Ic86TVQi?=+(yxR>X& zCxW;wJZul^vs6+PRa_F4jrGfRLkH^t0K~u70&Z;LItMSCM)goNW?;bC=SU8TrsIXj zSbql}c$MPFz0r)%`_ZQUAW}}yS)&nB`SIZkywN$pe-J)}q++S((KbJw^OTX{e4Ztz|OODPC-USHx?XJ+i4o;>i)D&yYYh3vDk6?$4b ze9Cu;yYW_e=+ZS|&{{r#*AqkvPvkBri$rP*u;v^a6g=!_%J$ZoC!sqYgHg*+Z2~6H zjzMV_5cJdn08h4UMOnXQdYtD_bl9`*1zLu-ICqhTamYd3E_k{tsfe*U+kB1wAzyo0 zl0b$q6{qi}X2?10IlIZ&Bnh215MpLk>>SD6hc; zRvfGd##8s5Vo9wN(?o+`I+b~OaaEd@ZFw~-A+nu>xY=DYaqTGo^aw*)8Z%YVvg6Ih zch8Nz2{Ek5cam_Cw4j@PTM*|qOCxy&K3v_hP^0mj$e032MgM?j#O$81GR3gV>he#h zI+NPI^tC6QpXwgw7B5yS{+s1*AR1WOtY3*Ld#%^2N;~o|((#?`^d#d zi6oyx!F`}c8vg2zUm%Hi(H9#LK0br0txujblaEorII1@yirAz=4$d;5K+}u`_tb(s zaV?c@$BMN_W9KaTX=}94v@IBB=P@xIP#-c{Y=V+ho8u)w8ngLk^3_mTQ@a4^s&S*-vGG!I>!vsL{tOvRxK_pOhr#Fj-bwmr{in9y4%c74H{cin zMVZ~tYbp&IHSW!1JlYc*|1_W9SpC`! zynm!GaJ4#WnjF2NkIt3BsM6pq=K48nnX514941{x}wYQaQP*jT@szCzFPc?6Yt1OSm`S zinI5>r}V$GeF>%2kIg_x(EfpJ`PqgLhZs43^kDxJl|Dlv%uKn}Qnk;WE#2CYM0td_ zWjlqveF=E%vs(fYso87<1Bdc-v}&U_yq`OW>w)P_6k71WbNy-aZu{d$d^nJK#o)n4A2* z3;=4yV;PgO)TZHRmWx*ao026t?dWtHJbz7ks+MZ)V@vO|KP67kUgkEe`x6mg5xuzQJBCM66(QSS z3r{X?V7u2?@B)B#|Ede!X8;vF~f>`9qN;4^r8M zD6yf7gu`rSW$EpeBBFE-1+YcKLE0lzi z#8&mLpMMU;@3>g}gXk$R01wA3iD+`$#{lp*w1=4olU>!LN(CWMK|XHj-B@`Fq{EU| zK!{p?5l164Lq9e)zoPuG*ez8}<)8{PiFFO$i9pCU_c?pnt9xNF2*{!!tYh8T^+Dr3 zXb(fsv7Ce|LN;Pymi8VN!uRWhcuPQODPs)g2t86nZGd}W6iB6l>c*%nS}t)`bpAwwf9Pf z{<?0545(=VCvT)4|D*=mt$A;9gcak6+e z7p1!v<({c|>xIzRK>DFBm-Q*7{<5Vum2#&RENRK3!*`{4aCyTpOh76E$L*Y1m^z+l$kztnh{_4s=Zi$osoJNFKGq78{M#?{&OF! z5YMp$uEGUPnPdO94fXjb)8cuZ$B9(|zzi<&e}hJ*2K?Zw`{zSW=b@gQy_}a9hzPeu z*?}>Z*6G$VJw=IVHzIy&x|lwVJ^fYFt$V{hg|h|~MY5vPhdqO*z*?Lb{x!yL&Z?$Q zOY*DB%(mvgj;r&yL(3uv1;s`h{P-;xWYPSmHd{cAP-n{a zody-j-?HjlMs=(3TD%UI<+!4IXecCi*IJZTl>$13<~)yp+Hdjk&4HO!{; zgQ6(-S(BqUzfp|8K-H(LY@?Cip8sqdGUkNBKKbMBzQp!rxQDWc*l>#gGf5PLyl70!RF?>i=Cx?tOp-v*vLlsr` z7z%7%J%L_eV&q$YTtDb(EU>k)cCg>T+|t zK#a0wvzjAJZ&X(a`CX;v2JuhCE3CHLDP4{H>G>qTeVJnMqCNAFz9GZ7dG<5{bOOk| zKIc330xvcejJl%Av;ojoktO-9%t-EJp1^FjoIK7cz4M zR8Zh`%WD`s#$K5Ni{*SDbDktet%pG?tCgY~Xqv@wcqBR{10j2sFb*`a7f&Z$;tsSE zg9;rKv?sn-b1ryL4jk@(DvH%mV~2VUMHvuP->aVJO;CL%MFwY^ERdco|EKO|Kq&5x zx~>vs0p6G{AvQ`4#wtZ}>OP4(#mzN>_qFe-3y-zNb$qh9lHy%+=yPJuT%k)dw$bx|?jZ>uDUa2^ zSBF<_dTe++EI^yq4n1=UwbdwjK8t-hmY$8(3L8`u*a`KkNp5E=f(>$cI7}Z%Ph90t z;T;xLVOfj7zQ@$IKc>$_p4rkekz%XUFT840bRTEdawrId;1LE^W*9@Wn#e~{H{?aRtVtu%ZYH|y zD`utA%r|SS(9{|+%!x*3;X?h0T9oT5W=q_YUMaD8W}M;K^mfE;qRQ1`MH;yNbdgD< z$P1}#lTf!%A$oU&$8#)_y;AD*&D54DV@@0&Q>ql%3^v3dzybiJtvX2IJjLxHKjZYu zi@suHSwvJ_zp#l7)FPe`r!-H5JCj&}z8^%rYvZ3WxwE@*u^?WzwQ@IMmvX#Z&we zr_{z0E(GCSGx-2$=Yea1=Jz;&W{aYiynF-*e8DgrpljHHdoBRB;Ym(B@u6U5;Lx_4 zwo%)F)Z0K89%9akA!CLAsjggcdGS!hM0l907x-_GyFozKww?b&Sr%mB zB5q-wZcm^RFLUD4O2=1y^mLzT2LPAYGabLm@j+JS3cA!-+fr!BK4PIv`Gm65xX4hm zI#G28Pm1>zA|{z?d-yS3xj8{cduOp=xwfTF+n?{%D~~TZfKR~L$)alk@Z`$qoO7BJ z<73#puyDyHvhcyqoyJu}NIr+qwseEAxdvv*aE#O*#;64V4~o*j4V%{8N=0r;v9|K6 zPAHN%v5_6JnI3?nBFOg8Sb1glAjT@fjW z;gV9jpH`5v+UzoYmj5q|Q=>J9=x~!R7M6pVXL6KbtC3_0Wa`%4_ewqA^>lj7{*LyV zDQ{SXn&inwtLks%6&C@=KK}V>{ET{3)g*nQu0?2CC|FYPM#XQz`PO>V{zFkL^G3oQ zf+!aU;(`?4+-$Y#hI$I3ow;}owOqdf^z<{PI@KdH6t>!p_f`9#yvQvS3)~-HVpCOx zG-6xO{SSqZ=6K?unD1y!O>(Q@>n>hGQ@*yS&FJGz!GdMrFn)n)`eZmn+aSkKa%0_7 zp!wi`rK4*=(s-ptkU&?b<*xR*j&hBlmm|Pp6ru3H$lV19Nm6&-m7*+2Z$ezlT7V0u zLPP1qBk|~c&hMT$hc~!Q_IrHua;D>^_?rp3N@dwONh*OfqHbe#$9yVi4#D#Ol)6IlPf3!Fa8!n=UYITsj0PgM;%yW0yj#khj9TPb zQiQQzKrkxD%Hx6O0^}h1)lO93)RGT4* zMHYjI1QvyYtW^}x*q6{MmYN5=t&El{9?(tzC%Iq<08|$(!+9>nUX?fwcUPMjQQ`$GF^Rp?3N`|f|`)_);24giDk=Mnay%>+SXl@tAtuiKbAau z-iS}@yus`dL~9anQetu^l>SjO*Z25eS6zf5q3P;8Qq-DSql?qPyDE;mhQf(Q9a}<9 z_4^!~@)$)%!YjXW|9xp25zIQvcNq!rneM793a#=Apjp5XA&(@e`($?SWAz3d0stavrwIA+h zzD3X?@DND1&?R1#&p}~|ww;8%H_I9;p*7w}#PXP=ewA_KY%!+A3q`=o(KSX^jmASx ztw>H~PUm|OfVFfDB%@QxmScd zmQnuT(Ln)c9Nc)ps$CU_B{UDE$^X3P2OuOWy@f}LQd2K>aUQF-3SudHg%cx!V29Z0kroCY)u1Un{3FJ+oDj|taCM0 zVwG64-5hu46-vU_j2~g@0_n2tnXXULQL{kreHQWH8}vbu(UP9d$8^hr(NOhc{1!%t z+Y#e&k%zLI`@!8emu!3_U$CgRR)ZhfIVhjzlI zk3O~G{J|dQus#s-Rw}OPIWq#?SbKb+O_f~G6Vu{E`Wu&#e(v5G%nuYlz1!zX9Vr#) zFtOxZseA#+8yc8pv?-ffM(ok_f=^n9udy|9D+7cFlNW<$@k%|WmeamS{|4RdnCh?9 zTMABW@b19+Fsgr8G*>NH=&-RNpHp({G-vGcdV|%C1&r*?h4tmYL6~sh!`Hv5`Kr_h zdiqGVc4`h(J3E@?1Q8b<>T|e-hAl$=>vZp1*G+Jk|3@ zSGF%F0PNrX5%tyZQA(8lKCuE&!hNRl7a(R=JMBm35YpMHI4tjvzlm=?>YdO%l`%IT z^_2If2lxIZI)*^Y#6s|>Q6c9)iw^YAEs9W`QiSxiDXBFd|1O;^cK>KLV!CQS^y+_4 z9%S=T_)ILN$2pb0R3gvT_TAe{zO}^ZC)WLc#P-KuG;6FYJ~vv+E+3}_jz@B@H1*)y z?H&I^-|G%KvN;U=w~RcXbKt_XuMXmfM8L-5En!`1!$rfPA==J5H4t5)(rOyE~qW z&nz{bGG^yPjLK2t3&Nq{v1P-Nn4$ghPrubg0)3Ux)`MLP16aD+=<0kA1k)?6R8YDD zmiq4aCo54eHhxNP1n1q&ul3IetbDGBTK^p-$lItys~I_oXXU9`9vdF7@1tEJjwA4{ zi<()7!c~(&YzLXBtD(Ny4lx;BY_YuWogz5(2)v@cCd({{LgfV=$KMWJ_w{yRE5rBS zh>YsRS!jvUUWMS7&J0gdR29`Ah#+jM>ENv-a@YdWxh%g2C(U_XR&VX%0`h0z{XoLK9zr#icY9V%dicFUs=DI!>;!$1 zz}%Dc?Pj4O#{?t)ghoqj$CA!L*sg96v627MCK@F`vz31@Mo-pEs1~W1qpb*L>&T!t z5P)PGGaaY%I;?N1lqQ2d7+`rslaKudmLikp-^i&y<8@$COOn*O9H=b$HUgXdc&}{XdKWmSCTt97{D<9dk;zIag=&-5^g%jYtp#PQ z;lr(g`a)!0qSp5wbB9fOMz%TQK0Ibv)(&b{6&p9?tbO-oA8(cr#8 z{%8bp`>#nVUGZ^%HLfy)Dsag30)sIO?C`mHm))7pT{4Kk+o_A8pI5EkmU4 z&eJy%Jz?6snP>X?4MHLlBVoliVpjCY3p`Uf%S!LO_&XmFffzEQeQ(69IGOb;%D@?S zo4S%l*ZiI`trxu+n%Qsl3Up_DRYP9*~nch0ILU{CI<8yt@*AXLjgBPolnJ2dU*~`K+ z6vpW7Py5^ao%u@bPOaUp%fQ#b5+xmIwU`+s6PmTktY$X7s=2%BPg`e`@*v?n-n(_4 z!~i_j-H7$aFD=o~J|_sN^3GMS>XK`hcw5J*>k~obJC<4l%Z>$6qsXm)`7R`0ll0z5 zD+&NFjewMfRA6KEuW8l9f*+NBY5K&uGA#CBUnL~o>tSBr@PbsUM8-Z4&xWolBZtnI zU-1sM7}f~RhvMtW~P*zv$yxvo}#4o`NA|*}?U$77=7r2Cqeir;Q zpQ@uaiKqBTy^>9^-z7ZwmoJ_o4f5M${mcKLy$eGgw>Npj)Ua{*6Hnu$Y?j399_j=DgqEFfX3=V-~aEK@#G!V)ff7iceg~ z)BQCg{tb`g-)5E@M@f-E=y1e3MUq9;n+cY8O>GfSIzPVmuP+-kAT!xRw2tjp{_*oC zN@$ZW@KkP(z;*eipJ{mR{6oHZn?V4^^0$xuKpKUlHKJN$oEmlk z<~Pv_TTw_XEiu!|&(7qs`*k9HR`j(Dr6I6HI+}t@9T}TQ#DakcA8lSL#fRU{_eA%? zI)cEtI~Jq@F8!xiUeHxtfu<0tqQKG;Syz2aSI&|4uj*LHHxAQC`h3Hzp}FB=+F(fl zQb%8!wk&PfFF4auUgbT@^|IhTUxYgp6Gj#Z-+6)sw&J!k0U6+0xguUrG5so+ACgZ{ zbaLWXLRVpUtTV&V-Vp+x7xdoe@3}I2RVrN%hay&%Vaz`5d!C2cUF+&s6<(MsHf5Am zfL^|H1jvd^*SuGpMml_D?}63iqWT3B#HlqkQJMoo0F5OOL6>tC!!?oOkbLAxKm$m2 z{)Oq99PfG>?9SSZJunAu81Xk#E|H_2P6xxY`FLyNz9O%G`Azirj zRI^a1W8I(ow2XhK6pIf*6!(c!o-cTnVak&Vw%#2Vpi8;ERp5!15iDx#1nS+o2O$ZuDiKw_Y9MaiK<^`XCs)=i{C5i@~Zyw zn;^nUjGJ3cYcf*Te$&<%bkX_ege^0@$N?isC_)oMG^)n;&lb=9B-M3Pf1Z+z4i1%mgIaYj6pCyfdLkoP*8tn ziy_L7wN`Fd!|=q=wO94dGzHhLA%(y92!Z<%4ZQX6(o1go+*kCqSjy3q&ze8v2|sgC z09T}2S0TXDcE~eSSsx2Jpo#DnjIVDv2*YAD9+aY8x!&kLDQjs*#_1L9>f>1qHDhPL*-~Px!HnHc5z1RD>^or$6 ze}10^xxa(tOoM$SCzgkv2f}2E(+%Ti6j*EYfBJg`j2vigbj2fhZtupSWw#qbS&*o= zoDV{1Y&!bZjtgQcV*hYhlebEazJJ{IeXpJ7yDA6%_vw+6Lm%w&%;}Bj^{2-hK9iG^ zQ}-Sb&MCb?pF$8&@*k2i)wV*xHH%-S}U$D&+Ly;}^0Yk+f4; z*ta$URoJ95eqt)49J5w^OiV5{jNQ#=6{GxQYiZVLCD4hgAeA{Kn zc)Z#v#VJrcXVCCAf2x>_5!ST_QBELL&kl$5z&2Q|+_^X5zh4Y1ITP07#6q<U zxRufXclps{cr0m~B<>1#IJbRif6+$NPNs(`l$)$fj}Qx&9C5X&kPWAQ9c4C-j@*I` zr+gU z&juk~i?&rsUn+IPEq)t@#=p_PuFFnU*bOwD7})u5Ma)kaB+`EEgC{Mh$*qohS{{LR zJk9sM9wfWiQV)pmLdbMJ3Qosmjm4E^ia;}w2(XaCoxJcq^0O&jiuEczwf82ftTe|&=AR#GZ$+0AJYQYqsIqJu6B|)SUxaN{YgSxf z`2U&kjZ;07Yx#5wNgd?R8qArf@R|yud}nDCCHI8CUQ^P~Z{2NXtR?k2$(&7^@WsPOO|@(mwd2#{OA#+M#|ab;|Tw z#&8Vd)k#djOcy1UceLJ+91-Qd`9BR#!m5b+;l?Yfq{Ynrs#!S6=PRg(G<04|%LwNP z5sEN*tAc?c!cw=XujO$_ChLw4wP}KgCZup~`(p0{lX3r9c-q zKsQV{83NP+en39mFH2LdIUiQ9yH_?cs$We8b_qLNQuxfj=)NAq$z)&N`0Mx9Rxp{a z$|$sB%veGW4Tgzpz_YG>Y1qo4{Lo+zE%#W2!V-wE+pL`sKywSk`YNgX^ftCSd1x|} z`e~hC8gRJ6g)9wo3DNnOl)5VVPrqT*{#KS1vA15$8qW$NGb?5$;PhI1B08cV9%>EhCBi@NV}SWX zWCOqb5OS8utyiYdS5u9^53h{4KKQ`o-Fg_3_CS{3do9ip>B8}wBgI15DC?FFhHsoW zprdPkSF_v*OvI}L0G@}3goGH_DK)i$c7c~LPfnSkk`9kacFBZdK9*X)pJyv7wbi=P zYg@1F^8uNSLvDu|i%yQcmz4cx6s4)Gvx}hc!ki}|UdsI%`U!1qtsJ9oT7}5CKJN$% zD{Yci5r6Yj?lRiV#*pt8qZpgwJdHuz)eVN8aM^z@xkeb$FN;mUnifHI*j4ic0wM?u z^**s@2<4}6f37F27uq5V?(nbW)jf#yTw*GEHknqSEOA>9bePhCtd%D)C1t&A0gwFq znC5U;b%0=UeFT_O2{fwCxDslC->8w?pS}cN)(?s<>#tH6hg&@Rh8*AMwiDj_R>!9s zD{uX44S7Hh(zpEG$x!qXa0Wc9K2K%6RS;ff69Nw=)h~~dWAxF2AOZF*yu5uoY6G$y zn%M@-U3ZQj{JK7WcbBg*Vb;rF`>U(qAT_T|u8q-`I*=rqnqT8an8GfsF=I`y#FYa? zbe*BsszQFo>awF{r&_2V)@i9UT|7Fi9n6OkCM#z{I}FZfdp(L3B9eaP9<;NTHSmZZ zb01HLmA^^eTkR(t8NYQG#A6th2Z0%Jg$2K9Fm8jvL;ecJO527Hf1WJN6ebEao@n69 zh>{O2Xuf}WUaBojV)C9qq}>T?1tzwDFCZEE-d*pl)>z=h5qQebkQD5ysn&&7563#m zYU`=DU>7G~Rcm{|#9Yv^)k`#fe4kI1ZjQ}NL+$k#;(L|h_t(*fDULN431Ls8cyBpH zj&#rQ*f1s`B1Mr%j$ePM?Z%?|l1nh@3-(Cu3>6jI%Dq@OFmC7j=XcH1P)%*(#B-}( zevRj=BFR?bIDwQ6A_NYxmYt+D1FNIbOoFnJT|D4#ROUFo+%X6~?0ubH<|{3wRpsBL z@|-N=YM%QMI@Vfk*K}|HtbKNUeTf{6$>I=)fy@1B`SqAJP&J{yG67##uD!4?rLa($ zx`R6k+@HO~)n?f#=`*gfRggipqoB?z2@Yl7{;F0~vxZ%g;)BbN%#+{Af}n3hDpRSu;oo;aYnZ-qAW zKGsUncXr;QskSzfv8|lGPY|lRJJnq8*ojC@RoWu-s-bw?uZDsK5aByUSVD6*3UXyt zTf^IGJw>%DHZpavG83p2Myba_U)FEgLU4ewuSBJg)JV{v1V5scy4r80%W*G})-WU% zE8W!Z?`U3kmHc56ai*|&GC^z1Ik;^Nr-u>k6g_5# zHY`Z=$LO~XhO($+Dohl#aj_@h)+9VskUew}0pZI+U;{n-w!&NX^jD+ zo{dj5_YWms98Qwg5|^e^^|_fO$UCZUnm#e;v1(U070bW@IJE?3R%WhyTC-POa)Lcc03M{jz>n8~^x-*;^??*zBx9Drwp>tn zWE~tp5l(WQlu@5bBvSMv3x$dc-zv-WMYr|~HK}+UN|Ytu9J&v+?c3p{Ucj1LNdCc& z1e3g@)KtahpF1KZ&w@=C3x4%)Tp}t#OnUzRknjv5tTi_c#!=P%|C;;Cpti!O&ET#H zPH_wFR-E9_;_ec>Sh2Q1aCf%?#kD~3mf}|2rO@Ku7AbAfyJ2T%zuh0-?99&Y?7lxT znN03Y&U4OvUOUfugU4?yqth{G`KQDD1sq!uUoz}^&Cb4`HK~kW4sp6 z8)^d6x9i_biabKM#anHDo)dWiu~Umli^_mm{Z}CG^GfEpbnMxnf+>hFUYYQCVvgp4 zVn|0MkwJ`#y{aC4^83VadrSsQ^b9{+L57)L1=NyS^^tJeAvyx!8#7l6f49uFVrluxx~Zt zMD8?x*!&J=GE`wUUjh0<9-NIDbZ<5<&L^~znJ+UqWc)^ybdUlXN4D9wEJ;LWMYmm`T0bbsguy58xr$ z6mw|7FrEoN6V0uYeM95QNfn|@{hj-g#p7_f|90nu> z6<-~5*LMpPjfy@OPNc<3f)7z8-Gt6Hs+!_u+Cd3MyU9#oD8fbJ9&r?W&1qqx)`4xt z7MIoQx5o|;1C-WL^1O3)D%IRloiT?hXor?bT_`dK@+`7j+C_G-Eo`7{)^_Bv7n%T} zSDPC9Kt&X?CQLTq9z9@@XuH#LGUuX3XX((l5j0lLXuuSa?D2{leojfE2yddusy(CW zz{-eKr{spR@wE_v<#a530WCYqA(oK0SCN;aw2?;%i+N@+ zlS1TEandu6{fQX+r)9h>j|YT#@ymDB{_SRY+ucdOG8VFQ2DRn3f{G>u6=r?X)qo~+ zVkV!L_yXy*qpxXI4_oFZ-(gSPUmmfA2<2b3x9`E2o#A z+l_Iz7F7~s<7;>lE7d2h+e`aYC&`wbqYx8&f)Ks>oC2A%yPqWa)3LAzm^TOc%GRA@ zJJ4K2b6gH4I-Gf?7FD*L*PXK;CXu-npFfKe);-0Q4+eXmc^3h7aK6ZO501p?%p}8E zO8Q~Ol#QK^x4gLf+6y)I1k!dl)sOOKROc-a`f8Bfa5)fMG@h2VtCaFr*~A_Yv5l%Ng>m26>z64S#~7W+i>aVIispxAyLgnn$b zdJ7d1beo`-lqEhRCW5^~hs;nAUpUb|k61E^eBSFj_MP{i#P zzD%R^A#N_`LrdrA^>ra5%+7LfinNKxl8bQoC;c?P^5A<1>Nytwzuu#?VH$jt6nake z-m=@k$I?VStqtE0*b1oGTyN-TfX< zP9Tj)`GnI94!Kk3HP%9|5+0;xxFan@=3>APzGTVuP-at-Bm=t`mhVz6))#2Kg{|T! zTi_f=h1m9GX_*Rp!U9a&3dB%UX4H$(Cu}>mVoJ7D{h0-$XL@zMg)-vj&A&`gDHgex zYF;a+(l z)I^YdBejR~F(0@y-0FvbuZ;>f=bPUJch7u*YsH=Kz)h8^>BT<%BQC%B|5m3@C*|Va z`5fnD1qL|c)TWKEQHB=}g+nl`Q6|oEjwYhOtc0P|t{D6@gMWPSOtt5DYgAq{^w6Xm z2fuexVyvy`s4eUcQZXGz_ixI92K5cw5VM7xxS2=m)eS91yFUx}&DzeQ`#=CgY=|#2 zC%Oy{T`g6tX0g?cw^lxFnqkb%gS*xWC>!G4-JAa?ZP+M(N_*03_-E22zo1^`gL>`9 zH_IoAx|1BpDon%?6MI>l*a3z`*g7IOP?*3D?QBs=ZROaZH~F47hkX8L96RN%J*f04O4P{@d+zTYUm ztJSxrubGBws?(qNO;sDWwt_X~gTLLU?pZ(Aj*fNwEOGRn?D%+pE7-*E)-t!X$HLCY zu-cSM8V&E=fWdeMrDcOO-m?bDazTQ~#w3ZBC6TfBF77)w6V2@=Q{_wb<+to;CL10X zju+I1blp^DWNg!L`gGP99UYv021F*e(4_HKh}k|Yn^L&EHq>_M>tiJBAka_DR$dd? zy>f~-<&>_hhLrmwCFY4+N?g_Vk(q^~EupG!VqfD%(qGaeAvj}buwk%qslkXvBLYLb zb9;V&0QMPYHcWtQBtF1z#lsqA!nWf$C+DarESl8uBYZB=42)irLODd3QyyxJXf;%W zbIKUofk_{pipmaMjt=|usiCj-{odD6c73sWEshQnu8#wW<{e|XJJO%a#!n;K*qZ1l zo0y-I%W^JgEY3SZQ}ZKpc}KI#qJ*xrul z^|`5XbB^yIzBm|hp zOL|~o@EHjBTAWIg9Vs+Ae;Ur>NQoCzjp6dnBQq=a!_xG(b5-Y^Qm9!BR591#ji?QR z`~r080q})MAS_Ak1yeTTN`$}7fex{O=Lcpa?Ur8CJc9Eyq}$~SL#J$k#c@Rd+`tTT z*LfqGfmIcoNIbgw6-~+ZJ1^Ii*bG?#TNErZc``fVjQJ_v!47A^JPhvp`_ghFZa41Z zgZxC6S1M7fPl`U9D}An(Qa5A>VJ$!VXxTW||EpEaSa|vOH>lR+=gWXkK`)fL#ckCt zcNEGD+b`kHOy8QNU0r8>wgs7O8y?!Ot(Dd;PF}kFa)0A&Gswfl98QAhVl}kM^o8fL ze-YeWyNct#i-J}4f;YvaS$jH>)4ZE&QiSs6EmOPtK?J(W(ZHcW=lG&O}xLt z*qzk*KC)=?y5Qo=j^_4lG$HdUZG~>^jl}+fI%_8mX8e`_>h{G|Mv4g41%{JP_8WN4 zOI#vKsY=Q=x3m{WVr$_hfGMT+2z89T7I(t8S;ymh`SWkD*fW^1cQYU?D#kpDA0|Z7 zbi*%5^g#irw2p#6C<8_v;Q*^2WnJ4~w9JGYYl|DwCWV!$u<&c(u$e7(=86|c{Ch*Y z86x5O@5OEQ$0uLjrA^b2tC=j_2HpU59MPx}hLYRhP&B@oj7RPPKxA6x$cWPlu;7g2 z5n(-tNJJW(b`u=?H&zrzx7GE}sS`$0Q+u`$rXKLpA?Y{ys_L^nH~LMw+;rM8QIl1J6oPHeIg1*S^bO2Rm<|p!Kua z%pj(0XI|5RxRlY>31|$yXcUG>=^g2iYRoYxss@L;Ue^bY0Pz9y1 z(*+!Uur586raf`whi9MIU$sh^xd%tFz}L#`(NA3TzT-vi*_RlHnO)$2z`HW=MG*xa z4!&k=*P3+>;toSxn(R2A8(T!#NqpKjZ2ce^%0i^<;KM@LzOP$V?)jRBM+2>wNXHVY z#hwvxi0&Ax6ng54BTZe6JphQ(U;}$PKsT`Xel_K(U@M)M=j(@awOKf5PwTt=XLHAwldmz3b=n5PquP^67p6XtWj!FZ?CIZDcIJryVV~=8! za{S&*$#_qOY2j8w>FjXkV{VXWhUw3dNa449Kh78b=|Q>=u~q-ME1-j-68?Qu4GL_R z1vPwCuRC<)Tw_!6W=cvR0>3q<#SCM1x>;r8p$WK)iyQiA4rE~VhErY0coqE}>EovCir|u60!l)d|6j%m+;bP--?3%$suWa>CkUMv#u6fhe zD#;L?aD&sbWwoS6Xv<~w9X|rGiCn?eZ6NBD4C~UwL6A+FF<)(}vy$J$XnK?^05?LE zQ2E+nj*Bt~V&(w&O$y23!$L%GMuN7GJ=Qio5}~(16DrZ-*M(B2C|YMp$z=M=_|%Z= za;3_eOJ&NT^2Znk+XEus|llKvVjKVr}q#;Ib4HqCl*l11HW^<~%kA%C$|Q}5D@ zo96NjKy_V_`(LpOY-MO`S=yDj*piUFn$a<(?+eftWy9FsE)FG<+d*da-kqiYrolxyH+mA% z>2~&t7#CG|VdMrwE#yJ6GfJR-?>Wp!DOwqGRE~s|1syL1U-TGGDSo8z?&wvnQ{Eq* zyx1r}g2;9JMYzG5nRSJ}SkFa4KQmWlVb)SB(SZu*>5nMVX#OxvAL_p4dnZn9+V}yl z{Z!`m_5Qf@f#a3_($}nCe>7FCvCCD({J->!%qf)k8-1yOJ=sv9+k;lOZN z`}yAEVGqnWMR-jFi9wXDq?a+|cGYswbH~Es;tYE39=YcV0U5J1bcHHu`=7eV+Nji$ zwuTy=QU!9-G*-U+*H#yoB0wF6D}>>Br47CgU_@;pXQV{P`KClWxdr561*ysoK8g^o+u&Aa^08`cizl zfFG^X$OlPsm~5d3hi3aEOz~O-vN|nK1)T_1xpJGUg{29Sf0Dlkiz|xo%CQzJS&_KL zXBK9u{!-rrD)o8SA-v@0t>mjyhT*fa4`XHoxb z=7goIpCEII!g}>>|Mh?ycX1_fxpzC|8m#!KS^a_EDP$$+Li8-!WlC9K)wqP`R=~aN zl8LA&a2x_uw5NC-{$(Xt$)&(eR|F~mC><;pZs6+(s7b!A7;#LG7pl7}4D@6!G|T#$0VA6Km-S5ToC3i3eFSSxB7 zo?xgRVfnQ!gl9*d&iKcK%~+xGSMI}UrYLTHUj8{xb?7GH7Q9e9@hzv5d%f6D-SW63 zCjCh#@rtr=8a4~mQnkQ<@F%A>F-T6H1QQc0>5+S85yJWA`j?nmD?Z#3KWtJANS%H3 zPFcz15BcG#p>6nVf5S7(+fjz#f?XDS?fVnHK0-Y^;(4EuIt78y&oD6PJp+m;Rn+E_ zP(AYZ;+Av@uh>}oepo%c+kA^rXPl3vL#HEU%Z$1j#rO-js&b_}dgDi2gbbsmPrjlt z^4rNeslHPZ!U4e9E<~(u)OxxBZW0&mfVrj`P0kS=Ln{qpSP0-mT}O+s*knMkSXGnG zE{4W_h|zGGw-}9I4d!uo2L8nse6bwd!H2pd6Kr~ ze!LG{aq8}FbGkW6>JM)fy*|HZSO0$TQMBRkT`G4BgNrEv>@C+lpLw(Qkh;P8y942Q z|2@3W;em%JWd8vPpJCy{jUO%_GZ;HCQF#vTMtZYlQBOGs8HdIcMIZ?{ zR>B;tNw-%b0j|OVs8*b{yD#Qcix!ynZf$lZWmvxQq65n~zFn<5(~ad2*|MxB5! z3c-`=pzzqU=yx^2e5CoXwW9prC8pGpTgE?1#pqYp^8^~{3x!L-dNx|Jyxskpkvn&O z&S2p`uTr)C%&$Lq56k2~o$rCyi-w%ETaIUz{uGHf#+LCrZ#fXEm-qz6J%M@x0Fv38_*; zaeL7N+@uNekx?YXmM@sy3&aqlzXXoe9HYI{IC2Pm8nOqyH@5 zId8q_fy&_n>2bmjMH7YOKR6&<6p5)6HMieAW&!+Id?8M$JPsm|3(SJ=JP)IS^=)L)p9~Iuk65HW9pV z;rUj*Npv?=215681a5az(ZNS8t^Xy6|?=2=h*aHiETW|&ca|7twI3g z5GRbS|B{x_+nIC+(bC(8B|yq~Zw359s=5RN7D#-1u0)1)y>(Uf^-$AcWYm;W5v6#< zq^ZaFR9P%BXYy*;oV!|MS#*~@>4{MaTl&>1ItJERbLWrGsqZ(hL8ghG_cNOvcb08pN)`r z8L?B3q>rJ`-yJ68<@<^u-Fx%RS_6A-aRP^8q~j+YfuJv)YQZqmGg>rGspwcKn4sa4 z1Xfob%EPH_sve+1Hgdxt8#xNOVB~OjK_)92)I@YPWWza@`JT00qM`1+&f)EaU)Vi;xw(!yu+z}xkQ^<<<3%Fn60KA2@}tB|%Jl!s?iCjG4mxM@qOupbwerAt~XNb9`(f4FQ$znEUfn0Wyy6h+`?* zn1YOrZ|4g&_*vuZKh3|vGUdj2$ewHd)SzAnJ%+N2r%_zv^4$rj2}H)?=EBl`4C%K6 zBOjI>7?tgQZO>JjczqA#rBVQiA^E5lRKAW7 zMPFGv+D*fml%tt6lFgOUg!Oa!sx1%Pw?<(jP{>%D{NXao>XnPuhgIbmZIn^0QQWV1 zADaAWvRpX!$48#Q7aiFwpZBb#{z)@+5peCD|7qgS=6ACb>^w5_PhNyu^DtE@9~iub zy1kcNvaihxI=)%Ljrgk9Iawh9a2&4!JUA@vd@8mYl2)9KHCvl0RFc#TdX|5K>)(Lu||-5RsGTg+#=9Gf>U2$BU!hJ4hJqXfBSo@%Kx zT*f%}LE^MD1Fx1P38ro5ss_fDhEEyCqeU9&=J1K%Eg!u;g(+4@v$RlKUKhv;!r0by zs5f#0P5kfAlzFfRS6}OZ`Fv4d?)-!8Po5k{H7gR(7ify-Q}g**c(~A^4NywzCY8La zz)i9`TgXU8CW(?U%S-j5{FPzA4xX!X1c0rN!O4IUPbnh+j2X=~i#P-;tP)i0!iIDq zVVkI^1C(__m30|qEVi55g}n0W_PTHOS@*Yn9WoheFtpm8M%(sqbEZOYOz*e5udK^y z^TUk@N9G(%W1P8lsp+FojN^7&2{U*Lr_ymrYd7k!7k915&ec69q6+?vRIp&Ch49oz}Xg zy@@+xrX<0xLLTRZLEUNiqk|R@=oRKVt~g4%Y$&)^7;G%0?p|tj(s+mu1yH1e7$SfP zcd_E3=XGqVmhi2#s%MQqVaR&aYnhqwbj2s?FtQTgvNe;3`ztp_(H$FKJJYF$HegvmaMV?{!Pi)ve5WkVZ>t&#dCl=*bIy ze-ir^HHQFuRrlY-t|xkGx~Sl0X*|XYyFk;IWmQ%$O5S)`dOCfqNNw8}R@Y`*W9UEa zr@}V)7Gr$))8rf)(oxERJz-iq<;x*FaYG?|&7&U`%zp*^nphs*EKB8*O=yUA{K8l(w5q|<6}5D{`vz;uRu z2_s8Ix7Jd4P(VYZ;T#dO_lidgEEV@`ymChBmixAVOH}h>+aT)#9M88zjBFI4E3%|D zR>(*1u|2p*KhC%o5$K;S9v!63hb1WsLyZMj$E%%6bC1#Rmbc7^ZZxXY`j)A&a;~cC zqQK;sxM-CdX&@*zAQ*RNus+XwZh4*Vq&Exhi_sR@8%J&&7MMEfB~L$6JR3a%t@nla zH4wmJxA1UyLZ7kZvzE(?7of?Z1@g4cEiL!Q%Ib~@R7e8%TfeAS2;@)Y>8#ez?tRpq z{yvyN-Z*|cJ9BiJn#*|lw8`jltgm5(y8SIr$@SMSg5Te9TG(T#@peXCeEcRpRE`ks%~D~Sszvz(R2)hb)~WEZ!a z9{=t~BB#lrmZGa6Z2mycl)%l!j7t3}kWic|G!8Z#LaxVpovYQ}%uz9kkZrasjwtIG z6s1+A6_zGW!2Dv$;=K*ZtILyLfLcIN*)}rFp2|le?1SRQ=|geypWw5E@R;Vdh>0ZY z-n--Sztc#gt|LQ$YlGSrVSHwSua%d$FO>s)1=;06VURw4Q?FFJNlI0L7(#4qw09ty zLu6Uo6l9T6^eLO!6MPX;4cE?kYtBH3L%zi679--Wpi1g2Nj$xP(m;F}=m8?D7GI4e-C)1%!Hp2=xw`w23DE}pIXajGhoqxS%~P`!Uafzx43 zM{m}C-va=a%1jok8(V+A8qT0%i(?}2Q4~?NLLvPcADHZuKWn(t@HXx!r48j^(ZA~G zeyay_)i2fZ2-tU5x$*MB@eT_o!oWqBM%jlN>bVVHFax5y^@w0Q;5i0?3&BRQgkhrZbjoZ zxO)95L9C)ucuy5IW)p?BKXCn3njROs1ca(h%2vM1U)syS#|bVwH}(h4s$3snPyd7* z{%QwqHXaBJ{`(;$2LD<6Uw`92yYfFi|NrG4|M8Fhx8EU;kFcLezCfcLIocA?X6tpz z71%~H^#62V$^Z7v{~uiVcmT^rGP0BT9}h)$CuI*H-G9VqjE0_Oh$)6|c~ literal 0 HcmV?d00001 diff --git a/docs/server-tabs-source-of-truth.md b/docs/server-tabs-source-of-truth.md index d007d18..e5a9eb7 100644 --- a/docs/server-tabs-source-of-truth.md +++ b/docs/server-tabs-source-of-truth.md @@ -21,7 +21,7 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt | `type` | Extra fields | New? | |---|---|---| -| `tab_opened` | `openedAt` | **new** | +| `tab_opened` | `openedAt`, `email?` | **new** (`email` newer, see section 6) | | `tab_renamed` | — (`customerName` is the new name) | **new** | | `tab_deleted` | — (always preceded by a `line_removed` for every line still on the tab) | **new** | | `line_added` | `lineId`, `productId`, `productName`, `unitPriceInCents`, `quantity` (≥ 1) | `lineId`, `productId` new | @@ -30,10 +30,15 @@ Common fields (already sent): `id`, `type`, `tabId`, `customerName`, `occurredAt Events created by an older app version may lack `lineId` / `productId`; match those by name (see below). +`line_added` and `line_removed` also carry `orderId`: the order the drinks belong to (drinks rung up on one tab +without a pause of 5 minutes, and without leaving the tab in between). Each unit stores it as `orderId`; units from +older app versions have none and are grouped by time (a gap of over 5 minutes starts a new order). On `line_removed` +the field is optional: present (also `null`), only units of that order count; absent, any unit of the line. + ### 2. The venue's tab state ``` -Tab { id, customerName, openedAt, version, lines: Line[] } +Tab { id, customerName, email | null, openedAt, version, lines: Line[] } Line { id, productId, productName, unitPriceInCents, quantity, units: Unit[] } Unit { id, addedAt, removedAt | null } // one per piece ever put on the line ``` @@ -48,11 +53,11 @@ Unit { id, addedAt, removedAt | null } // one per piece ever put on the lin | Event | Rule | |---|---| -| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). | +| `tab_opened` | Create the tab with `tabId`, `customerName`, `openedAt` and `email` (absent → `null`) if it doesn't exist. Duplicate names are allowed (two offline tablets opening "Jan" give two tabs). | | `tab_renamed` | Set `customerName`. Unknown tab → ignore (acknowledge). | | `tab_deleted` | Remove the tab and its lines. Unknown tab → ignore. | -| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = ":"` (index 0…quantity−1) and `addedAt = occurredAt`. | -| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, line.quantity)` **latest** active units removed (`removedAt = occurredAt`). | +| `line_added` | If the tab doesn't exist (deleted elsewhere), **re-create it** from `tabId` + `customerName` (`openedAt = occurredAt`), so no sale is lost. Find the line; none → create it with `id = lineId` (or a new id if that id is taken). Add `quantity` units with `id = ":"` (index 0…quantity−1), `addedAt = occurredAt` and the event's `orderId`. | +| `line_removed` | Find the line; none → ignore. Mark the `min(quantity, n)` **latest** active units removed (`removedAt = occurredAt`), where `n` counts the line's active units of the event's `orderId` (all of them when the event has no `orderId`). | | `settled` | For each item: find the line; take `min(item.quantity, line.quantity)` of the **oldest** active units off it, together with the line's removed units; drop the line when nothing is left. Drinks added after the settlement (by another tablet) **stay on the tab**. Record a settlement (section 4) with id = the event's `id`. The tab itself stays open (tabs are never closed, only emptied). | The unit ids **must** follow `":"`: the tablet gives replayed units the same ids, so a sync that @@ -65,7 +70,7 @@ Stock is applied in the same transaction, see `server-stock-source-of-truth.md`. ```json { "tabs": [ - { "id": "…", "customerName": "Alice", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7, + { "id": "…", "customerName": "Alice", "email": "alice@example.com", "openedAt": "2026-10-24T20:00:00.000Z", "version": 7, "lines": [ { "id": "…", "productId": "12", "productName": "Cola", "unitPriceInCents": 250, "quantity": 1, "units": [ { "id": ":0", "addedAt": "…", "removedAt": null }, @@ -116,6 +121,26 @@ Response: - **Planned tab names**: create them as tabs in the server state. Once `tabs` is sent, the app ignores `plannedTabNames` (otherwise every tablet would open them). +### 6. Customer email + +Since the Open Tab dialog, the bartender can add a customer with a name and an **optional email**. The app sends it +on the `tab_opened` event: + +```json +{ "id": "…", "type": "tab_opened", "tabId": "…", "customerName": "Alice", "occurredAt": "…", + "openedAt": "…", "email": "alice@example.com" } +``` + +- `email` is **left out** (not `null`) when none was entered, and on every event from older app versions. The app trims + it and checks it looks like `name@domain.tld`; validate again if you rely on it, and reject nothing over it (store + `null` instead of refusing the event). +- Store it on the tab and return it as `email` in `tabs` (`null` or absent when there is none). +- There is no event to change or remove an email yet; the dashboard is the place for that if needed. +- Until the server returns `email`, the tablet keeps the one entered on it (by tab id) across syncs, but other + tablets don't see it. Note that the tablet treats `null`/absent as "unknown" and keeps its own value, so clearing an + email from the dashboard won't reach a tablet that has one stored. +- The email is personal data: show it only to venue staff, and delete it with the tab. + ### Order within one sync 1. Apply the received events (once per id), oldest first; acknowledge/reject them. @@ -138,3 +163,5 @@ Response: - Settle on the dashboard with every tablet off: the tab is emptied at once; the tablets show it in History after their next sync, exactly once. - The same event id sent twice (also in two syncs) is applied once. +- Add a customer with an email on tablet A: after both synced, tablet B shows the email under the name in the Open Tab + dialog. A customer added without email has `email: null` in `tabs`. diff --git a/lib/app/router.dart b/lib/app/router.dart index 8385559..8051cc8 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -168,8 +168,7 @@ GoRouter createAppRouter({ onSet: adminPinViewModel.setPin, error: () => adminPinViewModel.error, onSuccess: () => context.go(_returnLocation(state) ?? '/admin'), - onClose: () => - context.canPop() ? context.pop() : context.go('/bar'), + onClose: () => context.canPop() ? context.pop() : context.go('/bar'), ), ), diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8bdb188..d0bd767 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -55,6 +55,9 @@ class BarTabs extends Table { TextColumn get customerName => text()(); + /// Optional, entered when the customer was added. + TextColumn get email => text().nullable()(); + DateTimeColumn get openedAt => dateTime()(); IntColumn get version => integer().withDefault(const Constant(0))(); @@ -95,6 +98,10 @@ class TabItemPurchases extends Table { DateTimeColumn get removedAt => dateTime().nullable()(); + /// The order this drink was rung up in: drinks ordered together are shown + /// together. Null for drinks from before orders were tracked. + TextColumn get orderId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -135,6 +142,8 @@ class ClosedTabItems extends Table { DateTimeColumn get removedAt => dateTime().nullable()(); + TextColumn get orderId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -268,7 +277,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase([QueryExecutor? executor]) : super(executor ?? _openConnection()); @override - int get schemaVersion => 18; + int get schemaVersion => 20; @override MigrationStrategy get migration { @@ -358,7 +367,10 @@ class AppDatabase extends _$AppDatabase { // Tabs never close (only their items are archived), so the status // and closed_at columns were never anything but 'open' and null. if (await hasTableColumn('bar_tabs', 'status')) { - await migrator.alterTable(TableMigration(barTabs)); + // `email` only arrives in v20 + await migrator.alterTable( + TableMigration(barTabs, newColumns: [barTabs.email]), + ); } await _backfillPurchaseRows(); await _deleteOrphanedPurchaseRows(); @@ -391,6 +403,25 @@ class AppDatabase extends _$AppDatabase { } await migrator.createTable(pendingImageUploads); } + + if (from < 19) { + // `id`: the table exists + if (await hasTableColumn('tab_item_purchases', 'id') && + !await hasTableColumn('tab_item_purchases', 'order_id')) { + await migrator.addColumn( + tabItemPurchases, + tabItemPurchases.orderId, + ); + } + if (await hasTableColumn('closed_tab_items', 'id') && + !await hasTableColumn('closed_tab_items', 'order_id')) { + await migrator.addColumn(closedTabItems, closedTabItems.orderId); + } + } + + if (from < 20 && !await hasTableColumn('bar_tabs', 'email')) { + await migrator.addColumn(barTabs, barTabs.email); + } }, ); } diff --git a/lib/database/app_database.g.dart b/lib/database/app_database.g.dart index 10d76d9..b4387fe 100644 --- a/lib/database/app_database.g.dart +++ b/lib/database/app_database.g.dart @@ -663,6 +663,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { type: DriftSqlType.string, requiredDuringInsert: true, ); + static const VerificationMeta _emailMeta = const VerificationMeta('email'); + @override + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _openedAtMeta = const VerificationMeta( 'openedAt', ); @@ -687,7 +696,13 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { defaultValue: const Constant(0), ); @override - List get $columns => [id, customerName, openedAt, version]; + List get $columns => [ + id, + customerName, + email, + openedAt, + version, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -716,6 +731,12 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { } else if (isInserting) { context.missing(_customerNameMeta); } + if (data.containsKey('email')) { + context.handle( + _emailMeta, + email.isAcceptableOrUnknown(data['email']!, _emailMeta), + ); + } if (data.containsKey('opened_at')) { context.handle( _openedAtMeta, @@ -747,6 +768,10 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { DriftSqlType.string, data['${effectivePrefix}customer_name'], )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + ), openedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}opened_at'], @@ -767,11 +792,15 @@ class $BarTabsTable extends BarTabs with TableInfo<$BarTabsTable, BarTabRow> { class BarTabRow extends DataClass implements Insertable { final String id; final String customerName; + + /// Optional, entered when the customer was added. + final String? email; final DateTime openedAt; final int version; const BarTabRow({ required this.id, required this.customerName, + this.email, required this.openedAt, required this.version, }); @@ -780,6 +809,9 @@ class BarTabRow extends DataClass implements Insertable { final map = {}; map['id'] = Variable(id); map['customer_name'] = Variable(customerName); + if (!nullToAbsent || email != null) { + map['email'] = Variable(email); + } map['opened_at'] = Variable(openedAt); map['version'] = Variable(version); return map; @@ -789,6 +821,9 @@ class BarTabRow extends DataClass implements Insertable { return BarTabsCompanion( id: Value(id), customerName: Value(customerName), + email: email == null && nullToAbsent + ? const Value.absent() + : Value(email), openedAt: Value(openedAt), version: Value(version), ); @@ -802,6 +837,7 @@ class BarTabRow extends DataClass implements Insertable { return BarTabRow( id: serializer.fromJson(json['id']), customerName: serializer.fromJson(json['customerName']), + email: serializer.fromJson(json['email']), openedAt: serializer.fromJson(json['openedAt']), version: serializer.fromJson(json['version']), ); @@ -812,6 +848,7 @@ class BarTabRow extends DataClass implements Insertable { return { 'id': serializer.toJson(id), 'customerName': serializer.toJson(customerName), + 'email': serializer.toJson(email), 'openedAt': serializer.toJson(openedAt), 'version': serializer.toJson(version), }; @@ -820,11 +857,13 @@ class BarTabRow extends DataClass implements Insertable { BarTabRow copyWith({ String? id, String? customerName, + Value email = const Value.absent(), DateTime? openedAt, int? version, }) => BarTabRow( id: id ?? this.id, customerName: customerName ?? this.customerName, + email: email.present ? email.value : this.email, openedAt: openedAt ?? this.openedAt, version: version ?? this.version, ); @@ -834,6 +873,7 @@ class BarTabRow extends DataClass implements Insertable { customerName: data.customerName.present ? data.customerName.value : this.customerName, + email: data.email.present ? data.email.value : this.email, openedAt: data.openedAt.present ? data.openedAt.value : this.openedAt, version: data.version.present ? data.version.value : this.version, ); @@ -844,6 +884,7 @@ class BarTabRow extends DataClass implements Insertable { return (StringBuffer('BarTabRow(') ..write('id: $id, ') ..write('customerName: $customerName, ') + ..write('email: $email, ') ..write('openedAt: $openedAt, ') ..write('version: $version') ..write(')')) @@ -851,13 +892,14 @@ class BarTabRow extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, customerName, openedAt, version); + int get hashCode => Object.hash(id, customerName, email, openedAt, version); @override bool operator ==(Object other) => identical(this, other) || (other is BarTabRow && other.id == this.id && other.customerName == this.customerName && + other.email == this.email && other.openedAt == this.openedAt && other.version == this.version); } @@ -865,12 +907,14 @@ class BarTabRow extends DataClass implements Insertable { class BarTabsCompanion extends UpdateCompanion { final Value id; final Value customerName; + final Value email; final Value openedAt; final Value version; final Value rowid; const BarTabsCompanion({ this.id = const Value.absent(), this.customerName = const Value.absent(), + this.email = const Value.absent(), this.openedAt = const Value.absent(), this.version = const Value.absent(), this.rowid = const Value.absent(), @@ -878,6 +922,7 @@ class BarTabsCompanion extends UpdateCompanion { BarTabsCompanion.insert({ required String id, required String customerName, + this.email = const Value.absent(), required DateTime openedAt, this.version = const Value.absent(), this.rowid = const Value.absent(), @@ -887,6 +932,7 @@ class BarTabsCompanion extends UpdateCompanion { static Insertable custom({ Expression? id, Expression? customerName, + Expression? email, Expression? openedAt, Expression? version, Expression? rowid, @@ -894,6 +940,7 @@ class BarTabsCompanion extends UpdateCompanion { return RawValuesInsertable({ if (id != null) 'id': id, if (customerName != null) 'customer_name': customerName, + if (email != null) 'email': email, if (openedAt != null) 'opened_at': openedAt, if (version != null) 'version': version, if (rowid != null) 'rowid': rowid, @@ -903,6 +950,7 @@ class BarTabsCompanion extends UpdateCompanion { BarTabsCompanion copyWith({ Value? id, Value? customerName, + Value? email, Value? openedAt, Value? version, Value? rowid, @@ -910,6 +958,7 @@ class BarTabsCompanion extends UpdateCompanion { return BarTabsCompanion( id: id ?? this.id, customerName: customerName ?? this.customerName, + email: email ?? this.email, openedAt: openedAt ?? this.openedAt, version: version ?? this.version, rowid: rowid ?? this.rowid, @@ -925,6 +974,9 @@ class BarTabsCompanion extends UpdateCompanion { if (customerName.present) { map['customer_name'] = Variable(customerName.value); } + if (email.present) { + map['email'] = Variable(email.value); + } if (openedAt.present) { map['opened_at'] = Variable(openedAt.value); } @@ -942,6 +994,7 @@ class BarTabsCompanion extends UpdateCompanion { return (StringBuffer('BarTabsCompanion(') ..write('id: $id, ') ..write('customerName: $customerName, ') + ..write('email: $email, ') ..write('openedAt: $openedAt, ') ..write('version: $version, ') ..write('rowid: $rowid') @@ -1479,8 +1532,25 @@ class $TabItemPurchasesTable extends TabItemPurchases type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); @override - List get $columns => [id, tabItemId, purchasedAt, removedAt]; + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + tabItemId, + purchasedAt, + removedAt, + orderId, + ]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -1523,6 +1593,12 @@ class $TabItemPurchasesTable extends TabItemPurchases removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), ); } + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } return context; } @@ -1548,6 +1624,10 @@ class $TabItemPurchasesTable extends TabItemPurchases DriftSqlType.dateTime, data['${effectivePrefix}removed_at'], ), + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + ), ); } @@ -1563,11 +1643,16 @@ class TabItemPurchaseRow extends DataClass final String tabItemId; final DateTime purchasedAt; final DateTime? removedAt; + + /// The order this drink was rung up in: drinks ordered together are shown + /// together. Null for drinks from before orders were tracked. + final String? orderId; const TabItemPurchaseRow({ required this.id, required this.tabItemId, required this.purchasedAt, this.removedAt, + this.orderId, }); @override Map toColumns(bool nullToAbsent) { @@ -1578,6 +1663,9 @@ class TabItemPurchaseRow extends DataClass if (!nullToAbsent || removedAt != null) { map['removed_at'] = Variable(removedAt); } + if (!nullToAbsent || orderId != null) { + map['order_id'] = Variable(orderId); + } return map; } @@ -1589,6 +1677,9 @@ class TabItemPurchaseRow extends DataClass removedAt: removedAt == null && nullToAbsent ? const Value.absent() : Value(removedAt), + orderId: orderId == null && nullToAbsent + ? const Value.absent() + : Value(orderId), ); } @@ -1602,6 +1693,7 @@ class TabItemPurchaseRow extends DataClass tabItemId: serializer.fromJson(json['tabItemId']), purchasedAt: serializer.fromJson(json['purchasedAt']), removedAt: serializer.fromJson(json['removedAt']), + orderId: serializer.fromJson(json['orderId']), ); } @override @@ -1612,6 +1704,7 @@ class TabItemPurchaseRow extends DataClass 'tabItemId': serializer.toJson(tabItemId), 'purchasedAt': serializer.toJson(purchasedAt), 'removedAt': serializer.toJson(removedAt), + 'orderId': serializer.toJson(orderId), }; } @@ -1620,11 +1713,13 @@ class TabItemPurchaseRow extends DataClass String? tabItemId, DateTime? purchasedAt, Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), }) => TabItemPurchaseRow( id: id ?? this.id, tabItemId: tabItemId ?? this.tabItemId, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt, + orderId: orderId.present ? orderId.value : this.orderId, ); TabItemPurchaseRow copyWithCompanion(TabItemPurchasesCompanion data) { return TabItemPurchaseRow( @@ -1634,6 +1729,7 @@ class TabItemPurchaseRow extends DataClass ? data.purchasedAt.value : this.purchasedAt, removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, + orderId: data.orderId.present ? data.orderId.value : this.orderId, ); } @@ -1643,13 +1739,15 @@ class TabItemPurchaseRow extends DataClass ..write('id: $id, ') ..write('tabItemId: $tabItemId, ') ..write('purchasedAt: $purchasedAt, ') - ..write('removedAt: $removedAt') + ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); + int get hashCode => + Object.hash(id, tabItemId, purchasedAt, removedAt, orderId); @override bool operator ==(Object other) => identical(this, other) || @@ -1657,7 +1755,8 @@ class TabItemPurchaseRow extends DataClass other.id == this.id && other.tabItemId == this.tabItemId && other.purchasedAt == this.purchasedAt && - other.removedAt == this.removedAt); + other.removedAt == this.removedAt && + other.orderId == this.orderId); } class TabItemPurchasesCompanion extends UpdateCompanion { @@ -1665,12 +1764,14 @@ class TabItemPurchasesCompanion extends UpdateCompanion { final Value tabItemId; final Value purchasedAt; final Value removedAt; + final Value orderId; final Value rowid; const TabItemPurchasesCompanion({ this.id = const Value.absent(), this.tabItemId = const Value.absent(), this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }); TabItemPurchasesCompanion.insert({ @@ -1678,6 +1779,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { required String tabItemId, required DateTime purchasedAt, this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), tabItemId = Value(tabItemId), @@ -1687,6 +1789,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { Expression? tabItemId, Expression? purchasedAt, Expression? removedAt, + Expression? orderId, Expression? rowid, }) { return RawValuesInsertable({ @@ -1694,6 +1797,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { if (tabItemId != null) 'tab_item_id': tabItemId, if (purchasedAt != null) 'purchased_at': purchasedAt, if (removedAt != null) 'removed_at': removedAt, + if (orderId != null) 'order_id': orderId, if (rowid != null) 'rowid': rowid, }); } @@ -1703,6 +1807,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { Value? tabItemId, Value? purchasedAt, Value? removedAt, + Value? orderId, Value? rowid, }) { return TabItemPurchasesCompanion( @@ -1710,6 +1815,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { tabItemId: tabItemId ?? this.tabItemId, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt ?? this.removedAt, + orderId: orderId ?? this.orderId, rowid: rowid ?? this.rowid, ); } @@ -1729,6 +1835,9 @@ class TabItemPurchasesCompanion extends UpdateCompanion { if (removedAt.present) { map['removed_at'] = Variable(removedAt.value); } + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -1742,6 +1851,7 @@ class TabItemPurchasesCompanion extends UpdateCompanion { ..write('tabItemId: $tabItemId, ') ..write('purchasedAt: $purchasedAt, ') ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -2216,6 +2326,17 @@ class $ClosedTabItemsTable extends ClosedTabItems type: DriftSqlType.dateTime, requiredDuringInsert: false, ); + static const VerificationMeta _orderIdMeta = const VerificationMeta( + 'orderId', + ); + @override + late final GeneratedColumn orderId = GeneratedColumn( + 'order_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -2226,6 +2347,7 @@ class $ClosedTabItemsTable extends ClosedTabItems unitPriceInCents, purchasedAt, removedAt, + orderId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2308,6 +2430,12 @@ class $ClosedTabItemsTable extends ClosedTabItems removedAt.isAcceptableOrUnknown(data['removed_at']!, _removedAtMeta), ); } + if (data.containsKey('order_id')) { + context.handle( + _orderIdMeta, + orderId.isAcceptableOrUnknown(data['order_id']!, _orderIdMeta), + ); + } return context; } @@ -2349,6 +2477,10 @@ class $ClosedTabItemsTable extends ClosedTabItems DriftSqlType.dateTime, data['${effectivePrefix}removed_at'], ), + orderId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}order_id'], + ), ); } @@ -2368,6 +2500,7 @@ class ClosedTabItemRow extends DataClass final int unitPriceInCents; final DateTime? purchasedAt; final DateTime? removedAt; + final String? orderId; const ClosedTabItemRow({ required this.id, required this.closedTabId, @@ -2377,6 +2510,7 @@ class ClosedTabItemRow extends DataClass required this.unitPriceInCents, this.purchasedAt, this.removedAt, + this.orderId, }); @override Map toColumns(bool nullToAbsent) { @@ -2393,6 +2527,9 @@ class ClosedTabItemRow extends DataClass if (!nullToAbsent || removedAt != null) { map['removed_at'] = Variable(removedAt); } + if (!nullToAbsent || orderId != null) { + map['order_id'] = Variable(orderId); + } return map; } @@ -2410,6 +2547,9 @@ class ClosedTabItemRow extends DataClass removedAt: removedAt == null && nullToAbsent ? const Value.absent() : Value(removedAt), + orderId: orderId == null && nullToAbsent + ? const Value.absent() + : Value(orderId), ); } @@ -2427,6 +2567,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents: serializer.fromJson(json['unitPriceInCents']), purchasedAt: serializer.fromJson(json['purchasedAt']), removedAt: serializer.fromJson(json['removedAt']), + orderId: serializer.fromJson(json['orderId']), ); } @override @@ -2441,6 +2582,7 @@ class ClosedTabItemRow extends DataClass 'unitPriceInCents': serializer.toJson(unitPriceInCents), 'purchasedAt': serializer.toJson(purchasedAt), 'removedAt': serializer.toJson(removedAt), + 'orderId': serializer.toJson(orderId), }; } @@ -2453,6 +2595,7 @@ class ClosedTabItemRow extends DataClass int? unitPriceInCents, Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), }) => ClosedTabItemRow( id: id ?? this.id, closedTabId: closedTabId ?? this.closedTabId, @@ -2462,6 +2605,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, purchasedAt: purchasedAt.present ? purchasedAt.value : this.purchasedAt, removedAt: removedAt.present ? removedAt.value : this.removedAt, + orderId: orderId.present ? orderId.value : this.orderId, ); ClosedTabItemRow copyWithCompanion(ClosedTabItemsCompanion data) { return ClosedTabItemRow( @@ -2481,6 +2625,7 @@ class ClosedTabItemRow extends DataClass ? data.purchasedAt.value : this.purchasedAt, removedAt: data.removedAt.present ? data.removedAt.value : this.removedAt, + orderId: data.orderId.present ? data.orderId.value : this.orderId, ); } @@ -2494,7 +2639,8 @@ class ClosedTabItemRow extends DataClass ..write('quantity: $quantity, ') ..write('unitPriceInCents: $unitPriceInCents, ') ..write('purchasedAt: $purchasedAt, ') - ..write('removedAt: $removedAt') + ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId') ..write(')')) .toString(); } @@ -2509,6 +2655,7 @@ class ClosedTabItemRow extends DataClass unitPriceInCents, purchasedAt, removedAt, + orderId, ); @override bool operator ==(Object other) => @@ -2521,7 +2668,8 @@ class ClosedTabItemRow extends DataClass other.quantity == this.quantity && other.unitPriceInCents == this.unitPriceInCents && other.purchasedAt == this.purchasedAt && - other.removedAt == this.removedAt); + other.removedAt == this.removedAt && + other.orderId == this.orderId); } class ClosedTabItemsCompanion extends UpdateCompanion { @@ -2533,6 +2681,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { final Value unitPriceInCents; final Value purchasedAt; final Value removedAt; + final Value orderId; final Value rowid; const ClosedTabItemsCompanion({ this.id = const Value.absent(), @@ -2543,6 +2692,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { this.unitPriceInCents = const Value.absent(), this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }); ClosedTabItemsCompanion.insert({ @@ -2554,6 +2704,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { required int unitPriceInCents, this.purchasedAt = const Value.absent(), this.removedAt = const Value.absent(), + this.orderId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), closedTabId = Value(closedTabId), @@ -2570,6 +2721,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Expression? unitPriceInCents, Expression? purchasedAt, Expression? removedAt, + Expression? orderId, Expression? rowid, }) { return RawValuesInsertable({ @@ -2581,6 +2733,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (unitPriceInCents != null) 'unit_price_in_cents': unitPriceInCents, if (purchasedAt != null) 'purchased_at': purchasedAt, if (removedAt != null) 'removed_at': removedAt, + if (orderId != null) 'order_id': orderId, if (rowid != null) 'rowid': rowid, }); } @@ -2594,6 +2747,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { Value? unitPriceInCents, Value? purchasedAt, Value? removedAt, + Value? orderId, Value? rowid, }) { return ClosedTabItemsCompanion( @@ -2605,6 +2759,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { unitPriceInCents: unitPriceInCents ?? this.unitPriceInCents, purchasedAt: purchasedAt ?? this.purchasedAt, removedAt: removedAt ?? this.removedAt, + orderId: orderId ?? this.orderId, rowid: rowid ?? this.rowid, ); } @@ -2636,6 +2791,9 @@ class ClosedTabItemsCompanion extends UpdateCompanion { if (removedAt.present) { map['removed_at'] = Variable(removedAt.value); } + if (orderId.present) { + map['order_id'] = Variable(orderId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2653,6 +2811,7 @@ class ClosedTabItemsCompanion extends UpdateCompanion { ..write('unitPriceInCents: $unitPriceInCents, ') ..write('purchasedAt: $purchasedAt, ') ..write('removedAt: $removedAt, ') + ..write('orderId: $orderId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -5238,6 +5397,7 @@ typedef $$BarTabsTableCreateCompanionBuilder = BarTabsCompanion Function({ required String id, required String customerName, + Value email, required DateTime openedAt, Value version, Value rowid, @@ -5246,6 +5406,7 @@ typedef $$BarTabsTableUpdateCompanionBuilder = BarTabsCompanion Function({ Value id, Value customerName, + Value email, Value openedAt, Value version, Value rowid, @@ -5293,6 +5454,11 @@ class $$BarTabsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnFilters(column), @@ -5348,6 +5514,11 @@ class $$BarTabsTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get email => $composableBuilder( + column: $table.email, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get openedAt => $composableBuilder( column: $table.openedAt, builder: (column) => ColumnOrderings(column), @@ -5376,6 +5547,9 @@ class $$BarTabsTableAnnotationComposer builder: (column) => column, ); + GeneratedColumn get email => + $composableBuilder(column: $table.email, builder: (column) => column); + GeneratedColumn get openedAt => $composableBuilder(column: $table.openedAt, builder: (column) => column); @@ -5438,12 +5612,14 @@ class $$BarTabsTableTableManager ({ Value id = const Value.absent(), Value customerName = const Value.absent(), + Value email = const Value.absent(), Value openedAt = const Value.absent(), Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion( id: id, customerName: customerName, + email: email, openedAt: openedAt, version: version, rowid: rowid, @@ -5452,12 +5628,14 @@ class $$BarTabsTableTableManager ({ required String id, required String customerName, + Value email = const Value.absent(), required DateTime openedAt, Value version = const Value.absent(), Value rowid = const Value.absent(), }) => BarTabsCompanion.insert( id: id, customerName: customerName, + email: email, openedAt: openedAt, version: version, rowid: rowid, @@ -6072,6 +6250,7 @@ typedef $$TabItemPurchasesTableCreateCompanionBuilder = required String tabItemId, required DateTime purchasedAt, Value removedAt, + Value orderId, Value rowid, }); typedef $$TabItemPurchasesTableUpdateCompanionBuilder = @@ -6080,6 +6259,7 @@ typedef $$TabItemPurchasesTableUpdateCompanionBuilder = Value tabItemId, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); @@ -6138,6 +6318,11 @@ class $$TabItemPurchasesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); + $$TabItemsTableFilterComposer get tabItemId { final $$TabItemsTableFilterComposer composer = $composerBuilder( composer: this, @@ -6186,6 +6371,11 @@ class $$TabItemPurchasesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); + $$TabItemsTableOrderingComposer get tabItemId { final $$TabItemsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -6230,6 +6420,9 @@ class $$TabItemPurchasesTableAnnotationComposer GeneratedColumn get removedAt => $composableBuilder(column: $table.removedAt, builder: (column) => column); + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); + $$TabItemsTableAnnotationComposer get tabItemId { final $$TabItemsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -6288,12 +6481,14 @@ class $$TabItemPurchasesTableTableManager Value tabItemId = const Value.absent(), Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => TabItemPurchasesCompanion( id: id, tabItemId: tabItemId, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), createCompanionCallback: @@ -6302,12 +6497,14 @@ class $$TabItemPurchasesTableTableManager required String tabItemId, required DateTime purchasedAt, Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => TabItemPurchasesCompanion.insert( id: id, tabItemId: tabItemId, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -6597,6 +6794,7 @@ typedef $$ClosedTabItemsTableCreateCompanionBuilder = required int unitPriceInCents, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); typedef $$ClosedTabItemsTableUpdateCompanionBuilder = @@ -6609,6 +6807,7 @@ typedef $$ClosedTabItemsTableUpdateCompanionBuilder = Value unitPriceInCents, Value purchasedAt, Value removedAt, + Value orderId, Value rowid, }); @@ -6660,6 +6859,11 @@ class $$ClosedTabItemsTableFilterComposer column: $table.removedAt, builder: (column) => ColumnFilters(column), ); + + ColumnFilters get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnFilters(column), + ); } class $$ClosedTabItemsTableOrderingComposer @@ -6710,6 +6914,11 @@ class $$ClosedTabItemsTableOrderingComposer column: $table.removedAt, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get orderId => $composableBuilder( + column: $table.orderId, + builder: (column) => ColumnOrderings(column), + ); } class $$ClosedTabItemsTableAnnotationComposer @@ -6752,6 +6961,9 @@ class $$ClosedTabItemsTableAnnotationComposer GeneratedColumn get removedAt => $composableBuilder(column: $table.removedAt, builder: (column) => column); + + GeneratedColumn get orderId => + $composableBuilder(column: $table.orderId, builder: (column) => column); } class $$ClosedTabItemsTableTableManager @@ -6799,6 +7011,7 @@ class $$ClosedTabItemsTableTableManager Value unitPriceInCents = const Value.absent(), Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion( id: id, @@ -6809,6 +7022,7 @@ class $$ClosedTabItemsTableTableManager unitPriceInCents: unitPriceInCents, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), createCompanionCallback: @@ -6821,6 +7035,7 @@ class $$ClosedTabItemsTableTableManager required int unitPriceInCents, Value purchasedAt = const Value.absent(), Value removedAt = const Value.absent(), + Value orderId = const Value.absent(), Value rowid = const Value.absent(), }) => ClosedTabItemsCompanion.insert( id: id, @@ -6831,6 +7046,7 @@ class $$ClosedTabItemsTableTableManager unitPriceInCents: unitPriceInCents, purchasedAt: purchasedAt, removedAt: removedAt, + orderId: orderId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c46122c..ae756da 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -125,6 +125,14 @@ "incorrectPin": "Incorrect PIN.", "tooManyPinAttempts": "Too many attempts. Try again later.", "removed": "Removed", + "removedCount": "{count} removed", + "@removedCount": {"placeholders": {"count": {"type": "int"}}}, + "orderStartedToday": "{time} today", + "@orderStartedToday": {"placeholders": {"time": {"type": "String"}}}, + "orderStartedYesterday": "{time} yesterday", + "@orderStartedYesterday": {"placeholders": {"time": {"type": "String"}}}, + "orderStartedOn": "{date} {time}", + "@orderStartedOn": {"placeholders": {"date": {"type": "String"}, "time": {"type": "String"}}}, "enterPrice": "Enter a price.", "enterValidPrice": "Enter a valid price.", "enterValidStock": "Enter a valid stock amount.", @@ -365,5 +373,11 @@ "creditsEveryoneAtTheBar": "Everyone at the bar", "creditsDrinkResponsibly": "Drink responsibly. Mostly.", "lazyPinRemark": "🤨 Really?", - "rainbowRoad": "Rainbow road 🌈" + "rainbowRoad": "Rainbow road 🌈", + "newCustomer": "New customer", + "emailOptional": "Email (optional)", + "invalidEmail": "Enter a valid email address", + "allTabsOpen": "Every tab is already open", + "noTabsFound": "No tabs found", + "nameRequired": "Enter a name" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ee08895..8f530eb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -800,6 +800,30 @@ abstract class AppLocalizations { /// **'Removed'** String get removed; + /// No description provided for @removedCount. + /// + /// In en, this message translates to: + /// **'{count} removed'** + String removedCount(int count); + + /// No description provided for @orderStartedToday. + /// + /// In en, this message translates to: + /// **'{time} today'** + String orderStartedToday(String time); + + /// No description provided for @orderStartedYesterday. + /// + /// In en, this message translates to: + /// **'{time} yesterday'** + String orderStartedYesterday(String time); + + /// No description provided for @orderStartedOn. + /// + /// In en, this message translates to: + /// **'{date} {time}'** + String orderStartedOn(String date, String time); + /// No description provided for @enterPrice. /// /// In en, this message translates to: @@ -2089,6 +2113,42 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Rainbow road 🌈'** String get rainbowRoad; + + /// No description provided for @newCustomer. + /// + /// In en, this message translates to: + /// **'New customer'** + String get newCustomer; + + /// No description provided for @emailOptional. + /// + /// In en, this message translates to: + /// **'Email (optional)'** + String get emailOptional; + + /// No description provided for @invalidEmail. + /// + /// In en, this message translates to: + /// **'Enter a valid email address'** + String get invalidEmail; + + /// No description provided for @allTabsOpen. + /// + /// In en, this message translates to: + /// **'Every tab is already open'** + String get allTabsOpen; + + /// No description provided for @noTabsFound. + /// + /// In en, this message translates to: + /// **'No tabs found'** + String get noTabsFound; + + /// No description provided for @nameRequired. + /// + /// In en, this message translates to: + /// **'Enter a name'** + String get nameRequired; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3cd253e..b5a4e06 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -393,6 +393,26 @@ class AppLocalizationsEn extends AppLocalizations { @override String get removed => 'Removed'; + @override + String removedCount(int count) { + return '$count removed'; + } + + @override + String orderStartedToday(String time) { + return '$time today'; + } + + @override + String orderStartedYesterday(String time) { + return '$time yesterday'; + } + + @override + String orderStartedOn(String date, String time) { + return '$date $time'; + } + @override String get enterPrice => 'Enter a price.'; @@ -1110,4 +1130,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rainbowRoad => 'Rainbow road 🌈'; + + @override + String get newCustomer => 'New customer'; + + @override + String get emailOptional => 'Email (optional)'; + + @override + String get invalidEmail => 'Enter a valid email address'; + + @override + String get allTabsOpen => 'Every tab is already open'; + + @override + String get noTabsFound => 'No tabs found'; + + @override + String get nameRequired => 'Enter a name'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index c90bdfa..78f65e5 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -334,7 +334,7 @@ class AppLocalizationsNl extends AppLocalizations { String get total => 'Totaal'; @override - String get closeTab => 'Poef sluiten'; + String get closeTab => 'Poef afsluiten'; @override String productStockSummary(String category, String price, int stock) { @@ -397,6 +397,26 @@ class AppLocalizationsNl extends AppLocalizations { @override String get removed => 'Verwijderd'; + @override + String removedCount(int count) { + return '$count verwijderd'; + } + + @override + String orderStartedToday(String time) { + return '$time vandaag'; + } + + @override + String orderStartedYesterday(String time) { + return '$time gisteren'; + } + + @override + String orderStartedOn(String date, String time) { + return '$date $time'; + } + @override String get enterPrice => 'Voer een prijs in.'; @@ -441,7 +461,7 @@ class AppLocalizationsNl extends AppLocalizations { String get couldNotCloseTab => 'Poef kon niet worden gesloten.'; @override - String get closingTab => 'Poef sluiten…'; + String get closingTab => 'Poef afsluiten…'; @override String get slideToConfirmClosing => 'Schuif om het sluiten te bevestigen'; @@ -977,7 +997,7 @@ class AppLocalizationsNl extends AppLocalizations { } @override - String get remoteCommandCloseTab => 'Poef sluiten via dashboard'; + String get remoteCommandCloseTab => 'Poef afsluiten via dashboard'; @override String remoteCommandUnknown(String type) { @@ -1124,4 +1144,22 @@ class AppLocalizationsNl extends AppLocalizations { @override String get rainbowRoad => 'Regenboogweg 🌈'; + + @override + String get newCustomer => 'Nieuwe klant'; + + @override + String get emailOptional => 'E-mail (optioneel)'; + + @override + String get invalidEmail => 'Geef een geldig e-mailadres in'; + + @override + String get allTabsOpen => 'Alle poefs zijn al open'; + + @override + String get noTabsFound => 'Geen poefs gevonden'; + + @override + String get nameRequired => 'Geef een naam in'; } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index e28bf8f..0123686 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -104,7 +104,7 @@ "@tabItemCount": {"placeholders": {"count": {"type": "int"}}}, "tapProductsToAdd": "Tik op producten om ze toe te voegen", "total": "Totaal", - "closeTab": "Poef sluiten", + "closeTab": "Poef afsluiten", "productStockSummary": "{category} • {price} • Voorraad: {stock}", "@productStockSummary": {"placeholders": {"category": {"type": "String"}, "price": {"type": "String"}, "stock": {"type": "int"}}}, "infiniteStock": "Oneindige voorraad", @@ -125,6 +125,10 @@ "incorrectPin": "Onjuiste PIN.", "tooManyPinAttempts": "Te veel pogingen. Probeer het later opnieuw.", "removed": "Verwijderd", + "removedCount": "{count} verwijderd", + "orderStartedToday": "{time} vandaag", + "orderStartedYesterday": "{time} gisteren", + "orderStartedOn": "{date} {time}", "enterPrice": "Voer een prijs in.", "enterValidPrice": "Voer een geldige prijs in.", "enterValidStock": "Voer een geldige voorraad in.", @@ -140,7 +144,7 @@ "currentTotal": "Huidig totaal: {total}", "@currentTotal": {"placeholders": {"total": {"type": "String"}}}, "couldNotCloseTab": "Poef kon niet worden gesloten.", - "closingTab": "Poef sluiten…", + "closingTab": "Poef afsluiten…", "slideToConfirmClosing": "Schuif om het sluiten te bevestigen", "errorScreen": "Foutscherm", "unexpectedError": "Er is een onverwachte fout opgetreden.\nProbeer de app opnieuw te starten.", @@ -307,7 +311,7 @@ "justNow": "zonet", "minutesAgo": "{count, plural, =1{1 minuut geleden} other{{count} minuten geleden}}", "hoursAgo": "{count, plural, =1{1 uur geleden} other{{count} uur geleden}}", - "remoteCommandCloseTab": "Poef sluiten via dashboard", + "remoteCommandCloseTab": "Poef afsluiten via dashboard", "remoteCommandUnknown": "Onbekende opdracht ({type})", "commandCompleted": "Uitgevoerd", "commandConflict": "Conflict", @@ -353,5 +357,11 @@ "creditsEveryoneAtTheBar": "Iedereen aan de toog", "creditsDrinkResponsibly": "Drink met mate. Meestal.", "lazyPinRemark": "🤨 Echt?", - "rainbowRoad": "Regenboogweg 🌈" + "rainbowRoad": "Regenboogweg 🌈", + "newCustomer": "Nieuwe klant", + "emailOptional": "E-mail (optioneel)", + "invalidEmail": "Geef een geldig e-mailadres in", + "allTabsOpen": "Alle poefs zijn al open", + "noTabsFound": "Geen poefs gevonden", + "nameRequired": "Geef een naam in" } diff --git a/lib/models/bar_tab.dart b/lib/models/bar_tab.dart index 87065fc..b503343 100644 --- a/lib/models/bar_tab.dart +++ b/lib/models/bar_tab.dart @@ -5,6 +5,7 @@ import 'tab_item.dart'; class BarTab { final String id; final String customerName; + final String? email; final DateTime openedAt; final int version; final List items; @@ -12,6 +13,7 @@ class BarTab { const BarTab({ required this.id, required this.customerName, + this.email, required this.openedAt, this.version = 0, required this.items, @@ -31,11 +33,19 @@ class BarTab { other is BarTab && id == other.id && customerName == other.customerName && + email == other.email && openedAt == other.openedAt && version == other.version && listEquals(items, other.items); @override int get hashCode => - Object.hash(id, customerName, openedAt, version, Object.hashAll(items)); + Object.hash( + id, + customerName, + email, + openedAt, + version, + Object.hashAll(items), + ); } diff --git a/lib/models/closed_tab_item.dart b/lib/models/closed_tab_item.dart index eea896f..8f90d0e 100644 --- a/lib/models/closed_tab_item.dart +++ b/lib/models/closed_tab_item.dart @@ -7,6 +7,7 @@ class ClosedTabItem { final int unitPriceInCents; final DateTime? purchasedAt; final DateTime? removedAt; + final String? orderId; ClosedTabItem({ required this.id, @@ -17,6 +18,7 @@ class ClosedTabItem { required this.unitPriceInCents, this.purchasedAt, this.removedAt, + this.orderId, }); bool get isRemoved => removedAt != null; @@ -34,7 +36,8 @@ class ClosedTabItem { quantity == other.quantity && unitPriceInCents == other.unitPriceInCents && purchasedAt == other.purchasedAt && - removedAt == other.removedAt; + removedAt == other.removedAt && + orderId == other.orderId; @override int get hashCode => Object.hash( @@ -46,5 +49,6 @@ class ClosedTabItem { unitPriceInCents, purchasedAt, removedAt, + orderId, ); } diff --git a/lib/models/server_tab.dart b/lib/models/server_tab.dart index e0b3a23..e2a4fbf 100644 --- a/lib/models/server_tab.dart +++ b/lib/models/server_tab.dart @@ -8,7 +8,15 @@ class ServerUnit { final DateTime addedAt; final DateTime? removedAt; - const ServerUnit({required this.id, required this.addedAt, this.removedAt}); + /// The order it was rung up in; null for drinks from older app versions. + final String? orderId; + + const ServerUnit({ + required this.id, + required this.addedAt, + this.removedAt, + this.orderId, + }); factory ServerUnit.fromJson(Map json) { return ServerUnit( @@ -18,6 +26,7 @@ class ServerUnit { final String value => DateTime.parse(value).toLocal(), _ => null, }, + orderId: json['orderId'] as String?, ); } } @@ -58,6 +67,7 @@ class ServerTabLine { class ServerTab { final String id; final String customerName; + final String? email; final DateTime openedAt; final int version; final List lines; @@ -65,6 +75,7 @@ class ServerTab { const ServerTab({ required this.id, required this.customerName, + this.email, required this.openedAt, required this.version, this.lines = const [], @@ -74,6 +85,7 @@ class ServerTab { return ServerTab( id: json['id'] as String, customerName: json['customerName'] as String, + email: json['email'] as String?, openedAt: DateTime.parse(json['openedAt'] as String).toLocal(), version: json['version'] as int? ?? 0, lines: [ diff --git a/lib/models/tab_event.dart b/lib/models/tab_event.dart index a039740..d2bb2dc 100644 --- a/lib/models/tab_event.dart +++ b/lib/models/tab_event.dart @@ -47,6 +47,10 @@ class TabEvent { const TabEvent({required this.id, required this.payload}); } +/// The order a removal takes drinks off: `orderId`, or (when null) the +/// drinks rung up before orders were tracked. +typedef OrderRef = ({String? orderId}); + /// One settled line in a `settled` event. typedef SettledLine = ({ String productId, @@ -66,12 +70,16 @@ class TabEventFactory { required String tabId, required String customerName, required DateTime openedAt, + String? email, }) { return _event( TabEventType.tabOpened, tabId: tabId, customerName: customerName, - fields: {'openedAt': openedAt.toUtc().toIso8601String()}, + fields: { + 'openedAt': openedAt.toUtc().toIso8601String(), + 'email': ?email, + }, ); } @@ -96,7 +104,8 @@ class TabEventFactory { } /// [quantity] is how many pieces were added; always positive. [lineId] is - /// the local line, so the server can keep its id. + /// the local line, so the server can keep its id. [orderId] is the order + /// they are part of. TabEvent lineAdded({ required String tabId, required String customerName, @@ -105,6 +114,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required String? orderId, }) { return _lineEvent( TabEventType.lineAdded, @@ -115,10 +125,13 @@ class TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + order: (orderId: orderId), ); } - /// [quantity] is how many pieces were removed; always positive. + /// [quantity] is how many pieces were removed; always positive. With + /// [order] they are taken off that order only; without, the latest drinks + /// of the line (as before orders). TabEvent lineRemoved({ required String tabId, required String customerName, @@ -127,6 +140,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + OrderRef? order, }) { return _lineEvent( TabEventType.lineRemoved, @@ -137,6 +151,7 @@ class TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + order: order, ); } @@ -246,6 +261,7 @@ class TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required OrderRef? order, }) { if (quantity < 1) { throw ArgumentError.value(quantity, 'quantity', 'Must be at least 1'); @@ -263,6 +279,9 @@ class TabEventFactory { 'productName': productName, 'unitPriceInCents': unitPriceInCents, 'quantity': quantity, + // Also when null: a removal then only takes drinks without an order, + // where a removal without `orderId` takes the latest of the line + if (order != null) 'orderId': order.orderId, }, ); } diff --git a/lib/models/tab_item_purchase.dart b/lib/models/tab_item_purchase.dart index f1a39b4..96c8abf 100644 --- a/lib/models/tab_item_purchase.dart +++ b/lib/models/tab_item_purchase.dart @@ -4,11 +4,15 @@ class TabItemPurchase { final DateTime purchasedAt; final DateTime? removedAt; + /// The order it was rung up in; null for drinks from before orders. + final String? orderId; + const TabItemPurchase({ required this.id, required this.tabItemId, required this.purchasedAt, this.removedAt, + this.orderId, }); bool get isRemoved => removedAt != null; @@ -20,8 +24,10 @@ class TabItemPurchase { id == other.id && tabItemId == other.tabItemId && purchasedAt == other.purchasedAt && - removedAt == other.removedAt; + removedAt == other.removedAt && + orderId == other.orderId; @override - int get hashCode => Object.hash(id, tabItemId, purchasedAt, removedAt); + int get hashCode => + Object.hash(id, tabItemId, purchasedAt, removedAt, orderId); } diff --git a/lib/services/bar_tab_service.dart b/lib/services/bar_tab_service.dart index 3a28894..65f9ccc 100644 --- a/lib/services/bar_tab_service.dart +++ b/lib/services/bar_tab_service.dart @@ -50,23 +50,29 @@ abstract class BarTabService { Future getTabById(String id); - Future createTab({required String customerName}); + Future createTab({required String customerName, String? email}); Future renameTab({required String tabId, required String customerName}); /// Deletes an open tab and returns its items to inventory. Future deleteTab(String tabId); + /// Rings up one [product] as part of order [orderId]; a new order when + /// null. Future addProductToTab({ required String tabId, required Product product, + String? orderId, }); - /// Returns the delta that was actually applied, which is smaller than - /// [delta] when it would push the quantity below zero. + /// Adds drinks to [order] (a new order when null), or takes drinks of + /// [order] off (when null: the latest of the line). Returns the delta that + /// was actually applied, which is smaller than [delta] when there are fewer + /// drinks to take off. Future adjustTabItemQuantity({ required String tabItemId, required int delta, + OrderRef? order, }); /// Archives the tab's current items into history and clears them. @@ -119,6 +125,7 @@ class DriftBarTabService implements BarTabService { tabItemId: row.tabItemId, purchasedAt: row.purchasedAt, removedAt: row.removedAt, + orderId: row.orderId, ); } @@ -138,6 +145,7 @@ class DriftBarTabService implements BarTabService { return BarTab( id: row.id, customerName: row.customerName, + email: row.email, openedAt: row.openedAt, version: row.version, items: items, @@ -154,6 +162,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: row.unitPriceInCents, purchasedAt: row.purchasedAt, removedAt: row.removedAt, + orderId: row.orderId, ); } @@ -173,11 +182,16 @@ class DriftBarTabService implements BarTabService { if (tabId != null) query.where(database.barTabs.id.equals(tabId)); + // Times are stored to the second: drinks rung up within one second keep + // the order they were added in (rowid), so a tab's rows never swap query.orderBy([ OrderingTerm.desc(database.barTabs.openedAt), OrderingTerm.asc(database.tabItems.createdAt), + OrderingTerm.asc(const CustomExpression('"tab_items"."rowid"')), OrderingTerm.asc(database.tabItemPurchases.purchasedAt), - OrderingTerm.asc(database.tabItemPurchases.id), + OrderingTerm.asc( + const CustomExpression('"tab_item_purchases"."rowid"'), + ), ]); return query; @@ -224,31 +238,63 @@ class DriftBarTabService implements BarTabService { )..where((tab) => tab.id.equals(tabId))).getSingleOrNull(); } - Future _recordPurchase(String tabItemId) async { + /// [id] is the one the server gives the unit: `:`. + Future _recordPurchase( + String tabItemId, + String orderId, { + required String id, + }) async { await database .into(database.tabItemPurchases) .insert( TabItemPurchasesCompanion.insert( - id: _uuid.v4(), + id: id, tabItemId: tabItemId, purchasedAt: DateTime.now(), + orderId: Value(orderId), ), ); } - /// Marks the most recent active purchases as removed, keeping them in the - /// audit trail. + /// The active purchases of [tabItemId] a removal may take off: those of + /// [order] when given, as the server does; else all of them (events from + /// before orders). + Future> _removablePurchases( + String tabItemId, + OrderRef? order, + ) { + return (database.select(database.tabItemPurchases)..where( + (purchase) => + purchase.tabItemId.equals(tabItemId) & + purchase.removedAt.isNull() & + switch (order) { + null => const Constant(true), + (orderId: null) => purchase.orderId.isNull(), + (orderId: final String id) => purchase.orderId.equals(id), + }, + )) + .get(); + } + + /// Marks the most recent active purchases (of [order], see + /// [_removablePurchases]) as removed, keeping them in the audit trail. Future _removeLatestPurchases( String tabItemId, int count, { DateTime? removedAt, + OrderRef? order, }) async { final latest = await (database.select(database.tabItemPurchases) ..where( (purchase) => purchase.tabItemId.equals(tabItemId) & - purchase.removedAt.isNull(), + purchase.removedAt.isNull() & + switch (order) { + null => const Constant(true), + (orderId: null) => purchase.orderId.isNull(), + (orderId: final String id) => purchase.orderId.equals(id), + }, ) ..orderBy([ (purchase) => OrderingTerm.desc(purchase.purchasedAt), @@ -303,11 +349,13 @@ class DriftBarTabService implements BarTabService { ); } + /// Returns the id of the event. + /// /// Queues a `line_added` ([delta] > 0) or `line_removed` ([delta] < 0) /// event and moves the stock of [productId], the product the line was /// created from, the other way. Every line event goes through here, so a /// line event and its stock change can never drift apart. - Future _recordLineChange({ + Future _recordLineChange({ required String tabId, required String customerName, required String lineId, @@ -315,22 +363,32 @@ class DriftBarTabService implements BarTabService { required String productName, required int unitPriceInCents, required int delta, + required OrderRef? order, }) async { - final lineEvent = delta > 0 - ? eventFactory.lineAdded - : eventFactory.lineRemoved; - await _recordEvent( - lineEvent( - tabId: tabId, - customerName: customerName, - lineId: lineId, - productId: productId, - productName: productName, - unitPriceInCents: unitPriceInCents, - quantity: delta.abs(), - ), - ); + final event = delta > 0 + ? eventFactory.lineAdded( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: delta, + orderId: order?.orderId, + ) + : eventFactory.lineRemoved( + tabId: tabId, + customerName: customerName, + lineId: lineId, + productId: productId, + productName: productName, + unitPriceInCents: unitPriceInCents, + quantity: -delta, + order: order, + ); + await _recordEvent(event); await database.changeTrackedStock(productId, -delta); + return event.id; } Future _incrementTabVersion(String tabId) async { @@ -377,9 +435,16 @@ class DriftBarTabService implements BarTabService { } @override - Future createTab({required String customerName}) async { + Future createTab({ + required String customerName, + String? email, + }) async { final id = _uuid.v4(); final name = _validateCustomerName(customerName); + final trimmedEmail = email?.trim(); + final cleanEmail = trimmedEmail == null || trimmedEmail.isEmpty + ? null + : trimmedEmail; final openedAt = DateTime.now(); await database.transaction(() async { @@ -389,6 +454,7 @@ class DriftBarTabService implements BarTabService { BarTabsCompanion.insert( id: id, customerName: name, + email: Value(cleanEmail), openedAt: openedAt, version: const Value(1), ), @@ -398,6 +464,7 @@ class DriftBarTabService implements BarTabService { tabId: id, customerName: name, openedAt: openedAt, + email: cleanEmail, ), ); }); @@ -434,6 +501,7 @@ class DriftBarTabService implements BarTabService { if (tab == null) return; for (final item in tab.items) { + // Without an order: everything on the line, whatever its order await _recordLineChange( tabId: tab.id, customerName: tab.customerName, @@ -442,6 +510,7 @@ class DriftBarTabService implements BarTabService { productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: -item.quantity, + order: null, ); } await _recordEvent( @@ -456,6 +525,7 @@ class DriftBarTabService implements BarTabService { Future addProductToTab({ required String tabId, required Product product, + String? orderId, }) async { await database.transaction(() async { final tabRow = await _getTabRow(tabId); @@ -467,6 +537,7 @@ class DriftBarTabService implements BarTabService { productName: product.name, unitPriceInCents: product.priceInCents, quantity: 1, + orderId: orderId ?? _uuid.v4(), ); await _incrementTabVersion(tabId); }); @@ -485,6 +556,7 @@ class DriftBarTabService implements BarTabService { required String productName, required int unitPriceInCents, required int quantity, + required String orderId, }) async { final existingItem = await (database.select(database.tabItems) @@ -524,10 +596,7 @@ class DriftBarTabService implements BarTabService { ); } - for (var index = 0; index < quantity; index++) { - await _recordPurchase(lineId); - } - await _recordLineChange( + final eventId = await _recordLineChange( tabId: tabRow.id, customerName: tabRow.customerName, lineId: lineId, @@ -535,7 +604,13 @@ class DriftBarTabService implements BarTabService { productName: productName, unitPriceInCents: unitPriceInCents, delta: quantity, + order: (orderId: orderId), ); + // The ids the server gives these units, so they are recognised after a + // sync + for (var index = 0; index < quantity; index++) { + await _recordPurchase(lineId, orderId, id: '$eventId:$index'); + } } /// Taking drinks off changes [tabItemId] itself. Adding is ringing up new @@ -546,6 +621,7 @@ class DriftBarTabService implements BarTabService { Future adjustTabItemQuantity({ required String tabItemId, required int delta, + OrderRef? order, }) async { if (delta == 0) return 0; @@ -570,17 +646,23 @@ class DriftBarTabService implements BarTabService { productName: product?.name ?? item.productName, unitPriceInCents: product?.priceInCents ?? item.unitPriceInCents, quantity: delta, + orderId: order?.orderId ?? _uuid.v4(), ); await _incrementTabVersion(item.tabId); return delta; } - final newQuantity = max(item.quantity + delta, 0); - final actualDelta = newQuantity - item.quantity; + // Only the drinks of the order can be taken off; without an order the + // line's quantity counts, as before orders + final available = order == null + ? item.quantity + : (await _removablePurchases(item.id, order)).length; + final actualDelta = -min(-delta, available); + final newQuantity = item.quantity + actualDelta; if (actualDelta == 0) return 0; - await _removeLatestPurchases(item.id, -actualDelta); + await _removeLatestPurchases(item.id, -actualDelta, order: order); await (database.update(database.tabItems) ..where((row) => row.id.equals(tabItemId))) .write(TabItemsCompanion(quantity: Value(newQuantity))); @@ -594,6 +676,7 @@ class DriftBarTabService implements BarTabService { productName: item.productName, unitPriceInCents: item.unitPriceInCents, delta: actualDelta, + order: order, ); await _incrementTabVersion(item.tabId); @@ -681,6 +764,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: item.unitPriceInCents, purchasedAt: Value(purchase.purchasedAt), removedAt: Value(purchase.removedAt), + orderId: Value(purchase.orderId), ), ]); }); @@ -703,7 +787,9 @@ class DriftBarTabService implements BarTabService { ) ..orderBy([ (item) => OrderingTerm.desc(item.purchasedAt), - (item) => OrderingTerm.desc(item.id), + // Newest first also within one second: the order they were + // stored in, reversed + (item) => OrderingTerm.desc(const CustomExpression('rowid')), ])) .get(); @@ -787,12 +873,25 @@ class DriftBarTabService implements BarTabService { @override Future applyServerState(List tabs) async { await database.transaction(() async { + // A server without orders sends units back without one: they keep the + // order they were rung up in here, so the tab doesn't regroup + final knownOrders = { + for (final purchase in await (database.select( + database.tabItemPurchases, + )..where((row) => row.orderId.isNotNull())).get()) + purchase.id: purchase.orderId!, + }; + + // A server that doesn't know emails yet would wipe the ones entered + // here + final knownEmails = {}; for (final tab in await database.select(database.barTabs).get()) { + if (tab.email case final email?) knownEmails[tab.id] = email; await _deleteTabWithItems(tab.id); } for (final tab in tabs) { - await _insertServerTab(tab); + await _insertServerTab(tab, knownOrders, knownEmails[tab.id]); } // Read here, so changes made while the sync was in flight are included @@ -808,13 +907,18 @@ class DriftBarTabService implements BarTabService { }); } - Future _insertServerTab(ServerTab tab) async { + Future _insertServerTab( + ServerTab tab, + Map knownOrders, + String? knownEmail, + ) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tab.id, customerName: tab.customerName, + email: Value(tab.email ?? knownEmail), openedAt: tab.openedAt, version: Value(tab.version), ), @@ -848,6 +952,7 @@ class DriftBarTabService implements BarTabService { tabItemId: line.id, purchasedAt: unit.addedAt, removedAt: Value(unit.removedAt), + orderId: Value(unit.orderId ?? knownOrders[unit.id]), ), ]); }); @@ -871,7 +976,12 @@ class DriftBarTabService implements BarTabService { final openedAt = DateTime.tryParse( event['openedAt'] as String? ?? '', )?.toLocal(); - await _ensureTab(tabId, customerName, openedAt ?? occurredAt); + await _ensureTab( + tabId, + customerName, + openedAt ?? occurredAt, + email: event['email'] as String?, + ); case TabEventType.tabRenamed: await (database.update(database.barTabs) ..where((tab) => tab.id.equals(tabId))) @@ -885,9 +995,20 @@ class DriftBarTabService implements BarTabService { final quantity = event['quantity'] as int? ?? 0; if (line == null || quantity <= 0) return; - final removed = min(quantity, line.quantity); + // Events from before orders have no `orderId`: the latest of the line + final OrderRef? order = event.containsKey('orderId') + ? (orderId: event['orderId'] as String?) + : null; + final removable = await _removablePurchases(line.id, order); + final removed = min(quantity, removable.length); + if (removed == 0) return; await _setLineQuantity(line.id, line.quantity - removed); - await _removeLatestPurchases(line.id, removed, removedAt: occurredAt); + await _removeLatestPurchases( + line.id, + removed, + removedAt: occurredAt, + order: order, + ); case TabEventType.settled: for (final item in event['items'] as List? ?? []) { await _replaySettledLine(tabId, item as Map); @@ -947,6 +1068,7 @@ class DriftBarTabService implements BarTabService { id: '${event['id']}:$index', tabItemId: line!.id, purchasedAt: occurredAt, + orderId: Value(event['orderId'] as String?), ), ]); }); @@ -998,14 +1120,16 @@ class DriftBarTabService implements BarTabService { Future _ensureTab( String tabId, String customerName, - DateTime openedAt, - ) async { + DateTime openedAt, { + String? email, + }) async { await database .into(database.barTabs) .insert( BarTabsCompanion.insert( id: tabId, customerName: customerName, + email: Value(email), openedAt: openedAt, version: const Value(1), ), @@ -1096,6 +1220,7 @@ class DriftBarTabService implements BarTabService { unitPriceInCents: line.unitPriceInCents, purchasedAt: Value(unit.addedAt), removedAt: Value(unit.removedAt), + orderId: Value(unit.orderId), ), ]); }); diff --git a/lib/services/kooltap_api_client.dart b/lib/services/kooltap_api_client.dart index de97667..edde031 100644 --- a/lib/services/kooltap_api_client.dart +++ b/lib/services/kooltap_api_client.dart @@ -60,9 +60,7 @@ class KoolTapApiClient { this.appVersion, }) : _client = client ?? http.Client(); - Map get _versionHeader => { - 'X-App-Version': ?appVersion, - }; + Map get _versionHeader => {'X-App-Version': ?appVersion}; static const _timeout = Duration(seconds: 15); static const _defaultRetryAfter = Duration(seconds: 60); diff --git a/lib/utils/history_grouping.dart b/lib/utils/history_grouping.dart deleted file mode 100644 index 77b9d7b..0000000 --- a/lib/utils/history_grouping.dart +++ /dev/null @@ -1,48 +0,0 @@ -import '../models/closed_tab_item.dart'; - -class ClosedTabItemDayGroup { - final DateTime day; - final List items; - - const ClosedTabItemDayGroup({required this.day, required this.items}); - - int get itemCount => items.fold( - 0, - (total, item) => total + (item.isRemoved ? 0 : item.quantity), - ); -} - -List groupClosedTabItemsByDay( - Iterable items, { - required DateTime fallbackDate, -}) { - final groups = >{}; - - for (final item in items) { - final localPurchasedAt = (item.purchasedAt ?? fallbackDate).toLocal(); - final day = DateTime( - localPurchasedAt.year, - localPurchasedAt.month, - localPurchasedAt.day, - ); - groups.putIfAbsent(day, () => []).add(item); - } - - for (final group in groups.values) { - group.sort((a, b) { - final aTime = (a.purchasedAt ?? fallbackDate).toLocal(); - final bTime = (b.purchasedAt ?? fallbackDate).toLocal(); - final byTime = bTime.compareTo(aTime); - if (byTime != 0) return byTime; - - return b.id.compareTo(a.id); - }); - } - - final sortedGroups = groups.entries.toList() - ..sort((a, b) => b.key.compareTo(a.key)); - - return sortedGroups - .map((entry) => ClosedTabItemDayGroup(day: entry.key, items: entry.value)) - .toList(); -} diff --git a/lib/utils/tab_orders.dart b/lib/utils/tab_orders.dart new file mode 100644 index 0000000..5c38eeb --- /dev/null +++ b/lib/utils/tab_orders.dart @@ -0,0 +1,219 @@ +import 'package:collection/collection.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/app_localizations.dart'; +import '../models/bar_tab.dart'; +import '../models/closed_tab.dart'; +import '../models/closed_tab_item.dart'; +import '../models/tab_item.dart'; + +/// Drinks rung up without an order (before orders were tracked) that follow +/// each other this closely form one order, as on the server. +const orderGap = Duration(minutes: 5); + +/// One drink, for grouping: [line] is what it is shown as, [lineKey] which +/// drinks are counted on one row. +class OrderDrink { + final L line; + final Object lineKey; + final String id; + final DateTime addedAt; + final String? orderId; + final bool isRemoved; + + const OrderDrink({ + required this.line, + required this.lineKey, + required this.id, + required this.addedAt, + required this.orderId, + required this.isRemoved, + }); +} + +/// The drinks of one line within an order. +class TabOrderRow { + final L line; + + /// Still on the tab (or paid for, in history). + final int quantity; + + /// Taken off again. + final int removed; + + const TabOrderRow({ + required this.line, + required this.quantity, + required this.removed, + }); +} + +/// Drinks ordered together, shown under one "12:32 today" divider. +class TabOrder { + /// Null for drinks from before orders, grouped by time instead. + final String? orderId; + + /// When its first drink was rung up. + final DateTime startedAt; + final List> rows; + + const TabOrder({ + required this.orderId, + required this.startedAt, + required this.rows, + }); + + int get itemCount => rows.fold(0, (total, row) => total + row.quantity); +} + +/// Splits [drinks] into their orders, oldest first. Drinks rung up at the +/// same moment keep the order they are given in. +List> groupOrders(Iterable> drinks) { + final sorted = drinks.toList(); + mergeSort(sorted, compare: (a, b) => a.addedAt.compareTo(b.addedAt)); + + final orders = <_OrderBuilder>[]; + final byId = >{}; + _OrderBuilder? unordered; + DateTime? unorderedLastAt; + + for (final drink in sorted) { + final _OrderBuilder order; + final orderId = drink.orderId; + if (orderId != null) { + order = byId.putIfAbsent(orderId, () { + final created = _OrderBuilder(orderId, drink.addedAt); + orders.add(created); + return created; + }); + } else { + if (unordered == null || + drink.addedAt.difference(unorderedLastAt!) > orderGap) { + unordered = _OrderBuilder(null, drink.addedAt); + orders.add(unordered); + } + unorderedLastAt = drink.addedAt; + order = unordered; + } + order.add(drink); + } + + // Started in the order of their first drink: already oldest first + return [for (final order in orders) order.build()]; +} + +class _OrderBuilder { + final String? orderId; + final DateTime startedAt; + final _rows = {}; + + _OrderBuilder(this.orderId, this.startedAt); + + void add(OrderDrink drink) { + final row = _rows[drink.lineKey]; + _rows[drink.lineKey] = ( + line: row?.line ?? drink.line, + quantity: (row?.quantity ?? 0) + (drink.isRemoved ? 0 : 1), + removed: (row?.removed ?? 0) + (drink.isRemoved ? 1 : 0), + ); + } + + TabOrder build() => TabOrder( + orderId: orderId, + startedAt: startedAt, + rows: [ + for (final row in _rows.values) + TabOrderRow( + line: row.line, + quantity: row.quantity, + removed: row.removed, + ), + ], + ); +} + +/// The orders of an open tab. Without [includeRemoved] only what is still on +/// it: rows and orders whose drinks were all taken off again are left out. +/// Drinks of an item without purchase records count as ordered when the tab +/// was opened. +List> ordersOfTab(BarTab tab, {bool includeRemoved = false}) { + final orders = groupOrders([ + for (final item in tab.items) + if (item.purchases.isEmpty) + for (var index = 0; index < item.quantity; index++) + OrderDrink( + line: item, + lineKey: item.id, + id: '${item.id}-$index', + addedAt: tab.openedAt, + orderId: null, + isRemoved: false, + ) + else + for (final purchase in item.purchases) + OrderDrink( + line: item, + lineKey: item.id, + id: purchase.id, + addedAt: purchase.purchasedAt, + orderId: purchase.orderId, + isRemoved: purchase.isRemoved, + ), + ]); + if (includeRemoved) return orders; + + return [ + for (final order in orders) + if (order.itemCount > 0) + TabOrder( + orderId: order.orderId, + startedAt: order.startedAt, + rows: [ + for (final row in order.rows) + if (row.quantity > 0) row, + ], + ), + ]; +} + +/// The orders of a paid tab, with the drinks taken off before paying. +List> ordersOfClosedTab(ClosedTab tab) { + return groupOrders([ + // The items come newest first; drinks of the same moment keep their + // order, so start from the oldest + for (final item in tab.items.reversed) + for (var index = 0; index < item.quantity; index++) + OrderDrink( + line: item, + lineKey: (item.productId, item.productName, item.unitPriceInCents), + id: '${item.id}-$index', + addedAt: item.purchasedAt ?? tab.closedAt, + orderId: item.orderId, + isRemoved: item.isRemoved, + ), + ]); +} + +/// "12:32 today", "12:32 yesterday", or the date with the time. +String orderTimeLabel( + AppLocalizations l10n, + String locale, + DateTime startedAt, { + DateTime? now, +}) { + final local = startedAt.toLocal(); + final today = now ?? DateTime.now(); + final day = DateTime(local.year, local.month, local.day); + final days = DateTime( + today.year, + today.month, + today.day, + ).difference(day).inDays; + final time = DateFormat.Hm(locale).format(local); + + return switch (days) { + 0 => l10n.orderStartedToday(time), + 1 => l10n.orderStartedYesterday(time), + _ => l10n.orderStartedOn(DateFormat.yMMMd(locale).format(local), time), + }; +} diff --git a/lib/viewmodels/bar_screen_view_model.dart b/lib/viewmodels/bar_screen_view_model.dart index fdd8d1a..83fa83a 100644 --- a/lib/viewmodels/bar_screen_view_model.dart +++ b/lib/viewmodels/bar_screen_view_model.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:uuid/uuid.dart'; import '../models/app_error.dart'; import '../models/bar_tab.dart'; @@ -15,9 +16,20 @@ import '../services/bar_tab_service.dart'; /// up without manual reloads. Actions report to Sentry and rethrow; the view /// decides how to tell the user. class BarScreenViewModel extends ChangeNotifier { - final BarTabService barTabService; + /// Drinks rung up on the same tab within this time of the previous one are + /// one order; a later drink starts a new order. + static const orderTimeout = Duration(minutes: 5); - BarScreenViewModel({required this.barTabService}); + final BarTabService barTabService; + final DateTime Function() _now; + final _uuid = const Uuid(); + + BarScreenViewModel({required this.barTabService, DateTime Function()? now}) + : _now = now ?? DateTime.now; + + /// The order being rung up: selecting a tab (again) or a pause of + /// [orderTimeout] starts a new one. + ({String tabId, String orderId, DateTime lastAddedAt})? _order; StreamSubscription>? _subscription; List _tabs = []; @@ -25,7 +37,30 @@ class BarScreenViewModel extends ChangeNotifier { bool _hasLoaded = false; AppError? _loadError; - List get tabs => _tabs; + /// Tabs opened on this tablet that have no drinks yet. Only kept in memory: + /// after a restart the list starts empty again. + final Set _openedTabIds = {}; + + /// Every tab the venue knows, open or not. + List get allTabs => _tabs; + + /// The tabs on the bar screen: opened here, with drinks on them, or + /// selected. + List get tabs => [ + for (final tab in _tabs) + if (_isOpen(tab)) tab, + ]; + + /// The tabs that can be opened from the Open Tab dialog. + List get closedTabs => [ + for (final tab in _tabs) + if (!_isOpen(tab)) tab, + ]; + + bool _isOpen(BarTab tab) => + tab.itemCount > 0 || + _openedTabIds.contains(tab.id) || + tab.id == _selectedTabId; String? get selectedTabId => _selectedTabId; @@ -55,11 +90,22 @@ class BarScreenViewModel extends ChangeNotifier { } void _onTabs(List tabs) { + // A tab whose drinks are all gone (settled elsewhere, or taken off) is + // closed again; while it is selected it stays on screen + final previousCounts = {for (final tab in _tabs) tab.id: tab.itemCount}; + for (final tab in tabs) { + if ((previousCounts[tab.id] ?? 0) > 0 && tab.itemCount == 0) { + _openedTabIds.remove(tab.id); + } + } + _tabs = tabs; _hasLoaded = true; _loadError = null; - if (selectedTab == null) _selectedTabId = tabs.firstOrNull?.id; + final ids = {for (final tab in tabs) tab.id}; + _openedTabIds.retainAll(ids); + if (!ids.contains(_selectedTabId)) _selectedTabId = null; notifyListeners(); } @@ -75,13 +121,44 @@ class BarScreenViewModel extends ChangeNotifier { void selectTab(String tabId) { _selectedTabId = tabId; + _order = null; notifyListeners(); } - Future createTab(String customerName) { + /// Leaves no tab selected, so products can't be added by accident. + void deselectTab() { + _selectedTabId = null; + _order = null; + notifyListeners(); + } + + /// The order a drink rung up on [tabId] now is part of. + String _orderFor(String tabId) { + final now = _now(); + final order = _order; + final orderId = + order != null && + order.tabId == tabId && + now.difference(order.lastAddedAt) < orderTimeout + ? order.orderId + : _uuid.v4(); + _order = (tabId: tabId, orderId: orderId, lastAddedAt: now); + return orderId; + } + + /// Puts [tabId] on the bar screen and selects it. + void openTab(String tabId) { + _openedTabIds.add(tabId); + selectTab(tabId); + } + + Future createTab(String customerName, {String? email}) { return _guard('createTab', () async { - final tab = await barTabService.createTab(customerName: customerName); - selectTab(tab.id); + final tab = await barTabService.createTab( + customerName: customerName, + email: email, + ); + openTab(tab.id); }); } @@ -102,17 +179,30 @@ class BarScreenViewModel extends ChangeNotifier { await _guard( 'addProductToSelectedTab', - () => barTabService.addProductToTab(tabId: tab.id, product: product), + () => barTabService.addProductToTab( + tabId: tab.id, + product: product, + orderId: _orderFor(tab.id), + ), ); } - Future changeItemQuantity(TabItem item, int delta) async { + /// More drinks go into the current order; taking drinks off takes them off + /// order [orderId], the one they are shown in. + Future changeItemQuantity( + TabItem item, + int delta, + String? orderId, + ) async { if (delta == 0) return; await _guard( 'changeItemQuantity', - () => - barTabService.adjustTabItemQuantity(tabItemId: item.id, delta: delta), + () => barTabService.adjustTabItemQuantity( + tabItemId: item.id, + delta: delta, + order: (orderId: delta > 0 ? _orderFor(item.tabId) : orderId), + ), ); } @@ -126,6 +216,9 @@ class BarScreenViewModel extends ChangeNotifier { 'closeSelectedTab', () => barTabService.closeTab(tab.id, paymentMethod: paymentMethod), ); + // A paid tab goes back to the Open Tab dialog + _openedTabIds.remove(tab.id); + deselectTab(); } Future _guard(String action, Future Function() body) async { diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart index a4b2b55..3755055 100644 --- a/lib/views/admin_screen_view.dart +++ b/lib/views/admin_screen_view.dart @@ -8,7 +8,6 @@ import '../models/remote_command.dart'; import '../utils/navigation.dart'; import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/remote_sync_view_model.dart'; -import 'dialogs/new_tab_dialog.dart'; class AdminScreenView extends StatelessWidget { const AdminScreenView({super.key}); @@ -19,16 +18,6 @@ class AdminScreenView extends StatelessWidget { adminPinViewModel.lock(); } - Future _openNewTab(BuildContext context) async { - final l10n = AppLocalizations.of(context); - final name = await showNewTabDialog(context); - - if (name == null || !context.mounted) return; - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name)))); - } - Future _confirmUnlink(BuildContext context) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( @@ -94,12 +83,6 @@ class AdminScreenView extends StatelessWidget { _AdminSection( title: l10n.adminTools, children: [ - _AdminTile( - icon: Icons.person_add_alt_1_outlined, - title: l10n.openNewTab, - subtitle: l10n.openNewTabDescription, - onTap: () => _openNewTab(context), - ), _AdminTile( icon: Icons.inventory_2_outlined, title: l10n.products, diff --git a/lib/views/bar/bar_screen_view.dart b/lib/views/bar/bar_screen_view.dart index 48cfc09..88fe25f 100644 --- a/lib/views/bar/bar_screen_view.dart +++ b/lib/views/bar/bar_screen_view.dart @@ -18,6 +18,7 @@ import '../../viewmodels/remote_sync_view_model.dart'; import '../../viewmodels/settings_view_model.dart'; import '../dialogs/close_tab_dialog.dart'; import '../dialogs/edit_tab_dialog.dart'; +import '../dialogs/open_tab_dialog.dart'; import '../widgets/venue_title.dart'; import 'product_grid.dart'; import 'tab_panel.dart'; @@ -30,8 +31,11 @@ class BarScreenView extends StatefulWidget { } class _BarScreenViewState extends State { + static const _idleTimeout = Duration(minutes: 5); + bool _showSearch = false; late final StreamSubscription _remoteCloseSubscription; + Timer? _idleTimer; @override void initState() { @@ -41,6 +45,7 @@ class _BarScreenViewState extends State { .read() .tabsClosedRemotely .listen(_showRemoteClose); + _restartIdleTimer(); WidgetsBinding.instance.addPostFrameCallback((_) { UpdateChecker.check(context); @@ -50,9 +55,19 @@ class _BarScreenViewState extends State { @override void dispose() { _remoteCloseSubscription.cancel(); + _idleTimer?.cancel(); super.dispose(); } + /// A tab left open after the bar goes quiet is easy to add to by mistake, + /// so it is put back in the list. + void _restartIdleTimer() { + _idleTimer?.cancel(); + _idleTimer = Timer(_idleTimeout, () { + if (mounted) context.read().deselectTab(); + }); + } + void _showRemoteClose(RemoteSyncEvent event) { if (!mounted) return; @@ -91,6 +106,16 @@ class _BarScreenViewState extends State { } } + Future _openTab() async { + final l10n = AppLocalizations.of(context); + final name = await showOpenTabDialog(context); + + if (name == null || !mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.tabOpened(name)))); + } + void _showSyncProblem({required String title, required String description}) { final l10n = AppLocalizations.of(context); showDialog( @@ -151,150 +176,162 @@ class _BarScreenViewState extends State { (viewModel) => viewModel.venueName, ); - return Scaffold( - appBar: AppBar( - title: VenueTitle( - text: venueName == null || venueName.trim().isEmpty - ? l10n.barTabs - : venueName, - ), - actions: [ - IconButton( - tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, - isSelected: _showSearch, - onPressed: () => setState(() => _showSearch = !_showSearch), - icon: const Icon(Icons.search_rounded), - selectedIcon: const Icon(Icons.search_off_rounded), + // Any touch counts as activity, wherever it lands + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartIdleTimer(), + child: Scaffold( + appBar: AppBar( + title: VenueTitle( + text: venueName == null || venueName.trim().isEmpty + ? l10n.barTabs + : venueName, ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.admin, - onPressed: () => context.push('/admin'), - icon: const Icon(Icons.admin_panel_settings_outlined), - ), - if (kDebugMode) ...[ - const SizedBox(width: 6), - _DebugShortcut( - tooltip: l10n.manageProducts, - icon: Icons.inventory_2_outlined, - location: '/products', + actions: [ + IconButton( + tooltip: l10n.openTab, + onPressed: _openTab, + icon: const Icon(Icons.person_add_alt_1_outlined), ), - const SizedBox(width: 6), - _DebugShortcut( - tooltip: l10n.tabHistory, - icon: Icons.history_rounded, - location: '/history', - ), - ], - if (context.watch().wasRevoked) ...[ const SizedBox(width: 6), IconButton( - tooltip: l10n.deviceAccessRevoked, - color: Theme.of(context).colorScheme.error, + tooltip: _showSearch ? l10n.closeSearch : l10n.searchTabs, + isSelected: _showSearch, + onPressed: () => setState(() => _showSearch = !_showSearch), + icon: const Icon(Icons.search_rounded), + selectedIcon: const Icon(Icons.search_off_rounded), + ), + const SizedBox(width: 6), + IconButton( + tooltip: l10n.admin, onPressed: () => context.push('/admin'), - icon: const Icon(Icons.cloud_off_rounded), + icon: const Icon(Icons.admin_panel_settings_outlined), ), - ], - if (context.watch().updateRequired - case final message?) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.appUpdateRequired, - color: Theme.of(context).colorScheme.error, - onPressed: () => _showUpdateRequired(message), - icon: const Icon(Icons.system_update_rounded), - ), - ], - // A failing sync is otherwise invisible: the bar keeps working - // offline while nothing from the website arrives - if (context.watch().lastError - case final error?) ...[ - const SizedBox(width: 6), - // Offline is normal for a bar; anything else needs attention - if (context.watch().isOffline) - IconButton( - tooltip: l10n.offline, - onPressed: () => _showSyncProblem( - title: l10n.offline, - description: l10n.offlineDescription, - ), - icon: const Icon(Icons.wifi_off_rounded), - ) - else - IconButton( - tooltip: '${l10n.syncProblem}: $error', - color: Theme.of(context).colorScheme.error, - onPressed: () => _showSyncProblem( - title: l10n.syncProblem, - description: '${l10n.syncProblemDescription}\n\n$error', - ), - icon: const Icon(Icons.sync_problem_rounded), + if (kDebugMode) ...[ + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.manageProducts, + icon: Icons.inventory_2_outlined, + location: '/products', ), - ], - const SizedBox(width: 6), - IconButton( - tooltip: l10n.refresh, - onPressed: () => context.read().syncNow(), - icon: const Icon(Icons.refresh_rounded), - ), - const SizedBox(width: 6), - IconButton( - tooltip: l10n.settings, - onPressed: () => context.push('/settings'), - icon: const Icon(Icons.settings), - ), - if (context.watch().isPinSet) ...[ - const SizedBox(width: 6), - IconButton( - tooltip: l10n.logout, - onPressed: _logout, - icon: const Icon(Icons.logout), - ), - ], - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - resizeToAvoidBottomInset: false, - body: Builder( - builder: (context) { - if (viewModel.isLoading) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2.5), - ); - } - - if (viewModel.loadError != null) { - return _LoadError(message: l10n.errorText(viewModel.loadError)); - } - - return Row( - children: [ - Expanded( - flex: 2, - child: ProductGrid( - products: productsViewModel.products, - rows: settings.barGridRows, - hasSelectedTab: viewModel.selectedTab != null, - onProductTap: _addProduct, - ), - ), - Container(width: 1, color: Theme.of(context).dividerColor), - Expanded( - child: TabPanel( - tabs: viewModel.tabs, - selectedTab: viewModel.selectedTab, - selectedTabId: viewModel.selectedTabId, - showSearch: _showSearch, - onTabSelected: viewModel.selectTab, - onItemQuantityChanged: viewModel.changeItemQuantity, - onCloseTabPressed: () => confirmCloseTab(context), - onTabEdited: (tab) => showEditTabDialog(context, tab), - onTabDeleted: (tab) => confirmDeleteTab(context, tab), - ), + const SizedBox(width: 6), + _DebugShortcut( + tooltip: l10n.tabHistory, + icon: Icons.history_rounded, + location: '/history', ), ], - ); - }, + if (context.watch().wasRevoked) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.deviceAccessRevoked, + color: Theme.of(context).colorScheme.error, + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.cloud_off_rounded), + ), + ], + if (context.watch().updateRequired + case final message?) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.appUpdateRequired, + color: Theme.of(context).colorScheme.error, + onPressed: () => _showUpdateRequired(message), + icon: const Icon(Icons.system_update_rounded), + ), + ], + // A failing sync is otherwise invisible: the bar keeps working + // offline while nothing from the website arrives + if (context.watch().lastError + case final error?) ...[ + const SizedBox(width: 6), + // Offline is normal for a bar; anything else needs attention + if (context.watch().isOffline) + IconButton( + tooltip: l10n.offline, + onPressed: () => _showSyncProblem( + title: l10n.offline, + description: l10n.offlineDescription, + ), + icon: const Icon(Icons.wifi_off_rounded), + ) + else + IconButton( + tooltip: '${l10n.syncProblem}: $error', + color: Theme.of(context).colorScheme.error, + onPressed: () => _showSyncProblem( + title: l10n.syncProblem, + description: '${l10n.syncProblemDescription}\n\n$error', + ), + icon: const Icon(Icons.sync_problem_rounded), + ), + ], + const SizedBox(width: 6), + IconButton( + tooltip: l10n.refresh, + onPressed: () => context.read().syncNow(), + icon: const Icon(Icons.refresh_rounded), + ), + const SizedBox(width: 6), + IconButton( + tooltip: l10n.settings, + onPressed: () => context.push('/settings'), + icon: const Icon(Icons.settings), + ), + if (context.watch().isPinSet) ...[ + const SizedBox(width: 6), + IconButton( + tooltip: l10n.logout, + onPressed: _logout, + icon: const Icon(Icons.logout), + ), + ], + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 8), + ), + resizeToAvoidBottomInset: false, + body: Builder( + builder: (context) { + if (viewModel.isLoading) { + return const Center( + child: CircularProgressIndicator(strokeWidth: 2.5), + ); + } + + if (viewModel.loadError != null) { + return _LoadError(message: l10n.errorText(viewModel.loadError)); + } + + return Row( + children: [ + Expanded( + flex: 2, + child: ProductGrid( + products: productsViewModel.products, + rows: settings.barGridRows, + hasSelectedTab: viewModel.selectedTab != null, + onProductTap: _addProduct, + ), + ), + Container(width: 1, color: Theme.of(context).dividerColor), + Expanded( + child: TabPanel( + tabs: viewModel.tabs, + selectedTab: viewModel.selectedTab, + selectedTabId: viewModel.selectedTabId, + showSearch: _showSearch, + onTabSelected: viewModel.selectTab, + onTabDeselected: viewModel.deselectTab, + onItemQuantityChanged: viewModel.changeItemQuantity, + onCloseTabPressed: () => confirmCloseTab(context), + onTabEdited: (tab) => showEditTabDialog(context, tab), + onTabDeleted: (tab) => confirmDeleteTab(context, tab), + ), + ), + ], + ); + }, + ), ), ); } diff --git a/lib/views/bar/selected_tab_details.dart b/lib/views/bar/selected_tab_details.dart index b3739f5..050c737 100644 --- a/lib/views/bar/selected_tab_details.dart +++ b/lib/views/bar/selected_tab_details.dart @@ -4,10 +4,17 @@ import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; import '../../models/tab_item.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import '../widgets/order_divider.dart'; + +/// Changes how many of [item] are on the tab: [delta] > 0 rings up more, +/// [delta] < 0 takes drinks of order [orderId] off. +typedef ItemQuantityChanged = + Future Function(TabItem item, int delta, String? orderId); class SelectedTabDetails extends StatelessWidget { final BarTab tab; - final Future Function(TabItem item, int delta) onItemQuantityChanged; + final ItemQuantityChanged onItemQuantityChanged; final VoidCallback onCloseTabPressed; const SelectedTabDetails({ @@ -44,17 +51,11 @@ class SelectedTabDetails extends StatelessWidget { ], ), ) - : ListView.separated( - itemCount: tab.items.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = tab.items[index]; - - return TabItemRow( - item: item, - onQuantityChanged: onItemQuantityChanged, - ); - }, + : _OrderList( + // Another tab starts with its own orders folded + key: ValueKey(tab.id), + orders: ordersOfTab(tab), + onItemQuantityChanged: onItemQuantityChanged, ), ), const SizedBox(height: 8), @@ -102,13 +103,82 @@ class SelectedTabDetails extends StatelessWidget { } } +/// The tab's orders, oldest first, each under the time it started. +class _OrderList extends StatefulWidget { + final List> orders; + final ItemQuantityChanged onItemQuantityChanged; + + const _OrderList({ + super.key, + required this.orders, + required this.onItemQuantityChanged, + }); + + @override + State<_OrderList> createState() => _OrderListState(); +} + +/// The current (latest) order is open, earlier ones are folded away under +/// their time and total; tapping a header flips it. +class _OrderListState extends State<_OrderList> { + /// Orders opened or closed by hand, by [_key]. + final _toggled = {}; + + String _key(TabOrder order) => + order.orderId ?? order.startedAt.toIso8601String(); + + @override + Widget build(BuildContext context) { + final orders = widget.orders; + final children = []; + for (final (orderIndex, order) in orders.indexed) { + final key = _key(order); + final expanded = _toggled[key] ?? orderIndex == orders.length - 1; + children.add( + OrderDivider( + key: ValueKey('order-$key'), + startedAt: order.startedAt, + totalInCents: order.rows.fold( + 0, + (total, row) => total + row.quantity * row.line.unitPriceInCents, + ), + expanded: expanded, + onTap: () => setState(() => _toggled[key] = !expanded), + ), + ); + if (!expanded) continue; + + for (final (index, row) in order.rows.indexed) { + if (index > 0) children.add(const Divider(height: 1)); + children.add( + TabItemRow( + key: ValueKey('${order.orderId ?? order.startedAt}-${row.line.id}'), + item: row.line, + quantity: row.quantity, + orderId: order.orderId, + onQuantityChanged: widget.onItemQuantityChanged, + ), + ); + } + } + + return ListView(children: children); + } +} + class TabItemRow extends StatelessWidget { final TabItem item; - final Future Function(TabItem item, int delta) onQuantityChanged; + + /// How many of [item] are in this order. + final int quantity; + final String? orderId; + final ItemQuantityChanged onQuantityChanged; const TabItemRow({ super.key, required this.item, + required this.quantity, + required this.orderId, required this.onQuantityChanged, }); @@ -116,7 +186,7 @@ class TabItemRow extends StatelessWidget { final l10n = AppLocalizations.of(context); try { - await onQuantityChanged(item, delta); + await onQuantityChanged(item, delta, orderId); } catch (e) { if (!context.mounted) return; ScaffoldMessenger.of( @@ -145,7 +215,7 @@ class TabItemRow extends StatelessWidget { ), const SizedBox(height: 2), Text( - '${item.quantity} × ${item.unitPriceInCents.asEuro}', + '$quantity × ${item.unitPriceInCents.asEuro}', style: Theme.of(context).textTheme.bodySmall, ), ], @@ -167,7 +237,7 @@ class TabItemRow extends StatelessWidget { SizedBox( width: 22, child: Text( - '${item.quantity}', + '$quantity', textAlign: TextAlign.center, style: const TextStyle(fontWeight: FontWeight.w700), ), @@ -183,7 +253,7 @@ class TabItemRow extends StatelessWidget { SizedBox( width: 72, child: Text( - item.lineTotalInCents.asEuro, + (quantity * item.unitPriceInCents).asEuro, textAlign: TextAlign.end, style: const TextStyle(fontWeight: FontWeight.w700), ), diff --git a/lib/views/bar/tab_panel.dart b/lib/views/bar/tab_panel.dart index 4c7fcc7..0292815 100644 --- a/lib/views/bar/tab_panel.dart +++ b/lib/views/bar/tab_panel.dart @@ -7,7 +7,6 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; -import '../../models/tab_item.dart'; import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; import '../../utils/name_index.dart'; @@ -42,7 +41,8 @@ class TabPanel extends StatefulWidget { final String? selectedTabId; final bool showSearch; final ValueChanged onTabSelected; - final Future Function(TabItem item, int delta) onItemQuantityChanged; + final VoidCallback onTabDeselected; + final ItemQuantityChanged onItemQuantityChanged; final VoidCallback onCloseTabPressed; final Future Function(BarTab) onTabEdited; final Future Function(BarTab) onTabDeleted; @@ -54,6 +54,7 @@ class TabPanel extends StatefulWidget { required this.selectedTabId, required this.showSearch, required this.onTabSelected, + required this.onTabDeselected, required this.onItemQuantityChanged, required this.onCloseTabPressed, required this.onTabEdited, @@ -77,13 +78,23 @@ class _TabPanelState extends State { /// The names-list offset the selected tile was last lined up at. double? _attachedOffset; - @override - void initState() { - super.initState(); + /// The first row of the names list, measured for [_rowExtent]. + final GlobalKey _firstRowKey = GlobalKey(); - // The row height is only known once the list is laid out + /// The height of every row in the names list, gap included. Measured, as + /// a short list can't tell it from its scroll range. + double? _rowExtent; + + /// The row height is only known once the list is laid out (and changes + /// with the text size). + void _measureRow() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) setState(() {}); + if (!mounted) return; + final box = _firstRowKey.currentContext?.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + if (box.size.height != _rowExtent) { + setState(() => _rowExtent = box.size.height); + } }); } @@ -99,11 +110,12 @@ class _TabPanelState extends State { if (tab == null || _focus == _PanelFocus.names) return null; final index = tabs.indexWhere((candidate) => candidate.id == tab.id); - final extent = _tabExtent(tabs.length); + final extent = _rowExtent; if (index == -1 || extent == null) return null; final offset = (index + 1) * extent - _tabRowGap - viewportHeight; - return offset < 0 ? null : offset; + // Rounding can leave a sliver below zero + return offset < -0.5 ? null : max(offset, 0); } void _lineUpSelectedTab(double? offset) { @@ -152,12 +164,12 @@ class _TabPanelState extends State { _searchQuery = ''; } - // Adding products means working on the selected tab + // Adding products, or opening a tab from the Open Tab dialog, means + // working on the selected tab final tab = widget.selectedTab; final oldTab = oldWidget.selectedTab; if (tab != null && - tab.id == oldTab?.id && - tab.itemCount != oldTab?.itemCount) { + (tab.id != oldTab?.id || tab.itemCount != oldTab?.itemCount)) { _focus = _PanelFocus.items; } } @@ -169,14 +181,6 @@ class _TabPanelState extends State { super.dispose(); } - /// Every tab row has the same height, so it follows from the scroll range. - double? _tabExtent(int tabCount) { - if (!_tabsScrollController.hasClients || tabCount == 0) return null; - - final position = _tabsScrollController.position; - return (position.maxScrollExtent + position.viewportDimension) / tabCount; - } - /// Scrolls to the first tab at or after [letter], so a letter without names /// lands on the next one. void _jumpToLetter(String letter, List tabs) { @@ -186,8 +190,8 @@ class _TabPanelState extends State { setState(() => _touchedLetter = letter); } - final extent = _tabExtent(tabs.length); - if (extent == null) return; + final extent = _rowExtent; + if (extent == null || !_tabsScrollController.hasClients) return; final position = _tabsScrollController.position; final start = indexLetters.indexOf(letter); @@ -205,8 +209,8 @@ class _TabPanelState extends State { String? _visibleLetter(List tabs) { if (tabs.isEmpty) return null; - final extent = _tabExtent(tabs.length); - final index = extent == null + final extent = _rowExtent; + final index = extent == null || !_tabsScrollController.hasClients ? 0 : (_tabsScrollController.offset / extent).round().clamp( 0, @@ -226,6 +230,7 @@ class _TabPanelState extends State { _searchQuery.toLowerCase(), ); }).toList()..sort((a, b) => compareByIndex(a.customerName, b.customerName)); + _measureRow(); return DecoratedBox( decoration: BoxDecoration(color: scheme.surfaceContainerLow), @@ -236,7 +241,7 @@ class _TabPanelState extends State { children: [ ListenableBuilder( listenable: _tabsScrollController, - builder: (context, child) => _LetterIndexBar( + builder: (context, child) => LetterIndexBar( availableLetters: { for (final tab in filteredTabs) indexLetterOf(tab.customerName), @@ -262,11 +267,30 @@ class _TabPanelState extends State { ? _PanelFocus.balanced : _focus; final searchHeight = widget.showSearch ? _searchHeight : 0.0; - final namesHeight = switch (focus) { - _PanelFocus.balanced => min(246.0, available / 2), - _PanelFocus.names => available - collapsedItems, - _PanelFocus.items => collapsedNames + searchHeight, - }; + // Without a selected tab there are no items to show, so + // the names get the whole panel + final hasSelection = widget.selectedTab != null; + final selectedIndex = filteredTabs.indexWhere( + (tab) => tab.id == widget.selectedTabId, + ); + final rowExtent = _rowExtent; + final namesHeight = !hasSelection + ? constraints.maxHeight + : switch (focus) { + _PanelFocus.balanced => min(246.0, available / 2), + _PanelFocus.names => available - collapsedItems, + // A tile near the top can't scroll down to its + // items, so the names shrink up to it instead + _PanelFocus.items => + (selectedIndex == -1 || rowExtent == null + ? collapsedNames + : min( + collapsedNames, + (selectedIndex + 1) * rowExtent - + _tabRowGap, + )) + + searchHeight, + }; final attachOffset = _attachOffset( filteredTabs, @@ -335,12 +359,19 @@ class _TabPanelState extends State { }, child: OpenTabsList( controller: _tabsScrollController, + firstRowKey: _firstRowKey, tabs: filteredTabs, selectedTabId: widget.selectedTabId, attachedTabId: attached ? widget.selectedTabId : null, onTabSelected: (id) { + // Tapping the open tab again closes it + if (id == widget.selectedTabId) { + _focusOn(_PanelFocus.names); + widget.onTabDeselected(); + return; + } _focusOn(_PanelFocus.items); widget.onTabSelected(id); }, @@ -352,7 +383,8 @@ class _TabPanelState extends State { ], ), ), - if (!attached) const Divider(height: dividerHeight), + if (!attached && hasSelection) + const Divider(height: dividerHeight), Expanded( child: Listener( onPointerDown: (_) => _focusOn(_PanelFocus.items), @@ -434,6 +466,9 @@ class _TabPanelState extends State { class OpenTabsList extends StatelessWidget { final ScrollController? controller; + + /// Put on the first row, so its height can be measured. + final Key? firstRowKey; final List tabs; final String? selectedTabId; @@ -446,6 +481,7 @@ class OpenTabsList extends StatelessWidget { const OpenTabsList({ super.key, this.controller, + this.firstRowKey, required this.tabs, required this.selectedTabId, this.attachedTabId, @@ -477,6 +513,7 @@ class OpenTabsList extends StatelessWidget { final attached = tab.id == attachedTabId; return _TabRow( + key: index == 0 ? firstRowKey : null, child: ClipRRect( borderRadius: attached ? _attachedTileRadius @@ -520,7 +557,7 @@ class OpenTabsList extends StatelessWidget { class _TabRow extends StatelessWidget { final Widget child; - const _TabRow({required this.child}); + const _TabRow({super.key, required this.child}); @override Widget build(BuildContext context) { @@ -603,14 +640,16 @@ class _TabCard extends StatelessWidget { } } -class _LetterIndexBar extends StatefulWidget { +/// The A–Z strip beside a list of names: tap or slide to jump to a letter. +class LetterIndexBar extends StatefulWidget { final Set availableLetters; final String? activeLetter; final bool isTouched; final ValueChanged onLetterSelected; final VoidCallback onReleased; - const _LetterIndexBar({ + const LetterIndexBar({ + super.key, required this.availableLetters, required this.activeLetter, required this.isTouched, @@ -619,10 +658,10 @@ class _LetterIndexBar extends StatefulWidget { }); @override - State<_LetterIndexBar> createState() => _LetterIndexBarState(); + State createState() => _LetterIndexBarState(); } -class _LetterIndexBarState extends State<_LetterIndexBar> +class _LetterIndexBarState extends State with SingleTickerProviderStateMixin { static final _zIndex = indexLetters.indexOf('Z'); diff --git a/lib/views/dialogs/close_tab_dialog.dart b/lib/views/dialogs/close_tab_dialog.dart index 5f2dbc4..688994e 100644 --- a/lib/views/dialogs/close_tab_dialog.dart +++ b/lib/views/dialogs/close_tab_dialog.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; @@ -6,6 +9,7 @@ import '../../l10n/app_localizations.dart'; import '../../models/payment_method.dart'; import '../../utils/money.dart'; import '../../viewmodels/bar_screen_view_model.dart'; +import '../widgets/money_rain.dart'; import '../widgets/slide_confirm.dart'; Future confirmCloseTab(BuildContext context) async { @@ -73,6 +77,9 @@ Future confirmCloseTab(BuildContext context) async { try { await viewModel.closeSelectedTab(paymentMethod: paymentMethod); + _playChaChing(); + // Make it rain, but only once in a hundred tabs. + if (context.mounted && Random().nextInt(25) == 0) showMoneyRain(context); } catch (_) { if (!context.mounted) return; ScaffoldMessenger.of( @@ -81,6 +88,16 @@ Future confirmCloseTab(BuildContext context) async { } } +final _chaChingPlayer = AudioPlayer(); + +/// Plays the till sound once a tab is paid; a sound that fails to play never +/// gets in the way of closing the tab. +void _playChaChing() { + _chaChingPlayer + .play(AssetSource('Cha-Ching.mp3')) + .catchError((Object e) => debugPrint('Could not play cha-ching: $e')); +} + const _payconiqQrAsset = 'assets/payconiq/qr.png'; /// Shows the Payconiq QR code for the customer to scan. Resolves to true diff --git a/lib/views/dialogs/new_tab_dialog.dart b/lib/views/dialogs/new_tab_dialog.dart deleted file mode 100644 index 3ae8eab..0000000 --- a/lib/views/dialogs/new_tab_dialog.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../l10n/app_localizations.dart'; -import '../../viewmodels/bar_screen_view_model.dart'; -import 'duplicate_tab_name_dialog.dart'; - -/// Returns the name of the opened tab, or null when nothing was opened. -Future showNewTabDialog(BuildContext context) async { - final l10n = AppLocalizations.of(context); - final controller = TextEditingController(); - - final name = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.openNewTab), - content: TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration(labelText: l10n.customerGroupName), - onSubmitted: (value) { - Navigator.of(dialogContext).pop(value); - }, - ), - actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: Text(l10n.cancel), - ), - FilledButton( - onPressed: () { - Navigator.of(dialogContext).pop(controller.text); - }, - child: Text(l10n.openTab), - ), - ], - ); - }, - ); - - final trimmed = name?.trim() ?? ''; - if (trimmed.isEmpty || !context.mounted) return null; - - final viewModel = context.read(); - if (viewModel.hasOpenTabNamed(trimmed) && - !await confirmDuplicateTabName(context, trimmed)) { - return null; - } - - try { - await viewModel.createTab(trimmed); - return trimmed; - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); - } - return null; - } -} diff --git a/lib/views/dialogs/open_tab_dialog.dart b/lib/views/dialogs/open_tab_dialog.dart new file mode 100644 index 0000000..ec511f6 --- /dev/null +++ b/lib/views/dialogs/open_tab_dialog.dart @@ -0,0 +1,328 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/bar_tab.dart'; +import '../../utils/name_index.dart'; +import '../../viewmodels/bar_screen_view_model.dart'; +import '../bar/tab_panel.dart'; +import 'duplicate_tab_name_dialog.dart'; + +/// What the Open Tab dialog was closed with. +sealed class _Choice {} + +class _ExistingTab extends _Choice { + final BarTab tab; + + _ExistingTab(this.tab); +} + +class _NewCustomer extends _Choice { + /// The search text, as a start for the name. + final String name; + + _NewCustomer(this.name); +} + +/// Lets the bartender pick a tab that isn't open yet, or add a new customer. +/// Returns the name of the opened tab, or null when nothing was opened. +Future showOpenTabDialog(BuildContext context) async { + final viewModel = context.read(); + + final choice = await showDialog<_Choice>( + context: context, + builder: (dialogContext) => _OpenTabDialog(viewModel: viewModel), + ); + + if (!context.mounted) return null; + + switch (choice) { + case null: + return null; + case _ExistingTab(:final tab): + viewModel.openTab(tab.id); + return tab.customerName; + case _NewCustomer(:final name): + return _addCustomer(context, initialName: name); + } +} + +class _OpenTabDialog extends StatefulWidget { + final BarScreenViewModel viewModel; + + const _OpenTabDialog({required this.viewModel}); + + @override + State<_OpenTabDialog> createState() => _OpenTabDialogState(); +} + +/// Every row is this tall, so the letter index knows where each name is. +const _rowExtent = 64.0; + +class _OpenTabDialogState extends State<_OpenTabDialog> { + final ScrollController _scrollController = ScrollController(); + String _query = ''; + + /// The letter under the finger while the index bar is pressed. + String? _touchedLetter; + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + /// Scrolls to the first tab at or after [letter], so a letter without names + /// lands on the next one. + void _jumpToLetter(String letter, List tabs) { + if (letter != _touchedLetter) { + HapticFeedback.selectionClick(); + setState(() => _touchedLetter = letter); + } + if (!_scrollController.hasClients) return; + + final position = _scrollController.position; + final start = indexLetters.indexOf(letter); + final index = tabs.indexWhere( + (tab) => indexLetters.indexOf(indexLetterOf(tab.customerName)) >= start, + ); + + _scrollController.jumpTo( + index == -1 + ? position.maxScrollExtent + : min(index * _rowExtent, position.maxScrollExtent), + ); + } + + String? _visibleLetter(List tabs) { + if (tabs.isEmpty) return null; + final offset = _scrollController.hasClients ? _scrollController.offset : 0; + final index = (offset / _rowExtent).round().clamp(0, tabs.length - 1); + return indexLetterOf(tabs[index].customerName); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return AlertDialog( + title: Text(l10n.openTab), + content: SizedBox( + width: 420, + height: 480, + child: Column( + children: [ + TextField( + onChanged: (value) => setState(() => _query = value), + decoration: InputDecoration( + hintText: l10n.searchByName, + prefixIcon: const Icon(Icons.search_rounded, size: 20), + isDense: true, + ), + ), + const SizedBox(height: 12), + Expanded( + // Tabs opened or closed elsewhere while the dialog is up + child: ListenableBuilder( + listenable: widget.viewModel, + builder: (context, _) { + final query = _query.trim().toLowerCase(); + final available = widget.viewModel.closedTabs; + final tabs = + available + .where( + (tab) => + tab.customerName.toLowerCase().contains(query), + ) + .toList() + ..sort( + (a, b) => + compareByIndex(a.customerName, b.customerName), + ); + + if (tabs.isEmpty) { + return Center( + child: Text( + available.isEmpty ? l10n.allTabsOpen : l10n.noTabsFound, + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + final list = ListView.builder( + controller: _scrollController, + itemCount: tabs.length, + itemExtent: _rowExtent, + itemBuilder: (context, index) { + final tab = tabs[index]; + + return ListTile( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + leading: CircleAvatar( + backgroundColor: scheme.primary.withValues( + alpha: 0.12, + ), + foregroundColor: scheme.primary, + child: Text(indexLetterOf(tab.customerName)), + ), + title: Text( + tab.customerName, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: tab.email == null + ? null + : Text( + tab.email!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => + Navigator.of(context).pop(_ExistingTab(tab)), + ); + }, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListenableBuilder( + listenable: _scrollController, + builder: (context, _) => LetterIndexBar( + availableLetters: { + for (final tab in tabs) + indexLetterOf(tab.customerName), + }, + activeLetter: _touchedLetter ?? _visibleLetter(tabs), + isTouched: _touchedLetter != null, + onLetterSelected: (letter) => + _jumpToLetter(letter, tabs), + onReleased: () => + setState(() => _touchedLetter = null), + ), + ), + const SizedBox(width: 8), + Expanded(child: list), + ], + ); + }, + ), + ), + ], + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.cancel), + ), + FilledButton.icon( + onPressed: () => + Navigator.of(context).pop(_NewCustomer(_query.trim())), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: Text(l10n.newCustomer), + ), + ], + ); + } +} + +final _emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); + +/// Asks for a name and an optional email, then opens a tab for them. +Future _addCustomer( + BuildContext context, { + required String initialName, +}) async { + final l10n = AppLocalizations.of(context); + final nameController = TextEditingController(text: initialName); + final emailController = TextEditingController(); + final formKey = GlobalKey(); + + final customer = await showDialog<({String name, String? email})>( + context: context, + builder: (dialogContext) { + void submit() { + if (!formKey.currentState!.validate()) return; + final email = emailController.text.trim(); + Navigator.of(dialogContext).pop(( + name: nameController.text.trim(), + email: email.isEmpty ? null : email, + )); + } + + return AlertDialog( + title: Text(l10n.newCustomer), + content: SizedBox( + width: 420, + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: nameController, + autofocus: true, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.customerGroupName, + ), + validator: (value) => + (value ?? '').trim().isEmpty ? l10n.nameRequired : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: emailController, + keyboardType: TextInputType.emailAddress, + autocorrect: false, + decoration: InputDecoration(labelText: l10n.emailOptional), + validator: (value) { + final email = (value ?? '').trim(); + return email.isEmpty || _emailPattern.hasMatch(email) + ? null + : l10n.invalidEmail; + }, + onFieldSubmitted: (_) => submit(), + ), + ], + ), + ), + ), + actionsPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(l10n.cancel), + ), + FilledButton(onPressed: submit, child: Text(l10n.openTab)), + ], + ); + }, + ); + + if (customer == null || !context.mounted) return null; + + final viewModel = context.read(); + if (viewModel.hasOpenTabNamed(customer.name) && + !await confirmDuplicateTabName(context, customer.name)) { + return null; + } + + try { + await viewModel.createTab(customer.name, email: customer.email); + return customer.name; + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.couldNotCreateTabMessage))); + } + return null; + } +} diff --git a/lib/views/widgets/closed_tab_card.dart b/lib/views/widgets/closed_tab_card.dart index 9fc09d8..2b6785f 100644 --- a/lib/views/widgets/closed_tab_card.dart +++ b/lib/views/widgets/closed_tab_card.dart @@ -6,8 +6,9 @@ import '../../models/closed_tab.dart'; import '../../models/closed_tab_item.dart'; import '../../models/payment_method.dart'; import '../../utils/easter_eggs.dart'; -import '../../utils/history_grouping.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_history_list.dart'; class ClosedTabCard extends StatefulWidget { final ClosedTab closedTab; @@ -35,10 +36,6 @@ class _ClosedTabCardState extends State { final l10n = AppLocalizations.of(context); final locale = Localizations.localeOf(context).toLanguageTag(); final dateFormat = DateFormat.yMMMd(locale).add_jm(); - final dayGroups = groupClosedTabItemsByDay( - closedTab.items, - fallbackDate: closedTab.closedAt, - ); final scheme = Theme.of(context).colorScheme; return Container( @@ -159,16 +156,12 @@ class _ClosedTabCardState extends State { children: [ const Divider(height: 1), const SizedBox(height: 8), - for (var index = 0; index < dayGroups.length; index++) - _ClosedTabDayDropdown( - key: ValueKey( - '${dayGroups[index].items.first.closedTabId}-' - '${dayGroups[index].day.toIso8601String()}', - ), - group: dayGroups[index], - fallbackDate: closedTab.closedAt, - initiallyExpanded: index == 0, - ), + OrderHistoryList( + orders: ordersOfClosedTab(closedTab), + productName: (ClosedTabItem item) => item.productName, + unitPriceInCents: (ClosedTabItem item) => + item.unitPriceInCents, + ), ], ), ), @@ -179,213 +172,3 @@ class _ClosedTabCardState extends State { ); } } - -class _ClosedTabDayDropdown extends StatefulWidget { - final ClosedTabItemDayGroup group; - final DateTime fallbackDate; - final bool initiallyExpanded; - - const _ClosedTabDayDropdown({ - super.key, - required this.group, - required this.fallbackDate, - required this.initiallyExpanded, - }); - - @override - State<_ClosedTabDayDropdown> createState() => _ClosedTabDayDropdownState(); -} - -class _ClosedTabDayDropdownState extends State<_ClosedTabDayDropdown> { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final dayFormat = DateFormat.yMMMMd(locale); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - decoration: BoxDecoration( - color: _expanded - ? scheme.primary.withValues(alpha: 0.08) - : scheme.onSurface.withValues(alpha: 0.035), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: _expanded - ? scheme.primary.withValues(alpha: 0.28) - : scheme.onSurface.withValues(alpha: 0.08), - ), - ), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Row( - children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: scheme.primary.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(9), - ), - child: Icon( - Icons.calendar_today_rounded, - size: 16, - color: scheme.primary, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - dayFormat.format(widget.group.day), - style: const TextStyle( - fontWeight: FontWeight.w700, - ), - ), - Text( - l10n.tabItemCount(widget.group.itemCount), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - Icon( - _expanded - ? Icons.keyboard_arrow_up_rounded - : Icons.keyboard_arrow_down_rounded, - color: _expanded - ? scheme.primary - : scheme.onSurface.withValues(alpha: 0.5), - ), - ], - ), - ), - ), - ), - AnimatedSize( - duration: const Duration(milliseconds: 160), - curve: Curves.easeOut, - child: _expanded - ? Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: Column( - children: [ - Divider( - height: 1, - color: scheme.primary.withValues(alpha: 0.18), - ), - for (final item in widget.group.items) - _ClosedTabItemRow( - item: item, - purchasedAt: - item.purchasedAt ?? widget.fallbackDate, - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], - ), - ), - ); - } -} - -class _ClosedTabItemRow extends StatelessWidget { - final ClosedTabItem item; - final DateTime purchasedAt; - - const _ClosedTabItemRow({required this.item, required this.purchasedAt}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final timeFormat = DateFormat.jm(locale); - final unitPrice = NumberFormat.simpleCurrency( - locale: locale, - ).format(item.unitPriceInCents / 100); - final lineTotal = NumberFormat.simpleCurrency( - locale: locale, - ).format(item.lineTotalInCents / 100); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: item.isRemoved - ? scheme.onSurface.withValues(alpha: 0.5) - : scheme.onSurface, - decoration: item.isRemoved - ? TextDecoration.lineThrough - : null, - ), - ), - Text( - timeFormat.format(purchasedAt), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - if (item.isRemoved) - Text( - l10n.removed, - style: TextStyle( - color: scheme.error, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ) - else ...[ - Text( - '${item.quantity} × $unitPrice', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(width: 12), - SizedBox( - width: 64, - child: Text( - lineTotal, - textAlign: TextAlign.end, - style: TextStyle( - fontWeight: FontWeight.w700, - color: scheme.onSurface, - ), - ), - ), - ], - ], - ), - ); - } -} diff --git a/lib/views/widgets/money_rain.dart b/lib/views/widgets/money_rain.dart new file mode 100644 index 0000000..e45fd7f --- /dev/null +++ b/lib/views/widgets/money_rain.dart @@ -0,0 +1,135 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +const _emojis = ['💲', '🤑', '💸', '💰', '💵', '💶', '💷']; +const _dropCount = 160; +const _duration = Duration(seconds: 3); + +/// Lets money emojis rain down over the whole screen for three seconds, for +/// when a tab gets paid. Taps go straight through to the app underneath. +void showMoneyRain(BuildContext context) { + final overlay = Overlay.of(context, rootOverlay: true); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (_) => _MoneyRain(onDone: () => entry.remove()), + ); + overlay.insert(entry); +} + +class _MoneyRain extends StatefulWidget { + final VoidCallback onDone; + + const _MoneyRain({required this.onDone}); + + @override + State<_MoneyRain> createState() => _MoneyRainState(); +} + +class _MoneyRainState extends State<_MoneyRain> + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: _duration, + )..forward().whenComplete(widget.onDone); + + final _drops = List.generate(_dropCount, (_) => _Drop.random(Random())); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: CustomPaint( + size: Size.infinite, + painter: _MoneyRainPainter(_controller, _drops), + ), + ); + } +} + +/// One falling emoji. Times are fractions of the whole rain, so every drop +/// has left the screen by the time the rain ends. +class _Drop { + final int emoji; + final double x; + final double start; + final double fall; + final double size; + final double spin; + final double wobble; + + const _Drop({ + required this.emoji, + required this.x, + required this.start, + required this.fall, + required this.size, + required this.spin, + required this.wobble, + }); + + factory _Drop.random(Random random) { + final fall = 0.3 + random.nextDouble() * 0.17; + return _Drop( + emoji: random.nextInt(_emojis.length), + x: random.nextDouble(), + start: random.nextDouble() * (1 - fall), + fall: fall, + size: 28 + random.nextDouble() * 36, + spin: (random.nextDouble() - 0.5) * 4 * pi, + wobble: random.nextDouble() * 2 * pi, + ); + } +} + +class _MoneyRainPainter extends CustomPainter { + static const _baseSize = 64.0; + + final Animation animation; + final List<_Drop> drops; + final List _glyphs = [ + for (final emoji in _emojis) + TextPainter( + text: TextSpan( + text: emoji, + style: const TextStyle(fontSize: _baseSize), + ), + textDirection: TextDirection.ltr, + )..layout(), + ]; + + _MoneyRainPainter(this.animation, this.drops) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + final t = animation.value; + + for (final drop in drops) { + final progress = (t - drop.start) / drop.fall; + if (progress < 0 || progress > 1) continue; + + final glyph = _glyphs[drop.emoji]; + final scale = drop.size / _baseSize; + final dx = + drop.x * size.width + sin(progress * 3 * pi + drop.wobble) * 24; + final dy = -drop.size + progress * (size.height + drop.size * 2); + + canvas + ..save() + ..translate(dx, dy) + ..rotate(drop.spin * progress) + ..scale(scale); + glyph.paint(canvas, Offset(-glyph.width / 2, -glyph.height / 2)); + canvas.restore(); + } + } + + @override + bool shouldRepaint(_MoneyRainPainter oldDelegate) => + oldDelegate.drops != drops; +} diff --git a/lib/views/widgets/open_tab_history_card.dart b/lib/views/widgets/open_tab_history_card.dart index cfb9bd7..91dbfc9 100644 --- a/lib/views/widgets/open_tab_history_card.dart +++ b/lib/views/widgets/open_tab_history_card.dart @@ -3,9 +3,11 @@ import 'package:intl/intl.dart'; import '../../l10n/app_localizations.dart'; import '../../models/bar_tab.dart'; -import '../../models/tab_item_purchase.dart'; +import '../../models/tab_item.dart'; import '../../utils/easter_eggs.dart'; import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_history_list.dart'; class OpenTabHistoryCard extends StatefulWidget { final BarTab tab; @@ -19,42 +21,6 @@ class OpenTabHistoryCard extends StatefulWidget { class _OpenTabHistoryCardState extends State { bool _expanded = false; - List<_OpenPurchase> _purchases(BarTab tab) { - final purchases = <_OpenPurchase>[]; - - for (final item in tab.items) { - final records = item.purchases.isEmpty - ? List.generate( - item.quantity, - (index) => TabItemPurchase( - id: '${item.id}-$index', - tabItemId: item.id, - purchasedAt: tab.openedAt, - ), - ) - : item.purchases; - - purchases.addAll( - records.map( - (purchase) => _OpenPurchase( - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - purchasedAt: purchase.purchasedAt, - isRemoved: purchase.isRemoved, - ), - ), - ); - } - - purchases.sort((a, b) { - final byTime = b.purchasedAt.compareTo(a.purchasedAt); - if (byTime != 0) return byTime; - - return b.productName.compareTo(a.productName); - }); - return purchases; - } - @override Widget build(BuildContext context) { final tab = widget.tab; @@ -163,9 +129,12 @@ class _OpenTabHistoryCardState extends State { children: [ const Divider(height: 1), const SizedBox(height: 8), - ..._purchases( - tab, - ).map((purchase) => _OpenPurchaseRow(purchase: purchase)), + OrderHistoryList( + orders: ordersOfTab(tab, includeRemoved: true), + productName: (TabItem item) => item.productName, + unitPriceInCents: (TabItem item) => + item.unitPriceInCents, + ), ], ), ), @@ -178,81 +147,3 @@ class _OpenTabHistoryCardState extends State { ); } } - -class _OpenPurchase { - final String productName; - final int unitPriceInCents; - final DateTime purchasedAt; - final bool isRemoved; - - const _OpenPurchase({ - required this.productName, - required this.unitPriceInCents, - required this.purchasedAt, - required this.isRemoved, - }); -} - -class _OpenPurchaseRow extends StatelessWidget { - final _OpenPurchase purchase; - - const _OpenPurchaseRow({required this.purchase}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context); - final locale = Localizations.localeOf(context).toLanguageTag(); - final dateFormat = DateFormat.yMMMd(locale).add_jm(); - final unitPrice = NumberFormat.simpleCurrency( - locale: locale, - ).format(purchase.unitPriceInCents / 100); - final scheme = Theme.of(context).colorScheme; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - purchase.productName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - color: purchase.isRemoved - ? scheme.onSurface.withValues(alpha: 0.5) - : scheme.onSurface, - decoration: purchase.isRemoved - ? TextDecoration.lineThrough - : null, - ), - ), - Text( - dateFormat.format(purchase.purchasedAt), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ), - if (purchase.isRemoved) - Text( - l10n.removed, - style: TextStyle( - color: scheme.error, - fontSize: 12, - fontWeight: FontWeight.w700, - ), - ) - else - Text( - '1 × $unitPrice', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - ); - } -} diff --git a/lib/views/widgets/order_divider.dart b/lib/views/widgets/order_divider.dart new file mode 100644 index 0000000..6c87f23 --- /dev/null +++ b/lib/views/widgets/order_divider.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; + +/// The line above the drinks of one order: "—— 12:32 today ——". With +/// [totalInCents] it adds what the order costs; with [onTap] it opens and +/// closes the order, a chevron showing whether it is [expanded]. +class OrderDivider extends StatelessWidget { + final DateTime startedAt; + final int? totalInCents; + final bool expanded; + final VoidCallback? onTap; + + const OrderDivider({ + super.key, + required this.startedAt, + this.totalInCents, + this.expanded = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locale = Localizations.localeOf(context).toLanguageTag(); + final scheme = Theme.of(context).colorScheme; + final line = Expanded( + child: Divider(color: scheme.onSurface.withValues(alpha: 0.18)), + ); + final label = [ + orderTimeLabel(l10n, locale, startedAt), + if (totalInCents != null) totalInCents!.asEuro, + ].join(' – '); + final style = Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: FontWeight.w700, + color: scheme.onSurface.withValues(alpha: 0.6), + ); + + final row = Padding( + padding: const EdgeInsets.only(top: 6, bottom: 2), + child: Row( + children: [ + line, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: style), + if (onTap != null) ...[ + const SizedBox(width: 2), + Icon( + expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 18, + color: style?.color, + ), + ], + ], + ), + ), + line, + ], + ), + ); + + if (onTap == null) return row; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: row, + ); + } +} diff --git a/lib/views/widgets/order_history_list.dart b/lib/views/widgets/order_history_list.dart new file mode 100644 index 0000000..f885501 --- /dev/null +++ b/lib/views/widgets/order_history_list.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../utils/money.dart'; +import '../../utils/tab_orders.dart'; +import 'order_divider.dart'; + +/// The drinks of a tab in history, per order: "2 × Duvel € 8,00", with the +/// drinks taken off again struck through. +class OrderHistoryList extends StatelessWidget { + final List> orders; + final String Function(L line) productName; + final int Function(L line) unitPriceInCents; + + const OrderHistoryList({ + super.key, + required this.orders, + required this.productName, + required this.unitPriceInCents, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final order in orders) ...[ + OrderDivider(startedAt: order.startedAt), + for (final row in order.rows) ...[ + if (row.quantity > 0) + _HistoryRow( + name: productName(row.line), + quantity: row.quantity, + unitPriceInCents: unitPriceInCents(row.line), + ), + if (row.removed > 0) + _HistoryRow( + name: productName(row.line), + quantity: row.removed, + unitPriceInCents: unitPriceInCents(row.line), + removed: true, + ), + ], + ], + ], + ); + } +} + +class _HistoryRow extends StatelessWidget { + final String name; + final int quantity; + final int unitPriceInCents; + final bool removed; + + const _HistoryRow({ + required this.name, + required this.quantity, + required this.unitPriceInCents, + this.removed = false, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final scheme = Theme.of(context).colorScheme; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Expanded( + child: Text( + '$quantity × $name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + color: removed + ? scheme.onSurface.withValues(alpha: 0.5) + : scheme.onSurface, + decoration: removed ? TextDecoration.lineThrough : null, + ), + ), + ), + const SizedBox(width: 12), + if (removed) + Text( + l10n.removedCount(quantity), + style: TextStyle( + color: scheme.error, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ) + else + Text( + (quantity * unitPriceInCents).asEuro, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 499475a..046a6c3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -970,6 +1026,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6799079..0eb7584 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: kooltap description: "KoolTap: a tablet point-of-sale app for running bar tabs." publish_to: 'none' -version: 1.4.0+1 +version: 1.4.4+0 environment: sdk: ^3.12.2 @@ -13,6 +13,7 @@ dependencies: flutter_localizations: sdk: flutter + audioplayers: ^6.8.1 collection: ^1.19.1 crypto: ^3.0.0 cupertino_icons: ^1.0.8 @@ -50,6 +51,7 @@ flutter: uses-material-design: true assets: + - assets/Cha-Ching.mp3 - assets/icons/payconic.svg - assets/payconiq/ - assets/products/ diff --git a/test/bar_screen_view_model_test.dart b/test/bar_screen_view_model_test.dart index c33041a..eaf2401 100644 --- a/test/bar_screen_view_model_test.dart +++ b/test/bar_screen_view_model_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/payment_method.dart'; import 'package:kooltap/models/tab_item.dart'; import 'package:kooltap/services/bar_tab_service.dart'; import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; @@ -9,12 +10,22 @@ import 'package:mocktail/mocktail.dart'; class MockBarTabService extends Mock implements BarTabService {} -BarTab _tab(String id) { +BarTab _tab(String id, {int drinks = 0}) { return BarTab( id: id, customerName: id, openedAt: DateTime(2026), - items: const [], + items: [ + if (drinks > 0) + TabItem( + id: '$id-item', + tabId: id, + productId: 'prod-1', + productName: 'Beer', + quantity: drinks, + unitPriceInCents: 400, + ), + ], ); } @@ -38,7 +49,7 @@ void main() { await openTabs.close(); }); - test('shows tabs from the stream and selects the first one', () async { + test('starts with no open tabs and nothing selected', () async { viewModel.ensureLoaded(); expect(viewModel.isLoading, true); @@ -46,8 +57,62 @@ void main() { await pumpEventQueue(); expect(viewModel.isLoading, false); - expect(viewModel.tabs.map((tab) => tab.id), ['a', 'b']); - expect(viewModel.selectedTabId, 'a'); + expect(viewModel.tabs, isEmpty); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a', 'b']); + expect(viewModel.selectedTabId, isNull); + }); + + test('tabs with drinks on them are open', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a', drinks: 2), _tab('b')]); + await pumpEventQueue(); + + expect(viewModel.tabs.map((tab) => tab.id), ['a']); + expect(viewModel.closedTabs.map((tab) => tab.id), ['b']); + }); + + test('opening a tab shows and selects it', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + + viewModel.openTab('b'); + viewModel.deselectTab(); + + expect(viewModel.tabs.map((tab) => tab.id), ['b']); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a']); + }); + + test('a tab settled elsewhere goes back to the closed tabs', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a', drinks: 2)]); + await pumpEventQueue(); + viewModel.openTab('a'); + viewModel.deselectTab(); + + openTabs.add([_tab('a')]); + await pumpEventQueue(); + + expect(viewModel.tabs, isEmpty); + expect(viewModel.closedTabs.map((tab) => tab.id), ['a']); + }); + + test('closing the selected tab moves it back and deselects', () async { + when( + () => barTabService.closeTab( + 'a', + paymentMethod: PaymentMethod.cash, + ), + ).thenAnswer((_) async {}); + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.openTab('a'); + + await viewModel.closeSelectedTab(); + + expect(viewModel.selectedTabId, isNull); + expect(viewModel.tabs, isEmpty); }); test('keeps the selection while that tab still exists', () async { @@ -62,20 +127,17 @@ void main() { expect(viewModel.selectedTabId, 'b'); }); - test( - 'falls back to the first tab when the selected one disappears', - () async { - viewModel.ensureLoaded(); - openTabs.add([_tab('a'), _tab('b')]); - await pumpEventQueue(); - viewModel.selectTab('b'); + test('selects nothing when the selected tab disappears', () async { + viewModel.ensureLoaded(); + openTabs.add([_tab('a'), _tab('b')]); + await pumpEventQueue(); + viewModel.selectTab('b'); - openTabs.add([_tab('a')]); - await pumpEventQueue(); + openTabs.add([_tab('a')]); + await pumpEventQueue(); - expect(viewModel.selectedTabId, 'a'); - }, - ); + expect(viewModel.selectedTabId, isNull); + }); test('exposes a load error when the stream fails', () async { viewModel.ensureLoaded(); @@ -97,11 +159,15 @@ void main() { ); when( - () => barTabService.adjustTabItemQuantity(tabItemId: 'item-1', delta: 1), + () => barTabService.adjustTabItemQuantity( + tabItemId: 'item-1', + delta: 1, + order: any(named: 'order'), + ), ).thenAnswer((_) async => throw const TabNotFoundException('tab-1')); await expectLater( - viewModel.changeItemQuantity(item, 1), + viewModel.changeItemQuantity(item, 1, null), throwsA(isA()), ); @@ -110,7 +176,7 @@ void main() { test('selects a newly created tab', () async { when( - () => barTabService.createTab(customerName: 'New'), + () => barTabService.createTab(customerName: 'New', email: null), ).thenAnswer((_) async => _tab('new')); await viewModel.createTab('New'); diff --git a/test/database_migration_test.dart b/test/database_migration_test.dart index 4f22964..3edca03 100644 --- a/test/database_migration_test.dart +++ b/test/database_migration_test.dart @@ -103,10 +103,16 @@ void main() { expect((await database.select(database.products).get()), hasLength(1)); }); - test('gives products no server picture yet and an empty upload queue', () async { - final product = await database.select(database.products).getSingle(); + test( + 'gives products no server picture yet and an empty upload queue', + () async { + final product = await database.select(database.products).getSingle(); - expect(product.imageId, isNull); - expect(await database.select(database.pendingImageUploads).get(), isEmpty); - }); + expect(product.imageId, isNull); + expect( + await database.select(database.pendingImageUploads).get(), + isEmpty, + ); + }, + ); } diff --git a/test/history_grouping_test.dart b/test/history_grouping_test.dart deleted file mode 100644 index de51ea0..0000000 --- a/test/history_grouping_test.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:kooltap/models/closed_tab_item.dart'; -import 'package:kooltap/utils/history_grouping.dart'; - -void main() { - test('groups items by purchase day with newest day first', () { - final items = [ - _closedItem('older', DateTime(2026, 8, 1, 22)), - _closedItem('newer-b', DateTime(2026, 8, 3, 18)), - _closedItem('newer-a', DateTime(2026, 8, 3, 10)), - ]; - - final groups = groupClosedTabItemsByDay( - items, - fallbackDate: DateTime(2026, 8, 4), - ); - - expect(groups, hasLength(2)); - expect(groups.first.day, DateTime(2026, 8, 3)); - expect(groups.first.items.map((item) => item.id), ['newer-b', 'newer-a']); - expect(groups.last.day, DateTime(2026, 8, 1)); - }); -} - -ClosedTabItem _closedItem(String id, DateTime purchasedAt) { - return ClosedTabItem( - id: id, - closedTabId: 'closed-tab', - productId: 'product-$id', - productName: id, - quantity: 1, - unitPriceInCents: 100, - purchasedAt: purchasedAt, - ); -} diff --git a/test/money_rain_test.dart b/test/money_rain_test.dart new file mode 100644 index 0000000..fdc2fc6 --- /dev/null +++ b/test/money_rain_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltap/views/widgets/money_rain.dart'; + +void main() { + testWidgets('money rain covers the screen for three seconds', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => showMoneyRain(context), + child: const Text('Pay'), + ), + ), + ), + ); + + await tester.tap(find.text('Pay')); + await tester.pump(); + expect(find.byType(IgnorePointer), findsWidgets); + + await tester.pump(const Duration(milliseconds: 1500)); + final painted = find.byWidgetPredicate( + (widget) => widget is CustomPaint && widget.size == Size.infinite, + ); + expect(painted, findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1600)); + await tester.pump(); + expect(painted, findsNothing); + }); +} diff --git a/test/tab_events_test.dart b/test/tab_events_test.dart index c77c3f6..91e4c65 100644 --- a/test/tab_events_test.dart +++ b/test/tab_events_test.dart @@ -694,6 +694,7 @@ void main() { productName: 'Cola', unitPriceInCents: 250, quantity: 2, + orderId: 'order-1', ); expect(event.payload['occurredAt'], '2026-10-24T21:41:03.120Z'); @@ -723,6 +724,7 @@ class _FixedIdEventFactory extends TabEventFactory { required String productName, required int unitPriceInCents, required int quantity, + required String? orderId, }) { final event = super.lineAdded( tabId: tabId, @@ -732,6 +734,7 @@ class _FixedIdEventFactory extends TabEventFactory { productName: productName, unitPriceInCents: unitPriceInCents, quantity: quantity, + orderId: orderId, ); return TabEvent(id: 'fixed', payload: {...event.payload, 'id': 'fixed'}); } diff --git a/test/tab_orders_test.dart b/test/tab_orders_test.dart new file mode 100644 index 0000000..8c0d0d6 --- /dev/null +++ b/test/tab_orders_test.dart @@ -0,0 +1,509 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart' hide Table; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:kooltap/database/app_database.dart'; +import 'package:kooltap/l10n/app_localizations.dart'; +import 'package:kooltap/models/bar_tab.dart'; +import 'package:kooltap/models/product.dart'; +import 'package:kooltap/models/server_tab.dart'; +import 'package:kooltap/models/tab_item.dart'; +import 'package:kooltap/models/tab_item_purchase.dart'; +import 'package:kooltap/services/bar_tab_service.dart'; +import 'package:kooltap/utils/money.dart'; +import 'package:kooltap/utils/tab_orders.dart'; +import 'package:kooltap/viewmodels/bar_screen_view_model.dart'; +import 'package:kooltap/views/bar/selected_tab_details.dart'; +import 'package:mocktail/mocktail.dart'; + +const _duvel = Product( + id: 'duvel', + name: 'Duvel', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 400, +); + +const _cola = Product( + id: 'cola', + name: 'Cola', + category: 'Drinks', + stockQuantity: 100, + lowStockThreshold: 10, + priceInCents: 250, +); + +class MockBarTabService extends Mock implements BarTabService {} + +OrderDrink _drink( + String name, + DateTime at, { + String? orderId, + bool removed = false, +}) { + return OrderDrink( + line: name, + lineKey: name, + id: '$name-${at.toIso8601String()}-$removed', + addedAt: at, + orderId: orderId, + isRemoved: removed, + ); +} + +List> _summary(List> orders) => [ + for (final order in orders) + [ + order.orderId, + order.startedAt, + [for (final row in order.rows) '${row.quantity}x ${row.line}'], + ], +]; + +void main() { + final t1232 = DateTime(2026, 10, 28, 12, 32); + final t1310 = DateTime(2026, 10, 28, 13, 10); + + group('groupOrders', () { + test('shows each order under the time it started, oldest first', () { + final orders = groupOrders([ + _drink('Duvel', t1310, orderId: 'o2'), + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Cola', t1232.add(const Duration(minutes: 1)), orderId: 'o1'), + _drink('Beer', t1310.add(const Duration(minutes: 1)), orderId: 'o2'), + ]); + + expect(_summary(orders), [ + [ + 'o1', + t1232, + ['2x Duvel', '1x Cola'], + ], + [ + 'o2', + t1310, + ['1x Duvel', '1x Beer'], + ], + ]); + }); + + test('groups drinks without an order by time: over 5 minutes apart is ' + 'a new order', () { + final orders = groupOrders([ + _drink('Duvel', t1232), + _drink('Cola', t1232.add(const Duration(minutes: 4))), + _drink('Beer', t1310), + ]); + + expect(_summary(orders), [ + [ + null, + t1232, + ['1x Duvel', '1x Cola'], + ], + [ + null, + t1310, + ['1x Beer'], + ], + ]); + }); + + test('counts drinks taken off again apart', () { + final orders = groupOrders([ + _drink('Duvel', t1232, orderId: 'o1'), + _drink('Duvel', t1232, orderId: 'o1', removed: true), + ]); + + expect(orders.single.rows.single.quantity, 1); + expect(orders.single.rows.single.removed, 1); + }); + }); + + test('orderTimeLabel says today, yesterday or the date', () async { + await initializeDateFormatting('en'); + final l10n = lookupAppLocalizations(const Locale('en')); + final now = DateTime(2026, 10, 28, 14); + + expect(orderTimeLabel(l10n, 'en', t1232, now: now), '12:32 today'); + expect( + orderTimeLabel( + l10n, + 'en', + t1232.subtract(const Duration(days: 1)), + now: now, + ), + '12:32 yesterday', + ); + expect( + orderTimeLabel(l10n, 'en', DateTime(2026, 10, 20, 9, 5), now: now), + 'Oct 20, 2026 09:05', + ); + }); + + group('DriftBarTabService orders', () { + late AppDatabase database; + late DriftBarTabService service; + + setUp(() async { + database = AppDatabase(NativeDatabase.memory()); + service = DriftBarTabService(database: database); + for (final product in [_duvel, _cola]) { + await database + .into(database.products) + .insert( + ProductsCompanion.insert( + id: product.id, + name: product.name, + category: product.category, + stockQuantity: product.stockQuantity, + lowStockThreshold: product.lowStockThreshold, + priceInCents: product.priceInCents, + ), + ); + } + }); + + tearDown(() => database.close()); + + Future>> outbox() async { + final rows = + await (database.select(database.tabEvents)..orderBy([ + (row) => OrderingTerm.asc(const CustomExpression('rowid')), + ])) + .get(); + return [ + for (final row in rows) jsonDecode(row.payload) as Map, + ]; + } + + /// 2 Duvel and 1 Cola in order o1, then 1 Duvel in o2. + Future ringUp() async { + final tab = await service.createTab(customerName: 'Bram'); + for (final (product, orderId) in [ + (_duvel, 'o1'), + (_duvel, 'o1'), + (_cola, 'o1'), + (_duvel, 'o2'), + ]) { + // Taps are never at the same moment + await Future.delayed(const Duration(milliseconds: 5)); + await service.addProductToTab( + tabId: tab.id, + product: product, + orderId: orderId, + ); + } + return (await service.getTabById(tab.id))!; + } + + test( + 'keeps the order of every drink and sends it with the event', + () async { + final tab = await ringUp(); + + expect( + [ + for (final order in ordersOfTab(tab)) + [ + order.orderId, + [ + for (final row in order.rows) + '${row.quantity}x ${row.line.productName}', + ], + ], + ], + [ + [ + 'o1', + ['2x Duvel', '1x Cola'], + ], + [ + 'o2', + ['1x Duvel'], + ], + ], + ); + expect((await outbox()).skip(1).map((e) => e['orderId']), [ + 'o1', + 'o1', + 'o1', + 'o2', + ]); + }, + ); + + test('takes a drink off the order it is shown in', () async { + final tab = await ringUp(); + final duvel = tab.items.firstWhere((item) => item.productId == 'duvel'); + + final applied = await service.adjustTabItemQuantity( + tabItemId: duvel.id, + delta: -5, + order: (orderId: 'o2'), + ); + + expect(applied, -1); + final orders = ordersOfTab((await service.getTabById(tab.id))!); + expect(orders.map((order) => order.orderId), ['o1']); + expect((await outbox()).last, containsPair('orderId', 'o2')); + }); + + test('deleting a tab takes every line off whatever its orders', () async { + final tab = await ringUp(); + + await service.deleteTab(tab.id); + + final removals = (await outbox()).where( + (e) => e['type'] == 'line_removed', + ); + // Without an order: the server takes everything on the line off + expect([ + for (final e in removals) + '${e['productName']} ${e.containsKey('orderId')} ${e['quantity']}', + ], unorderedEquals(['Duvel false 3', 'Cola false 1'])); + }); + + test('a paid tab keeps its orders in history', () async { + final tab = await ringUp(); + + await service.closeTab(tab.id); + + final closed = (await service.getClosedTabs()).single; + expect(ordersOfClosedTab(closed).map((order) => order.orderId), [ + 'o1', + 'o2', + ]); + }); + + test( + 'replays a removal on the order it was made on, after a sync', + () async { + final openedAt = DateTime(2026, 10, 28, 12); + ServerUnit unit(String id, String orderId, int minutes) => ServerUnit( + id: id, + addedAt: openedAt.add(Duration(minutes: minutes)), + orderId: orderId, + ); + final serverTab = ServerTab( + id: 'tab-1', + customerName: 'Bram', + openedAt: openedAt, + version: 3, + lines: [ + ServerTabLine( + id: 'line-1', + productId: 'duvel', + productName: 'Duvel', + unitPriceInCents: 400, + quantity: 2, + units: [unit('u1', 'o1', 0), unit('u2', 'o2', 40)], + ), + ], + ); + await service.applyServerState([serverTab]); + final tab = (await service.getTabById('tab-1'))!; + await service.adjustTabItemQuantity( + tabItemId: tab.items.single.id, + delta: -1, + order: (orderId: 'o1'), + ); + + // The server has not seen the removal yet: it is replayed on top + await service.applyServerState([serverTab]); + + final orders = ordersOfTab((await service.getTabById('tab-1'))!); + expect(orders.map((order) => order.orderId), ['o2']); + }, + ); + + test('a drink keeps its order when a server without orders sends it ' + 'back', () async { + final tab = await ringUp(); + final events = await outbox(); + // The server applied every event and knows nothing of orders + await database.delete(database.tabEvents).go(); + final serverTab = ServerTab( + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt, + version: tab.version, + lines: [ + for (final item in tab.items) + ServerTabLine( + id: item.id, + productId: item.productId, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: item.quantity, + units: [ + for (final event in events) + if (event['lineId'] == item.id) + ServerUnit( + id: '${event['id']}:0', + addedAt: DateTime.parse( + event['occurredAt'] as String, + ).toLocal(), + ), + ], + ), + ], + ); + + await service.applyServerState([serverTab]); + + final orders = ordersOfTab((await service.getTabById(tab.id))!); + expect(orders.map((order) => order.orderId), ['o1', 'o2']); + }); + }); + + group('SelectedTabDetails', () { + BarTab tabWith(List<(String product, String orderId, DateTime at)> drinks) { + final purchases = >{}; + for (final (index, (product, orderId, at)) in drinks.indexed) { + purchases + .putIfAbsent(product, () => []) + .add( + TabItemPurchase( + id: 'p$index', + tabItemId: product, + purchasedAt: at, + orderId: orderId, + ), + ); + } + return BarTab( + id: 'tab-1', + customerName: 'Bram', + openedAt: t1232, + items: [ + for (final MapEntry(key: product, value: units) in purchases.entries) + TabItem( + id: product, + tabId: 'tab-1', + productId: product, + productName: product, + quantity: units.length, + unitPriceInCents: 400, + purchases: units, + ), + ], + ); + } + + testWidgets('folds earlier orders under their time and total', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SelectedTabDetails( + tab: tabWith([ + ( + 'Duvel', + 'o1', + DateTime.now().subtract(const Duration(hours: 1)), + ), + ( + 'Duvel', + 'o1', + DateTime.now().subtract(const Duration(hours: 1)), + ), + ('Beer', 'o2', DateTime.now()), + ]), + onItemQuantityChanged: (_, _, _) async {}, + onCloseTabPressed: () {}, + ), + ), + ), + ); + + // The earlier order shows its total only; the current one its drinks + expect(find.textContaining('– ${800.asEuro}'), findsOneWidget); + expect(find.text('Duvel'), findsNothing); + expect(find.text('Beer'), findsOneWidget); + + await tester.tap(find.textContaining('– ${800.asEuro}')); + await tester.pump(); + + expect(find.text('Duvel'), findsOneWidget); + }); + }); + + group('BarScreenViewModel orders', () { + late MockBarTabService barTabService; + late StreamController> openTabs; + late DateTime now; + late BarScreenViewModel viewModel; + final orderIds = []; + + setUp(() async { + barTabService = MockBarTabService(); + openTabs = StreamController>.broadcast(); + when( + () => barTabService.watchOpenTabs(), + ).thenAnswer((_) => openTabs.stream); + orderIds.clear(); + when( + () => barTabService.addProductToTab( + tabId: any(named: 'tabId'), + product: any(named: 'product'), + orderId: any(named: 'orderId'), + ), + ).thenAnswer((invocation) async { + orderIds.add(invocation.namedArguments[#orderId] as String?); + }); + + now = DateTime(2026, 10, 28, 12, 32); + viewModel = BarScreenViewModel( + barTabService: barTabService, + now: () => now, + ); + viewModel.ensureLoaded(); + openTabs.add([ + BarTab(id: 'a', customerName: 'Bram', openedAt: now, items: const []), + BarTab(id: 'b', customerName: 'Jan', openedAt: now, items: const []), + ]); + await pumpEventQueue(); + viewModel.openTab('a'); + }); + + setUpAll(() => registerFallbackValue(_duvel)); + + tearDown(() async { + viewModel.dispose(); + await openTabs.close(); + }); + + test('drinks within 5 minutes of each other are one order', () async { + await viewModel.addProductToSelectedTab(_duvel); + now = now.add(const Duration(minutes: 4)); + await viewModel.addProductToSelectedTab(_cola); + now = now.add(const Duration(minutes: 5)); + await viewModel.addProductToSelectedTab(_duvel); + + expect(orderIds[0], orderIds[1]); + expect(orderIds[2], isNot(orderIds[1])); + }); + + test('closing the tab and coming back starts a new order', () async { + await viewModel.addProductToSelectedTab(_duvel); + viewModel.deselectTab(); + viewModel.selectTab('a'); + await viewModel.addProductToSelectedTab(_duvel); + viewModel.selectTab('b'); + viewModel.selectTab('a'); + await viewModel.addProductToSelectedTab(_duvel); + + expect(orderIds.toSet(), hasLength(3)); + }); + }); +} diff --git a/test/tab_panel_index_test.dart b/test/tab_panel_index_test.dart index 725eba5..0779d77 100644 --- a/test/tab_panel_index_test.dart +++ b/test/tab_panel_index_test.dart @@ -45,6 +45,7 @@ void main() { WidgetTester tester, { bool showSearch = false, BarTab? selectedTab, + List? panelTabs, }) async { tester.view.physicalSize = const Size(500, 800); tester.view.devicePixelRatio = 1; @@ -56,12 +57,13 @@ void main() { supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: TabPanel( - tabs: tabs, + tabs: panelTabs ?? tabs, selectedTab: selectedTab, selectedTabId: selectedTab?.id, showSearch: showSearch, onTabSelected: (_) {}, - onItemQuantityChanged: (_, _) async {}, + onTabDeselected: () {}, + onItemQuantityChanged: (_, _, _) async {}, onCloseTabPressed: () {}, onTabEdited: (_) async {}, onTabDeleted: (_) async {}, @@ -92,7 +94,7 @@ void main() { await tester.tap( find.descendant( of: find.byWidgetPredicate( - (widget) => widget.runtimeType.toString() == '_LetterIndexBar', + (widget) => widget.runtimeType.toString() == 'LetterIndexBar', ), matching: find.text(letter), ), @@ -190,6 +192,40 @@ void main() { ); }); + for (final (description, name, count) in [ + ('the first name', 'anna', null), + ('a name in a short list', 'Bram', 3), + ]) { + testWidgets('lines up $description on top of its items', ( + tester, + ) async { + final panelTabs = count == null + ? tabs + : (tabs.toList()..sort( + (a, b) => compareByIndex(a.customerName, b.customerName), + )) + .take(count) + .toList(); + final tab = panelTabs.firstWhere((tab) => tab.customerName == name); + + // Selected from outside, as the Open Tab dialog does + await pumpPanel(tester, panelTabs: panelTabs); + await pumpPanel(tester, panelTabs: panelTabs, selectedTab: tab); + await tester.pumpAndSettle(); + + final list = tester.getRect(find.byType(OpenTabsList)); + final details = tester.getRect(find.byType(SelectedTabDetails)); + final tile = tester.getRect( + find + .ancestor(of: find.text(name), matching: find.byType(ClipRRect)) + .first, + ); + + expect(tile.bottom, closeTo(list.bottom, 1)); + expect(details.top - tile.bottom, inInclusiveRange(0, 20)); + }); + } + testWidgets('browsing the names shows the divider again', (tester) async { final marc = tabs.firstWhere((tab) => tab.customerName == 'Marc'); await pumpPanel(tester, selectedTab: marc); @@ -221,7 +257,8 @@ void main() { }); testWidgets('a letter without names goes to the next one', (tester) async { - await pumpPanel(tester); + // A selected tab keeps the list short enough to scroll Rik to the top + await pumpPanel(tester, selectedTab: tabs.first); await tapLetter(tester, 'Q'); expect(firstVisibleName(tester), 'Rik');