From 3d7f3491b2813410a1abdc1f6670e65c9b72c54b Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 24 May 2020 12:34:37 +0200 Subject: [PATCH 01/31] Add ManagedController Also updated a lot of kdoc --- .media/controller_flow.drawio | 2 +- .media/controller_flow.png | Bin 46067 -> 46747 bytes control-core/api/control-core.api | 22 ++- control-core/build.gradle.kts | 4 +- .../at/florianschuster/control/Controller.kt | 170 +++++++++++++----- .../control/ManagedController.kt | 99 ++++++++++ .../at/florianschuster/control/builders.kt | 116 ------------ .../at/florianschuster/control/defaultTag.kt | 3 - .../at/florianschuster/control/event.kt | 22 ++- .../florianschuster/control/implementation.kt | 127 +++++++------ .../kotlin/at/florianschuster/control/log.kt | 14 +- .../florianschuster/control/BuildersTest.kt | 72 -------- .../control/ControllerStartTest.kt | 65 ------- .../florianschuster/control/ControllerTest.kt | 39 ++++ .../florianschuster/control/ExtensionsTest.kt | 3 +- ...ventTest.kt => ImplementationEventTest.kt} | 19 +- .../control/ImplementationStartStopTest.kt | 76 ++++++++ .../control/ImplementationTest.kt | 159 ++++++++++------ .../{ControllerLogTest.kt => LogTest.kt} | 2 +- .../control/ManagedControllerTest.kt | 42 +++++ .../at/florianschuster/control/StubTest.kt | 17 +- .../at/florianschuster/control/TestHelper.kt | 8 + .../CounterScreenTest.kt | 3 +- .../androidcountercomposeexample/App.kt | 52 +++++- .../extensions.kt | 77 ++++++-- .../control/androidcounter/CounterViewTest.kt | 15 +- 26 files changed, 749 insertions(+), 479 deletions(-) create mode 100644 control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt delete mode 100644 control-core/src/main/kotlin/at/florianschuster/control/builders.kt delete mode 100644 control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt delete mode 100644 control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt create mode 100644 control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt rename control-core/src/test/kotlin/at/florianschuster/control/{EventTest.kt => ImplementationEventTest.kt} (85%) create mode 100644 control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt rename control-core/src/test/kotlin/at/florianschuster/control/{ControllerLogTest.kt => LogTest.kt} (98%) create mode 100644 control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt create mode 100644 control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt diff --git a/.media/controller_flow.drawio b/.media/controller_flow.drawio index 4a11e1d7..bd102c7b 100644 --- a/.media/controller_flow.drawio +++ b/.media/controller_flow.drawio @@ -1 +1 @@ -7ZpJk5s4FMc/jY/pQggwPqYXzxwylVR1VSY5TckgQBOMXELe5tOPBGIRwo67gSaVal+MnhaQfu//tMACPmxPfzC0S/6iIU4XthWeFvBxYdsA+J74k5ZzafGAXxpiRkJVqDE8k/+wMlrKuichzrWCnNKUk51uDGiW4YBrNsQYPerFIprqd92hGBuG5wClpvVvEvKktPr2srH/iUmcVHcG3qrM2aDgR8zoPlP3y2iGy5wtqppRfcwTFNJjywSfFvCBUcrLq+3pAadyWKsRK+utL+TWj8xwxm+pQED42SVn5Lrrzeafk42/fvn2Aahmcn6uxgKHYmhUkjKe0JhmKH1qrPdFf7FsFohUU+YTpTtl/Bdzflac0Z5TYUr4NlW5+ET4N3Ft3bkq9V2m1PXjqZ04V4mMs3Orkkx+b+c11YpUVS/njP6oqYpRvy97LLt5cSSrUaF7FuBrw6d8FbEY8yvlYM1bSAjTLRZPKOoxnCJODvpzIOXLcV2ugSouFNeXMC7bPaB0r+50IPhocNepRiRNH2hKWZEJI99fWSthj1OU59XQVh4tEwHdkkBdxwyFRAxmq4Fg424QrnlUOYVemgqPhAl5E5qJLIxyLh+Eau1E4qc70zEhHD/vUAHqKOJTjfiAGcen65BNKKqCr2SrIpq9UuljEx9AJe2kFRs8ayKMtoHxJwhbY9QDZLnxImybpEHoB6E/EJQqndFPaIOvgdJcqO1bbXcagaa91HGCpYmzRt7GCcFkPC2D30tCrzVm6K1ypg69uk+Vch43IsMbI7I3Z0SGhpRRKabrgu4TUAvtCDKBLtRl4rm3RT04lUqAGeVmW6C8TiVgzgWKc6McLrjF28jBgbMwrnlpMa2Bd5EXYvyj3HQ0E11hWxPZ7RZTI86ZqDurm3Xxm8cFlgNdoKgqxgWdWwV2lGQ8b7X8RRpaAcfS52VHbazWN5aHS7vjfuUTNM5Yd2WAfxrhWvDmeFi0NlzEKn7TxHHHdeaO46v31c74qx3vjbQ9CL1nyGe7F/oRgzKigFQg7m5bo+hS5J1ws9FVn+3MvXesjuPe1Tem+pY3qs+ZU31LQ30Mh/sAT6G+CbQDHXtu7QxbnQ7VTks5jY5+oh2weP2KNpBxUUZDbVELeiU2opb8G7U0677dNxeCmB1IMHAp2D2d65/L6gO7/onv6kZikCQ9t3MUOvt0ZpsHKDkJsfTFKJLvh7o4ROf5C1cPyoRSEstDzkAMloiZ8F4OJQlQ+lFlbEkYFpLvg6yHgRFYuI6vs3BNFsseFPZkKMzNUUjyHeJB8vtSsD2gUQCVQ7YoOG9KwVxjBzRNf2spdCG4ljcvBGhGpWDPiu5IAn0nBsNhpDjiw1Dc9ApmwukFVtyqswpgnjm/LUdzmrfu7jJhKfathGb5O8gekI5tayChtTJA9r1imw7kygCZyRff72K8wlAPqo7lz8vQMdcXgeg+k7ObuYX9NSa310AsDycYLQOMsH1w3JHmyc6XDKDnS4aR4qtINt8zlafvzfdi8Ol/ \ No newline at end of file +7ZpLj5s8FIZ/TZYdYQyELDuXtItWrTRSv3b1yQFD3BIcGefWX18bTLAxkzJDmETVZBHhY5vLec57fIEJvFvtPzC0Xn6mMc4mrhPvJ/B+4rpTEIp/aTgog+NVhpSRuDKBxvBIfmNldJR1Q2JcGA05pRkna9MY0TzHETdsiDG6M5slNDOvukYptgyPEcps638k5svKGrrTxv4Rk3RZXxkEs6pmgaJfKaObXF0vpzmualaoPo16xmKJYrrTTPBhAu8Ypbw6Wu3vcCa9Wnus6jd/ovZ4ywznvE8HAuIvPjkg358vFv/vXfzt6/d3QJ2m4IfaFzgWrlFFyviSpjRH2UNjvS2fF8vTAlFq2nyidK2MPzHnB8UZbTgVpiVfZaoW7wn/Lo6dG1+VfsiSOr7f64VDXcg5O2idZPGHXtd0K0t1v4Iz+utIVXj91nZc7QS6YRE+5S0VmoilmJ9oB6t20pPaBRSWD5iusLhD0YDhDHGyNYMQqVhOj+0aqOJAcX0O4+q8W5Rt1JW2BO8s7ibVhGTZHc0oKythEoYzZybsaYaKonZtHdGyENEVidRxylBMhHe1E0QLf4HwkUddU+ql6XBPmJA3obmowqjg8kaocZ5E/Mxg2i0Jx49rVJLbifR0CvEWM473J6Go2lDJVmU0d6bKuyY/gFraSy03BM5IGF0L418Qaj7qADJdBAl2bdIgDqM4HAhKtc7pJ7TAp0AZIaTHlh5OZ6ApRiQDJ5jaOI/IdZwQjMbTsfg9J/U650y9dc3YqdeMqUrOgzIy7JmRg6vKyNCSMqrEdFrQXQLS0J5BJtCHpkwCv1/Wg2OpBNhZ7mITlJepBLziBMXrKQdwVXLw4EUYH3kZOa2B9yQvxPh7uehoBrrSNifysTWmVp6zUbdmN/Py9yohMD13CJRdhV/QQWuwpiTnhXbmr9KgJRzHHJc9tbCa92wPp24r/Ko7aILx+CgD4tNK14I3x8OytRUiTvkbJ497vnfpPD57m+0Mnu0El9L2IPSBJZ/VRuhHOOWMAlKJuL1sTZKnMu+Ii422+lzv0mvHen/uTX0D1DftqT7vqtQ3tdTHcLyJ8BjqG0E70HMvrZ1hs9Oh2tGU0+joL9oBk5fPaCOZF2U2NCa1oFNiL9dS2FNL17VuD+2JIGZbIp7UnW8KHKFi4KSwvU/XPaodt+66h8DnLin6izPwW5uiFx/YXHsrpSAxliGTJPJNURuHeFL+zHmEMqGMpHK7MxL+E9kT3kq/kQhl71XFisRxKf4uyGZCOAML3wtNFr7NYtqBwh0Nhb1MikmxRjxa/rsU3AAYFEAdkBoF71Up2LPtiGbZPy2FNgTfCS4LAdpZac3wltBNUSLo2jwYTiPDCR/GotfbmBHHF1iDq7ctgL39/Log7RHfubnJhaVcwhKaF28gO0B6rmuAhM7MAtn1tm08kDMLZC7fgb+J8QRDM6t6TnhZhp49wYjE4zM5vNmr2esY3V4CsdqnYLRKMML2zvPPNFC2PmoAHR81nCm/imLzaVO1Ed98OQYf/gA= \ No newline at end of file diff --git a/.media/controller_flow.png b/.media/controller_flow.png index 914b61f3db5fc6b199cc8e6cfd8f5e1d4f7aeefb..a342377cfd24dc27af873bdccae1cd3ad0318a51 100644 GIT binary patch literal 46747 zcmZU)2RK|^)HXbN2|=`o8YKh~j9#MmZWuNC%qTN@9Yl|W5D_&x5g|kwEqWsQC?R?$ zqL+wX|KoX{@BQBI`dy4U`|Q2Ws`pyEoR|8#YUHH%NI@VFxrVy3Aqa$v0fBImhzWs~ z2Omm^K_H@iZxvH-lt0`F2?KF~mHz$4B`oOZ=H<-=R^}2Gwsm(GaB#9kc-o@81YBX> zKof8u<>uf7cY-!v41DTAfDcpPPe{;ONYq;7A@Ehf-5m)tf!S#} z0X(RRg2e^EVn8#Gmb#ISHkYsx@Ez&o0t5c3!R%e!ZiXm1db**27F7u$0YL!~K@kxF zA+Q8+17Zue^>q4QWNu`FdD|lXl}AWKL(5A``H`ospSYwE)YaHQ4gRkjzA#TOCpXvs zx`73R1i&{pz$$?9{?igb$>~46z}3az)?HN6-BDZ5+|JlU zT+J>3E^KF}ui&fVs|@$G712ie*cl_toJ`z|Y!!s{MU@>)3|+ji=LUNvL_N{Ac8jZbP(|Z?7=0hC}afH@={Y3QC3w&nfOXLs~Agq+6pU+d%^{s zHCwB83-L8}RWXw^6IF9^QWFu^^hNj~z%U(S zqzK&8)Eum*sSC3+@;8wb@{%w%^${^QGe^T++|(63G<>zaU7Ur41igUgs{+5icKT>< zz!y{MHB~eM;)zsM4RCeTQW5oV7S~b}0weA80h=pA-4PHcAv+^cLm#A_ znyaWY+83s!191^k)q!tjy@;Qot(T($Ttd`L%gEjUg4O`;3nIYA z;$rr$dWwb+6J=2+A1`f3WkEA#M`d-j06{Yy0}rU9p0baSt%shbg1xDro(S+%4+%9< z2vp3)%?{ydujU{sN7|Wt!3~u3y}dPc z?DW;VoSe076(t}N8hU6ETLnE0UtdFKJ57{}fuf0uv9kgMY2pJHR1xxYG80o0RMGQP zgF3_P(JsnHlCCE9e##F1>h6kQdt(hhHGoM~Z4rH#y^xxZgAQ#p z)J4NYeVkNpg2>EHUB}ZMqF{)0QZc$|(9|_H1iGrjQ~?NAM607zbUf5_G}X1lb^MKh z78OYsBe07H(9^|G5h*TYs_NuoXs8JnaSTv*SJG3^(H8PU3aWaDYx+4#Dwy3kxSAi* z4`pa;Xr|#Wh5`l#h^Pwt>DVedD5#igz?3A!+>Ft>NMVS!zL%@0xstkwrYFoCrmCPL z?B%UyE@B|;Xbx3yQqx5MW7PyD9R1KbFn>Kgq&Hf_$XQ7kt)T1RW#Xp`aIXkQ3LA*J zIr@Sf&@O&R4R138S6wGG%s?SP!9iHkT)_b4?SoMC7ZXOCIRt3CDC)Swef@L+x+*XY zh@&gg*g;Di;bkBJvp3T)a*{B2u?Kshq2lT$I(9cerD&ojX{4j-=BlQqDlRJS=&I)A zD6Wn+Q9zhU*rMGH;f8kVlHOXbFsB>y0%X-SboAkV3O9)b4CF)A|C5paNl(D_f9V~p zQc7F@6$D}fX(%fg`CDxT69v&4UtOlBDyoOlpf%WScAK}ZjP$pf1$rBf=6L9kl*(wL z#_;Aw7qh++o}CaMpQqc7XR+7QbPQL%@pZVhTd!Px77?8Cl zuzUfcV#8GqC8oq-m&bo0AH$ye-m!HOb#w85?|>4`ZsY#%rm!THPzN!X9<#LW|L!L* z+$Z$^3{0pZPQZTHDz@(`kY@wQU%0%mn@zFg(sFC6II!b#V$%J`z^iHH zc<$_>a(EwcD;PuPmx4}KK_^sgyS7uz1i9RypO%{3#MJ(KBHJ<*TYCS={e&^lU5BF} z&^6J53rKo7=(`-qAJpc;iSbZP>8JWfED+TTT%5#79{DBShY_I_G&4jCcA!2`DXu>8 z0y~IxJ;a)1K^J5K>L%K|8!7~f9~q}6-H`{$;ZZvH=RG^{TPJlB3-!a9xt)vK)(le4 zNX0uby1qn%7;$897ZgB>>9Wcu(dRfPgl$|Pu|l${EuR0B8pe5}Ck;Ka#!*gue3D?M zHg(V!P$!;$I~+X0EKd?zhm%QQLmzyJ13ClwleAHTvI*RYLfJwwI9o(E0--J-qYd6t zwubP%7NP|P5CxtDMH^=p4LzQ}uG_INhy(PBV2?S}N$MqCi~#-r7$}zk(7G6&jO#mZ zV&XduIBkN>h>($xd)a0Ra0>iA-q2;73pr5W<}}l%x05oU8jvOqKaoBtmnoF3=o)88 ziyNbuMH};J7QROt>Ia%B-yLUbva?>wsgQc1l7ReimHS>Pftme3uSu}GG4TY6y0Qcx`e{I1{kDIfu?D>4|k*(y~ zFLsPi!#?j%6Whm>+0ey6T25nA{75_A6j_`L+}y{Z88}SEflg8MCOnOQgS$)h(buGH z6Rm8Y6lZ1Ili~8h8J5e>oA3W4MHr1LV5TTNmc|_gn-{v4*Z8^Qp?aX89d=9+u7t{Q zjq4~k>Ln+xtsZ5z*`HH6AHGmWHD%{!5}TL0&J<&H>7UPSIvfNP-gPJo+h2c^=G!8X z!=W4_wDqb`^Lm5z+`IFC+>jcO!~-|6pOXN0_VA_oHzGc|MVB(1J$j3QQhgg0<5^mr(t_zEvN5YK%S60C&QHy_M~@DZ0+Jhc4ycHtO@_#598 z9ch`miTf&+RG4p!>W%IH4eBsBo4oKe*CQ{U4HmOEo@_~`Z{Rlkb{Q<76kL5AZ^8<* z^a9j_ycOQ}0YZusfn~`yY=$HLC!8e!DH)!C{7)X>etXCEVr%4k3k%Hc8fJOX9z1`d5iPrCwy!BVG!}Lm{4#07D68#HGV; z1)&?s^J1{E#lMdKuu&Wh>M4cZ*E9;1(kMSmLL=VDKEPGZEu=IpNR_*i4lOr#=0>Le z%hkJh`44X=V{w{t+qs1mOxCWINethBd%3_=l#*4J!W1|ja{Qi`(qn|}si4pJdtSd4 z+BpDXp~RezanrTo+mSH#(DXBxj$g7CTMPiNFIDAfhU(?Z+3U)!@=c9`Ir4N4(ig7& z5$--^w~??kb6c6z1EMu99ZB_nTJtE^$QLv~T{tJ=p>lV4|BYnz+xWt7urZGdJESfU zAb)O2U4lRb0HYH**mhF$FDiLp{7pysnL_^sz9a0W`xC;2d!TZh`ikT~t2=-u)F0tL z!FR_Eg@7(NY8nRS{_(_N>M(6i%wL=XoQl^vc{+obz5*be+2)9iKzB*xFPLa|J11HG zRr+^8H*d_|a5OTvv4hyPQf}Qev(W+8vR8=R{D<0|(3>tolbeAcuS1h4lF+oqn?5R3 z^<<&yvH_iA*ZL07QofWT$bspuJxw;7kC-T1bYpS}QZrM_H<6e!OymZo zb@cctVu6eL`!*(s%X@{qjN%_BDgP1I_eR{VR~D52cv~a{QQQk3GhO%rh@TJ%z~6&K z4ck<}km^$Sip3Zs*xMbWN+rNQ9PR-RPWSB$XaX)o2yo10IC*$wcY1$M&d96WSMUN zLu8H02I_NQ>Y;O^9C<}bwsak0>f#wIP@nD_Cc<5g!IBR9zZ;n|}v4{o%O>jMx+ zG0%K4VNZY(|5{xg+!*~d&h_?z4zJ8&B=t}&YPwpgMO!K@OWJR#;PsPl+ja9TJ}bS+ zgGMGM^Y1je2JHc1*bZ-4Z1(D8p$>CUT3DL(o4;J#ZM|+u;1rwG)fqYGP~XNOVyZZ- zn}61rO^O=v_>}?(S}g;J{OGmNu{{3py|8#@=_?7uK<4Fu%wKHvlL2j+dhz)nY7^_= zLkdQ5R~i;ufq#_qUKz0KL%mzlD)@#(VWp*|&zYrt9adIW3U!MnM?O+gZXj)KZIyr@ zT_Z+!u76a>cY~Ouxn8NtZEjVyS3LgA(VGJ6!(?Qn}ild+|!E;eKujZ&g*5`}%gQ z3TL_((^;JOcztYe)B3WQwse?06fe}7;AX`i>iLm**fJbIbOGn-eU&nBR;s;9t1qY`#uc~#wDXW6!2 zM7s8=2m0Nvnmgo8&h+$;skhtFv*nImj(XG0U*tZXcz$PLOOKo0Q!jZ=CLDOmtcCSh zXMWxJLa_6V(34kx3gfgSueO%eS<5@?YhLT2`US_smc5z{XkBPjPN{fXRM}2cW(P__ zo?PrbkxnGl_k9jHHJw7JL#>JptU$T#W!v2HYN{xq=YT=z$Y`!q+lA+NePMJEspmQy zdF^Aj(UZiwHx_XP<__YVr`2yrY}}?)_vfr%8|^>Qcd5stSEVXg&a{O~dAx9%Moj1TIUfm@40zEm3mO`qD;H7VJ3LVxo3r6QH{9MKe%oEL|9+r27*r}t9BJ7k# zSR7ND1JoqE7+ZUwt}$@5?J%Z)#=(ba$S-+qvD*GFP{!Pih71M`gxEBZRIt?A1=NFk z%5_Kxe;$XnVIV~*&54uf2BKDr7`~@9TTD0Q{iMZ*#`m*v?XY68gD;;9F*qUBlOSHmEB|W`8~2>a7F^8E9-&pMGc_c)J?H>bZWo*7C-DMH@^hLSWpTC3+c0P)Oq2-lP9oy zUlya`d8ggmezFR7vhk_h{VM^z&Cbl1yCRO1V`Ubp7tC&O3dkJTB@nW>dz!o}^o*EL z$Zq3RRb2)Nz4%QMNWOs?u7xkt2lBatC-b-P!p@3AJ)hKZp{y@`B; z*yr}&Wp#7kk{&`hAIkpuPA&B8!vA1vlA4^H+X1DY;K8kxZ4d{yP8DNf1 z3a#2C1>%FO`UNXcfoQqQ#ACJb)hlZFTDs260@Zs=bu|r#>Z>*?HKwq}-4@34Eeyp6 zos0(>>i|HvZJxP8K=1?d+E7Hq}Kb{X^%@B7JLlNr9{K0=t&iH_2``P(6 z_w}<$SgOX7xxRiV0NczMc>lLgzLr_dPY>1dBAJC24Cg z>#b}v>2%snx#JlxiOo{XuMO6ai^^(FBV%I?;q%S%R=e@1exn1)`3vW}tzAI=OAw}4 ze-Ho8V7V11o(qA1FDQBa4(u_yHQhI!EN{KKN5tZvTTRz=vJ{($ zCC&M?*D;6QEUO|}jD5t%*Zxk_B`Db}uOU0+>a_k(C*#W5Hy1vB5<6)Hc+_C6-|pN) zJ@^P8LX6ZRfnl5LdiAe7%fMZ^mL}mNX`(uyRA8e5?EL^2w!=|KHAFi}VMH53x$&+U ziO841bZyVwj=23$(aA$V_nH68mk0;g*o%VW=Tp(y6(8RG`PjRxsPo0M)=n`+-#X-E zk!U?cb|ndtkdYDlxaCCg44xBm6>xD_9+JGV(Q>{Ms}f7s6JlcNK>#f?=Xyk{Dw3-2 zXYId8&~&liXShgPQp(mlZt10C{${dom(qSfRv@UiYbw@vf-m&+SM!-FY<)Zxvlce^ z?5PUXFM`E~yGXv)Pu4>5k7dU3wJpXp*!~ts#I?HeSlwv7x z8?`T1@Kg+RMS4-HQ26q0`gW^}G*+gZNDL~vP)|g!RkeP(UmMjq-O|+m?)XTAAa2@u z`y?pfTt^+;y4CMny+mOhKY{nvW`oAiycsPT|K!YZJ(V_A-&o~cn7-rSlXIkoYwP8a zMYY?!6>s%73cgy&wcH4OBcsS@7IZf!#%26-a`q(PNd~n(6SmQdS$=a0jlSDKJ*8|z zfFFef5yElJdmIK!eZwSp_ksm&(WgBmp;`Y_=c+d)E&i)d2s+$m?#Ds*O3=kdF{Peq z!`B~26OL0}kDBGXo$BGq>JbP=4?9fKVIM? z?HaO|Ue1rW^=Dvk+T$C`w)@$#mWTVGjJDJ#qT4z98I$&CxMYyU9UL_L@Rb0oUgJHo z0R}m*R6dJ4A+xRi%M4IVEaQqX0&6?DJvWJmLCQ5Po_I?*V3DGv zWM}t9sqhksr4!+wJuFL|g)~;Gh{)>9HCESAG|IP|(M6w?%hgr~nrVo3MqV}dJ!&!NDAYTmd?<>5E%|MtIKeJho^pdf+)TMIoJrZ=!jhDrN197deQrsoi&C9k6Cz-lU(!Oo^(?NzsbdlnlNbPs;FH5wc(?2@QyK_D%6{`%8Q8B*?h z#w}`}E*}Ax_mSP{H39Ir^RRp}M)78(vuCElQ8=*!3$76yyqo#=MKC2#PLqw0(ZLbl zvU3osfiG0F3oL#9&1$0Df)RV_d@Fvs26XN-*#30)bH~wCZt+n3rDL-yos9L^7>+U& zZUI!(37O<_O>rfx)*WXZ^LcqK@yq-wRNXyJ5mwC`va*Mx!%Krc(h!5hUzO12P83Ha0ukcrpb#2tzx(`HaWSoJ>hE#X3iN6$=aIK$Zdz*OMi7LOXlP#(VeCg5a<6SZU%;i_Y03J(BgFs@&p!s zIp%QH1HfwQrjCwhs8a{yYjQvKe+U3iiv;#N@2(Cyn#YG2D!%6Wi&&W^cF@m|3YB_Qn z!Y_<^nEw0ZQ0Dy**yWdA<6u!q$!=VR!87xBvn&A;%4}RWizzQhzU$V~;0AxVwi?s7 zejIL5^EhxkwJsW8q#dYLpNjo{I?mW7({#7|vXD%HR^s#zmoS=73#WylfpI6m?a|yd zta0pCG-6P7I6sKoIbn;gKYdgd<2s|rhjH~R;fuBH-9T$J=M|t?*m-4sIvYv&^SkY@KAhu|ZL%5bG?6T(CEIM<>0vGzi*#9%&{KxX zvTqS?=Eu_r*xRgiXY6I&p zm;saXjk;u5}*#16zjP%?Pt2c%iyOUYHY zJe}U?-_L3}G?(#IZ&2}6420w1EcCY~*^ldf`B-vm>7!Ze@Cr%TFxd;AVt>D$^T8@(h+PY77)l&$TQq2K(-1 z->j%E-8doB*bF4`zP{L>)DbHEs~v2mh?U8-)b#hvNtf6BokIoE1t0Fe7+K|S?|pPA z;aZ0);~fv(-o&A5hGGADz9EclQh5u{^%MLaDT0QJ>J>4>unMInWlz4C^YCgCMvP^P zv9oGMd>!c;m|B_TY@Dr>R%R=^sl_)(`XX*c%dh{6vZq;a)o~J<&^_oyzR#RoYT~c% zf$p6I$QoE>Mu30wx0jTWuhqG%1i1PNyxh!0D6$W{XyH!8fbxOn}y30xLqA z!T@kQI*~VU&&M1!N{_Pn4sLbhJazwi6=hoALYLawU^qoKS6^*;XX;jtMVPGAs@-Kg z;YHPF=(FFi@?)4DtsIixqfSc$GfMTh{oX#%im(~r_kss+(^bmOY<&63`YOx7*E4Z; zXM&kMVGKa44~Oe3U!zRmA!Y^g>kr0WrXr4Flwv*pvP#*FwTdrpv}+|~lr6mHPO)+E z;jlXMVz|AQ9lZ-RUS6Vy0$`G$`5XJL{pS^WzSR?YB7%1=$8DJne|4IQNf7ubb&3NvpUiWt zSS)z}wew5Rn4Oe!&>Ig>#%$N*Gn7!+Z1p_#`1&l$|0K3Q?P^l>;UV$h*15?JJ4qQ& zioV5TZQQwpQ~mS999dUpTHtZ?@v>{1mzp-cjQ|*Cpf&m<3S?>evzMDT0 zu-n5XbTg+VMC@?&Q1WB}1ue)8Et^sa#8r&yy>JCud%gANe~%u0H!d&#j?gOkHSdL? zu?#UHfd|$0Kp*z7(;r}+t>*Q=G^BX`KEUsKr{$bd8FzBeY|vzsn`YAbIIwTBLEY|! zCcH`|hlXI*zWDX6%@f5{77I0KsPYBqzq0^~pDF1ZWGcj`bijOfi^-3~DbAGD#~Dfc zo-aS>z^40TnwXK{6N3;y<+-)Z*|>cZC#ah^nTGV?0dZ2^bF17i_sQZRk7_wKro1fh z^=3@iIlbn$vm9D!k3(w{+wXs#y3aj}s^6$dJ_(xCRZF+r^r^sHaC^bGeIp!uNi8Ut zvFWgZ(&iwy`j4`YAJ!r9;ocVCqqANBTcebLqj+FV#9skz02l0TaDi-KMhn0Nsenbn zj~l;!`@^}jhiNa*s@r|sN)-*P=-`=?%#6Mwc5T$LLPx(`6Nj=-zTYXGFf3?9okt z)ZPThcJWGBlv1wuetuxs!W!`8)>fDa*>#ush5h2~Q`+ewPWtP5JNj2w4zk(TzPEIz z%9x*A_(UtEk6Q7KfL#`C`lxGGBBf}MYEzNNH7!^%uyt(o3$ec$pwURnVTG<_8=F@4 zCXwyA2TG5I?$sZMtv7e>{r?mp23u4Y{RkE50-x`0o-a%r zkJg`Vk52I;j5{FoDeR9ezIHr*x%OxrpDBk)t6aD*(=1ShYui@Idh6Fn=~`hhoTeG@ z)#JrBX)1wz{0|>lA0TT+XR;T%SsXL&Ohx`(@3*L(d=%sv5gaR0zoRwhLDRh5eYbA^ zho&)K=Kw!zvO}G$Z#41yM*wg+1v&@VECPw^Kr5<*hr|95dt0jFZp3Z=@8Ee?1%>$V zz%#$EaUj%TQFVBoL`Ub0n;utT*q*yNVzd-RiC;y4b0d1 z=Ft@Pdp~;3BXJ5?iYnp6T;Q_flH6>t<+*zydcs3FG5V^VOXFE?r#4Rye!J zUR8(OPca^||GkD?|1na*|3jUjfjU)~>J(LKsuo*L<37p2F}!m5L%&6YJGe~8O3y9ECL ztJ<7(NZ$zJNPuh#9w!G?MUt7)53khn>W3l?B)it*2dY4Z*|KltAXY)cTfYRDF#TP- zgZ${6K+Zs|t{62J*eVi73LUs)pzhVCcrV0fbycmKKKs{2IYcs6a6-5I>C62Lm|Net z_s%#&y2I~=se-pbNk&Siq{c7#yu0PR;IaW9AGGAL5@IJnUQ4!2NoIoqKD2ejkh#e} z?(~KXX4mv?T4) zhihQA2oH)Z>*6Xe6&xYMYC=Cx{v31BahpRL{Q_^L)?K^kM_8WsFZm`otKAFLg)&`^ zD=l5}n)bw)SnV^sdlmIyJn`UcYg}R2TXHa=d<59+-^=MSP=Q`)Fe~A7zOg=n*oB!G;dAugYC3flaQI1 zu9gyIrGjimmjtrqiM4c5jTSXqvA@2TGBH=Z7`N)gVlq|z{WS1q#^=SFM-#$Tmf42( z`R{UucY^K`KfOJX!E%RJeB%5AyXVi;{^$9vd%1|(WVh-WSt@l(ARiA3XfH%zCheZ^J%0px@M?5+DELv!I-1uc@ zq|~LTr@CivCXix<5n{AE>02CB>Tp2eZ%?->+#Wrbhsa{WOQ&efbx30Ej+%AM{$>a+ zmw$|U=MVdON3y@oU8TWO6xBuLyp&vsRMiDn#K_&%EL3nt{Z?sAR39vDZwI#Tq(<0Y zMThS>5l*!yd9*Agz!&!KX-P-qurn3vL}X9WH5OLdvM2li_U6->Npf*RKc+u==Gmi< zzm{IpzjKWJ@cKwJfK26fzQ2y{AmA0xC=j~S@yX{KIw}>0j292ypX#!C&tA{0)8w%T zDC5imlO2G$bJz|P}rS)gw=`>j^rs!?;{9$@c@fu{9>70n%{!yeToNn!@N`CdlL zmOP4l6e@{{KOj_7Hm#qr`HfW#h+cM9X2Sqkw!idmf59kKE<$YoJ0BYUsJwzntmMUS z-mh)XRH|&g_5J0|ih19!bx+J>JX;Ql5s7{3Z;fGOF-F7ftMUX9DaUB`1cMum6e7e5 zp8Ksu6e3cx&AODIi zQL*e?^h!tfkV7SLUd@Xrv*rE6xNyeMVsevo0l@g=P;V)VQ(;N{N&OwmnR3(c8doj6 zl+VQ-RDrY$ebyyIMS+ao4|`K`Ncz6^e;|XSCM1tFLUe~8;NIL3|g@m6xJ`+%_ zc*;9)0=6FpGxmU8f}R<)+F8z6LlBpA#l0~-HnziT>??`Rryp)AF93Dp1BR4yCxKyN zjEZ6jF~mbRIZ>pyv|fSKSS(43&uH?~2Z64Ed;NTy2JqnKtOIOspeiv85WuLZplT2} z%0UG#lSt`aQWhV*k1G^;g(LE$o%=3Ay_$F8b4Qto4+2;XZ0N`(UhJ2EWJ>l#>hdS&+ zTE>UoR0;*g@Q%>G5QM0)FF}d(z56niExn~0A5_vuhFG-h^}z_}F_RiP%~Pe=NwXX_ zvZqy4A=hNq>wY3mbEOGz>G6dGL&Z+1_ODsumKQWuKCIpNtodN+eX>}ih*;#D8OA+) z+2@M^&Z`?xf=NyrUB%nXLvDp>b6||%5SO4@Q@5L%QVhyfo~r}GL_7BoDke7%@Aabd zKg?j}MODP0urZNsLB| z!6y)L*@pv0)ONT=A=8N86Wr8Q9`^z$E7|1-OEccr%jW39ekeT<#)4h^?Nmy_^bt|K zd{<5n@sCKJUOBwVHgk~);PdNP2ouxr;%9Me4sIm>8YEFMD``nRTpdmF>0`L+RY4DV zN=kH0CAb*diMIZIrkq=RV|-dk*VN&EjbGYZ%2u{L1-I=ylodKzO#WCx@~#k2PL15f znr5jk%Ox%RH}=B;c~TZPuEz75^IB;5TO_C!>bI7T3K&GnJBA+dJW^{2{h{}lplOnuqeP3N>dX#ufQ4Tq$xXjG zU0N8Af0IbLr0BsD6^@#=KAtvY%T!@PGDnARyki?fe(bui{sg;^H~H$*_5E0;NJC!Q zROb7n(-F+5x+PS$-#m#Al=EH44+5q1L)H8*gAsnMDa>8G!@0*lxoMR!Z}rnrW#{P9 z^Rka=sk0RU<2d?1o5M?n?~iSNd1gmR%(GF;w)eOElh$g>(YxWbx^ckb2iOPjEv`!| zKuF0!(vr~ge#KCzLj%lFK2@ueb_QC+Utorj_tpM@&>_bsJ8BT$+psrXa* z)!qsd(kv)ebx+g;1F&oVV{vq2w&gg5mRrdH@k#zQbeBwe$Os==+?H&QND5wf7W?xj zAp|AOiz)W)H2>IofbE!?)^zugZKG~8Gh zq;=vS2agW#E{QpNCbH?7^-E2jWZ^y0hvZ;KdU#^%_@DLrJ;)$;?NjJldR#dIYI@76 zzV=lNve%kn49Cb7^*wk5s?modi>X{81oyL@3Pdipr_v%!_~Z=U=(Ko^ykaTqHMn<7 zn-ZaoVE`P|{^$FV4*+-L;J`kTEWy1O`QG=~W#~Yx7cPue0e+bNc*UC>TO8mt6S%ow z%qz$#8aQYbtthWx(~Ad2&!#vz0C~uY`X-swgi%reerh0id^FeH_v6m3`u7yLqBROL zrj;|=l7jobWgbd{rL)g%tqiIeI7e0V4f5dQCZA_;KW1(v8zCMDWKF(-SemB+BzuvT zow^(1ii5}t8I0O6U&a}|avDb(Zxbfg`ImO7eg~N8jSXBy2o+B61s_5CzyJRA2VcgW zqT-$`nz_k;fFwi4a@a))4PRbOZ(e)BJ|mM0L6cLm#Jz0m+eLP&<)FM04Z+u9$}0T7 zv4Ah#d8%|wX7z-I_XDRv>O`_Xd+tZ2e{xIQDoAs&7SMJM;;5uR%e_O0>!HlOi9>hd zrqdsE7nk;rhJ~bHP(8T{6VQm?fXvN=Dax&HZ> z8|sg)1EzP>xsVb`-5eC-EMdruy##8l$@1JlqVkXW#>OwqEQcv^-moYNXFTUliKbxy zR9p7g?{A+~xyoaWhypvv+IZiR=zg+V>VjtCWOsys$~dJ1l*TYYg~pP$r#i;OE=UlV zk&oZk0|Q5WM5i?1Z#TTZH-mw5&CyBZA4q{L);S2)f{7Wc29KN-Kg3w9r;D=glSNnL z$pa~Gs#|jgdb4KMAbOPa5-{)N*j*=$dcA*C$(6424Tq&mfI$qeOIZN0x!oPrD6mB# z*w!Rhn6V4zZuAFr_%g`uj`*8@hH|nbyxh7cdlTlKO@;d{Yw4pi)vE9Cyx;PVbitq$c|llZ|%k>A({t2IC!mbuDh z)!eHr3`k&Az}+#Oca!&NSig~MK9R+oVe7{JV&Qni8`n!dbFsil)wmiWee&GKQX6{-*@e$y=N%c?#4Q89R#x@QmGbTE#vAkicKb$#T$12#6)7 zU7nKjXcu%uG=$S;5BiK#!5W%lhioMeRRCELR)9NE)D2QSNX13nlY#$EEb;$~`Bkqv z9Cm?qX_7zKBSNcPOx1ScbQ!2D*0rCPA|}oB=w(uV^5d=0;K{PDU!s z@=D)`H;DX1R`YB6A&~qf^AKXEo$#u|7ul2bXn2$`uhB#pNDTMxXTCxc+%-ZQvQTyq z26Umoo!$&;`pk!k#!Dl!p#ZUh?&ec#9#{Oa&`Vf(4ca1K2nOY={*eT=5bSY-q(NBF z5JejWXa|2UYi{qfwuPkd&{?BP;P}ioiMS`2c2jE>1I#BCI zq{#uQS@+{0!JYspZ~wsx5EMy8T&`K{>2ZunNS;y3+#BN?F=EO_tpNw!NT+QX=fJTf zwQ0GEvF0gIB%pPGUOc7z@d>AiTweQXiflmuBnf(s!!xo`rr`sfxYMvXeOJlBJ?-#l zSoU-H9%&);BPJM!c=)VlF1qkci}A;6Mi5iU@d#VLViaw#|68$$u?F5R*3v)`!9>9j z3%bz-d~4zLpSJb%xK9Y^$4t9#NqfZtgo5O45g^rdWy1-w8T>=pr>8Rz6USM^kZgdK zDNSVJW}Ve4rNa>=sQe7+B^!FxKpKeI^a9HLrj&Yg-^~eE;rgmk8hM~B+vfZM&cKbR zNp4ghL|V#X&T&m~b2&mWAnZ@s(r-$9Nt28?NIbUgLVaXM6>gjVEF>_|RhBY@UbD<` zxAn8h#W!3^XIX`Of^=U#Oyfoh&f@g!=Uo$jmsA|e$RisNVlzZeyN>0>ONUI*SnoKu z-(&WiQPSl`eh4u46@v_TM=sx#)@D|uKkm!_!@<&MQ<@+baZ$G)t-}yo%yR^E;#Oph zy!MV1T^nP=Fz-c)-Eitj23tkl<}Z`?Bokn}C|0qbXtiiIOt^e3iDkZyLWLfPEvP!2 zzm8y!BoyL3dZIL2#}}{SFdE|n9M*TD2a2DgpWh4Z0c=QzQ>NyFmO9 zXE-GhoIioIApG22+^l~*{p^FqgFDQFT>Jh6>2%a%>;jw^s{5Mjw7|Onz|>{^K4a^^ z$j$WNLKT9pqlrV*e)g4qc7~3>@ca4a*!FesW5K@W5-BrU9?9+k)-Ogharm1Fq@Iom z3;MrIqn;@Aai*Td2OaTQcx{c>-)5JYnLM*S>Y6EMo7BC--(snhVCLb#9wtwte!?to z{Ne|Prwt%arEaqeGw?=^SlGO`B7-wXwJ|&R9Cvo7q8EA}dtzzIaW9zoee*lhd;vn> zMA^K5L-uFNl%I*%2OHJZ1JC|lfDt7Dq7N!fa)4D8zdP^0Oup56eYG}iqJ43@S-H{5 zJ5VRS=34-K6U7Ng#5m=%_A>VKr8e)!%7np>$!2ejrn(V7drR|Z8atbDFtL^{@5k92 zrw;8li(l^}YnigK54v9JTw1;ONstG>#m<=W^MQ1F^Zo|`9{z!EKYzHq3xW!KX`m*Z zf-U}V+I%;oTH>lMoKb>^zjPJ*cb?YACbbZX=f2x~ycB**fVk1Ddb6Wycg$*h5X`rQ z|M&XS!^-gQ_xdV}bUSy>v&&^U*Bu7)A~*QZw1!HPKRXLY9^^hOe}lRuR#hTy&-np9FE#0M24q})A@z-O|0lY|GP7el7msU9bU`I|=;QamF-yTJ8^`anNiu8Df!*esN z7*e!UJ)+yLL?Ij5+pDQz-m$+BL6KilLMrXM)rLnQ6Kd6rUMw!k{uYXZC+IpOA#;84 z*U`~2VOsZ{2nj7MZTul{qVn-L^?CWHPwlx15rS~+-Cw_d_g-3uTpp&$1PIRs{ngp3 zop#j-44#t39QsTPUbpJmw#0sq8PGC5Y8XUu0-*X{ws?I+NGYp^^-c88&RDD!8ihER zY>qS9O1*$a6tPaSOt0lU24byeVa$+JYLI_}Kfb^&%+^Lu~ors^wD~u4(HBme;x#kH;nd94Inf zb@@8JEwa6TR)uo0pSTkDlSssh(JQ#-Q~~)rLo$2v4Tsv&)%kAM3n~uI`^~@KxeNg( z<*;c&c&xz(R8KAsAQ!;hyHA27#9U`y0LNMl<6ZuoA8k*2^gXIu_zBVLC``4AVRWs> zaTzOp6M6Kfi&f3TqteI)7;doDd^{I_7`b9IQDK`nv;(|Nk%+bOorFqCfPTn%64k#6d)FT0cvm6Pt+QZ0qVlWKg^5ijSK zj&YOfX`8L`%Q#=fS~p$%kumQ;x46p*6n>TT4vE9(`R$m>y^VXdxW~#>;Y%>ouKs(rtOkThP$K7xOeW-3RNlCz>j8uCG>oC!snuF$~LN3oRtg zLle16IjpGmCq%yryi+YTtOywBF7HHo9Yt(izwSXm$B*2vX(zwprr)nA*R$aO+QEbWc`iP!pA9R7F`9uR?;G$mg$$NF#RF z;|Jwt2WKPhNv#^CLPX<*kWsAu}wM)}hk5{a?z zjPoQC&r|gc+*f{Z)$iYZlarkp%k~;L?kV)-TwA+|u)%kmSEKmk9C-C50*GcK-lF4K zznsVEv@vC~PxksAx2h8yzu<^(1CC&FrPFSdIu5qXEk?7(ztxo@DNu{0d7z#sgbG(JXJD)H7qNZ+Y5gSCgFGww=Hgjp2Zchfx#uIkk5skx@oYezRb`k`&B?(*KXCuMCK)jk*;CMWs_( zxKlImd&UtFb+H38nyJ!pd z=ZL-63B&|5bpTd?Cbdw7O_y#IY94hM7(S=9dO|E9&v7%9tohbakD_N0B8P2aD#H5@ zO6mKrVjr{XB#aPV1(q%Iy=o4yVGvHMCnaxbMedZo?BIDQ`V$~LhZP4U^Zeuwy=!-u z(JnR63gN>DTSn~@F4fVC7)m2j(Me$Gw~ZbrG4K~Bklhuzy1_T#$z$@rHlF*^Rlhigg86+%AHU&larub*RcBcQqufK6_O!d!oss zU55EyKHMxltap{~?%G)6R5C#TV!5Q9U0DtJjO=bW9#fv=O5;{${BE(%S4221gp}Vk z2A=Mg)YlV2{rBToSw%&%#l==uK#!46*ltdL>!*Re*U*PU@Pd&^o*dr|X#|I@Cx|8Z zFX3;?8j?s3ct|EY!jMhxsH2_jjTb0a4HU_pqXt@7$mJkjS|J0fJH^VVX=E6d+)VPn z%RD^9WqZN0dedd)z3Cy-;tGq{_|GmW{{r#^`;e%NYW9Erc4pdH3$x4N#La!v_s`kj zp~a+Pe5UYX{U#ZXW?yU43n3NEwZy1RCE*g3hiT*H>6v8mHdxr@(%-(luUI8Nk(S>< z{;a-BQ2Pme%_LfSN3dI>qWf^1#L1kat;>4vYAuZm*9jG7xog#8>m;!`Jdmyqjs4r*8Jrs8UuTUL%X~O8M%mo zxxuGoyET|sPcwyly!-ZID8!Ed68|djPs+=y@of13G9paI!6XO)rc~IzMHSZ#k<4oy ztL1!3-*YR&gE|H8^_i?6I5K&pG|xo>p_4WH=$@I?a624IfLnnk{? z^7I6C+uk+lY2M@DKmR#?f8>T2E?}AKqr9Kku2b!TyVt0!@B83}_reg?F9zz~qN2Ts z!)g6myPRmnExS}A25P?w5Xehq`aApW1>Sr!%~gPV=u+R zA7XmhYD#s8r02N!f52y3};IMqXIh?^{hZI8M99lz#VySi8el%<3 zvtP@5cQ!HB?BM`S<$AN^f5UQp4iEYL<)yrFxu4KVzu!tQ0rd`^k>SsQOrL|shMiw& zYH`r;hy@uuOzRgS|LAM=Qh$4;Dt2drvX>ZsaHdt)LoN0i1co}q%_5b0zd&Cns$HoC z7dN@dKR+Ox!)a$JcY zSBmg1zHG?bYa>c3wa-j!h7QQl~m3SU{1e~)hk*9b2_PHOuG(L z$RId$N(mJYXlY`KifCUMZ+b=URj*Bu|KWbsRG7!MxHxD&Ou}h2@;le$)0L+$H`|$S zgruL*#9_2O-21eg3@~kaOOyN6oI&reatn=Kleo{AswJ{8FvME8|Ne|TeiE}M4=|e^ z{On*DdHg;@?LZ01k{hdYR>e|0ZpJeX#=79_I6=CAiSlX3$<2+CEVV)FTH2wZjLG{9 z8+o#3WHG?@&HdeuyMeo(mFr%5Btz4XsPAH{cDVvO=I$P6blB@ZF?q^)xS{E=)0c_M z9i0;UukFYy1Y2Ld2`KNe-ojleG-oOK?eNCtXdZ`HmA#RGEVBjQR?GTzK_xqiz!x1x z9xfTCz;_U{6CJ=Z^!nb7miP7Jjd^yQz-f~p(YCfODj3qap3;L~Z$MKSo;CGq zlsx0suXx6d?X%}2>;k$Qgw;)1E7ae;*3`n4dw)-phxj8jjqxx(qAW2|r@rT(cZDOE|ZudSDH;DU_T+>#DYk0Li{j`gPH6WzEW6 zT9M3+?4wVKtw3M>xgmUw0B7~XYD!YxV7Km!@x#y`<~H48jr5@I+e;J9ck{#GBjf(^p@(Aw zgXQVPYW_1lTh9P>{ zYyJ=JLReC^F!D(d;=eu?3(9s8b+9j;P+BhPuvGW9WXH(W3CS%CiHiBx2vQ#HD_r!K zIU%}73x<*R`bXwYMnqiA5OgKOb-Y3PKHI4`bSa9d>SL6o6e)ICI6g8dv@64^zZ2bgQ8gVC%#1F-Wm~r#aKkHS0OI} zP;vQ3X&JylgQu9b>ybQxi+0CiNwfmlR#Vn&Cd1%eG;kd3uYgt6*$)eD{F(-PJpfSw z9$?Ltt$$?lSShPR4$=OYO8cl0FRKI4K&>yC0`_)~7Hk+8T$vDde89sKXW%yXgD-|G zNc|4=+o4E5*gMOZ%ho830$*y8&Wj-^08K|800$m}g+A@|i)Tof3GGN-0mWBEEK^JS@UYs?J^Ycw`;q6A_G})7 zcJO*~7oRGlcgx!a^X+>)N5eUZcVFdI=2MmCS}zMS@1$phi|^9zpmDs`Hy-Cp=gqkf z7i*~DomJNk2gXanDD=AF)wS$D&QE`H@wp^Uvw{^40rVs{*Gj81E)#YYS>$RL_E^SL z_X7iaTT=43q^>V{sPZij-%p%WNnk?vI#4FhyF9X692?^#78%)- zl*V1zM94_>_}Jh!PI+`9kaj#~HJ81cuGBKCc2-@chlOy*)k4qsPIxq+_+sh&{Cm#x z6BE+?A7wfYsNgK(X7;({%YfWZK`!mpx#f5LM%NchubO?;S7s_Kd$GvB3K)6KU#E!_ z+H}vPY~;pWoR5+||yBgL54p2IfAT+(65 z!1zWqt_yR0M&3q|6%MON{!271_BBM{B_Fp86H{RRK)&PUa()55&2p>84%-bOF(RX5 zbxS>ptr&6sF`cHScbBABWM29JvH05!?(vJJ*595(2bxB9)RSUuYx(p1vXzDu7n)k# z$Hl3N;y3fPtwTLn3tOf~@x4q34?&ZYHVbVvBv-}&amS{itCN#h1sB@digDshqCr#F zj5Vz!hhziSnCEpS{CtvWrfm^{pUFvo}n;rrM zS~Vwil|xxp(zc zO^w7R8-AH(*U5AU=9W#DrkL!{T(b2Wv|pC~EZ%Z)X~)*WVVg-KpgZ}keh7WV*E7DI zudjHhZrR*825pBMagAb>a-;5U+dx|ac?=X|Fg+qv&zkkZ#L@XYyw#c;k?~hliY~VT zRuM8+#A!WZ5aLfqDGk7EOxitH^>)VD2e_}~8l-v!_#7J@K8-->00`^fA(PI6R{q(_ zj};lZLk<$ALNU6DES*xZ7EMR&iAil4~X&Bndn3xt(K zQh{8B6XJZ3*&Kz9N{Wrb*YHB9RJ;~u|Eb(aDbp%6TsR`lYO0d7Fz9DUt7o}q>!0pN zsP6?|)!kp?=7BJ7e;gyaEtkkSqGzl8R9rZ1ltV865L!wNnZ!9^!o{P?9f$=8619SmoV?&TO{c!o zt@o6)c^tOTByQ|)W)Z7nEv~8x^kbJ5l9$^Qd)y~1Dd zkr!X_N1^0bu1|XTN*%?1nSVEeloL+<4=4n6ZE*NrC9Ur5+FcfPa0T{FY{k7sDHH{i(p4lig16by@EcLnKOkDsE?ZT4-;u>I%pzh8rYUt?j3H)Z)0ZFlPDH~GA?}f$; zDWjtO0?y>WC>1=vh=V;tE0>lj=YNWCu+5xG?Hi5x`P6gi-&%*sH~x(tl~Xw1j7!B} zse2_k)fwdGt5sfnAA`5bYqsAGD^Em^0N4#$4o~ccb86g&5E^e#3ecb6D_xSXmIidt zex?QfjDEmKg{W4pz#t9-K@&!V5;EBv#X|sxP~}cU-Noj6}6}&%;NchXEDK+aXSjSQkB6ei!ZEv|#iY)Sukd z01)Fa^~j$xZis}Cc7i&O)Mq<~{4agp9vv!gQzCC7c!DXt;S`|D8-{)NakyZ`lYjO# z2oa)0l-tj{jr}d9G0Itj#}iuq@yA=jh!Km{zT6N`e$s=pkZCJ?X)Uy zw^NM|;InLKfk+#ghLgz*%0witgh*z>NqA3G?!+H*8J$gp!9ayMe6KoEM#?4D5G&{_ z<-7h;n;4i(=8Q~MGov11Hv&mwiSIZ{>P@5czYjM%)ES%pX1DX|e9XnKO^1eJBWwIz z@%n&6qy`v>NWm2*Za^4hKxhG@^=j%%FVgUK7AkYWk0XJvkH70os6y<+HH0*9@GvcnP^+D9dn4`9M?pKX|WZQ^v~Wj$sQ|IA6&+&2%7 z5J?jgH~xAomtiwd#+0H+JZ>GJxKoY3{Ocm(@iWS%?}1UA0my6iwfD$zjyFLvDkk@C_g{j~m_}RT_BQqecC?CY9tZ;5d zqZx30?Shz-@<8%2#mzH>YVLzZoNhG}WsC}vnKrQYfEoKKUSsJl_4ud@S0H9Kw8q@D z^-t5D<{@Hn)zGoPs3}7&RSZNcfnF;Wy7+v)6B@nGG~T-F_7)$jp=8usCrN}QHQGWn zDKUwH3KM`C1|rq^2te%M4NA*dXSYgvBEQxSA0=rZW zVxQr?AivZ1+h4I=_hKR?nQYk46VE?=(#RhXcNAh7Wcbq=tUJZ+iksvlh)>8hP(hTb zW`&z{1uV}qR^eQviZ(F!$3r3)5x7ZqK_rxk52KPN^i*lQr8aN2f9^ z1IhvCjx%Uo-~RVV?4a+`@7yi9B?Te-Q9^Hm3k*LedZ7LlD%a~~?M~0ORLtf8Yn<8;c@@#}eCW^rg4D!Vr@Q}lebP{ADx(uHejW_{zt`t{OxH^% zb?S;)sT&W};v$bd&i)jplvFt@l3tTfA{hJC1E?r+>|}pw2{u498kDva?0ol3;+@#z z=J*9Pohiv<&@}e5iPM$NXP4#ZHcQM$J8#@NEWWY{V^tK*BlKiUXspAyX^cb_ zxG|+_R>N~Dp#78;5B){~Db`!j3}mfm?`!O-(J1;KpoGnbHcHi=O zrgO$hf?(_G68W9@_9mm}Ayi_+cl?6^L?Hxe)!Y10@9~mzyoc-<6E|V8A)gWj+`I$~ zH@r5mxJ)m6O8FquSunz0@^N&6C<8|0USnV1SHp+eUk7_xIxmW|*}R}5Z&Qro=id?9v;JMj7i9u=rm%4@^`Rj?K*cpp;P zT8!lo(1M)hTSbZy#mqTP{BT4x@2<$B;ANIU-C>ob5*i=HBB7P^*$zOXmnYy=zLuor zZ)aYSMnF^zegwzmfHn3}%?7A!tK9vB&=*C>x3Y}B=llWx{foqY6C8PEXCq?nh(I>F^eVR9uGG@~^MiS@B4?R;nWby3;87`zJ z7lKH0_s7`$>F%$lzPBmex2}J-jSc8~=r1g7vTQfK%f%V{H}F%%*|`!lg;cWuf9Be_ zrw8`FvjjhkByZnt4aO`~acYhH*v#DnG7on+T5ih#o03>2+QwvUD|LO9FWHL#*-X2A zCSAM`p#XF|#AzDkky0^yu!vhVF<OPWen@k&jum075&0{>zte6s@ycQr;f|Y zTBUb-6)R zjEb#u|5^v)jy<1&e7u#(Xr{4;<%{h_hL_skjlS)sX8-(>V>tILv!UJqdkmv}{@Z;> zUC6sXw%mW$O0wKnOYeiPmY3#^4j$&hQ#$2oQtsTWS@`iSO=mOm8yuz>_v-b9Sp^&4 z9{M-o1w2?Vk&)vBDHV%_+Gz=HV87EcJH=tJ*_;tB$Bm%Niu;Dfj@0BYi`SK z?#icCQPSDoHXk&h%q*c)_gO%?tR_}RTaqJr=nsefmC#;r@iP#^l6{=5(4Owiq)dgR z`#y&Ja0rDw77zK2&TtZS1PV1SV4YzW7f|=64KWKmx1L$mfN$GY^{Q;y{^!_#nXk}; z3+{DiQD?~m$X+YAVNQzp2hVOqsJiYV7wWd4oD)ywEUfgJ!0e{8C$dXM4@PrU?go8J z{r1EXqEo(Syz%e3w#rJ}Z<1}566P9zd*T8R-CW=IR&)NZl}@v|82f2iSoFqh`%6h4 z2NU>ZG%8bfg7(x}#DnHPM#%o@^B;a5e3@PzIExQv0^zgBN83jn-+e$V{stQ6<5%u> zc(Nd4PY2G$8(-pb-V&-Pkrp9)l6pk_dY;vv&GJ>whC*ZkepH}&h6Gscm76XaKR(EO zHly{u*_4fJ6derwB0v}-fvx2P=Ra+rJQJxFvM<$%y7;I2Hb7c=Uc?2BTXRYtNai3` z89HB$9wBZ&CX4oq1uF7-qcLQl$x+0q63a*HOM`iSvtYqt0sHN5Iq13#R`ZO4yyd8A zOvJ}mF2pLDlc7Q$wh<4$t)0}-{tk?kbNFBjP2$BiZnRV3m3JjQ65RT2W&1ySebr5U zaB>xWaRjROrLABZAoE6I7emTXUnI3f&a{9l2719uP-&VYTFk-=!;qmrzv!f?n)MrZ zQuc*coeH@Gs=ROQ#^d!)3QB;ZN?z&S^VXG|AG?GuN;H|tK}_og9c60LY~$|or|+t3 zsn?a52Ajc*;0TKlWQaVUWfbv=&G?5?0G_0VBG923|G~0OIhy;2ueh=Sj~6nxebMDP zlxSeD5N3OH8b{=nV?;PXJYlRC{cx+Xp$h7S8bxFURQ&EPz+da45?HRrlDBMzd`mXl z@9`Eq*?l3OVyG2iPwMjCNk_Q|sx@7RKC*{K;L&4xgC}!93x4y5KRXr{xRbJA05ge$ z4GtfMk_vozD`Xxi*!KCBS)qcydoP)L^#|45&q{IbgS2Y{Fyz*zEwiiVO!;y? z2iD!rBDHRS%WT3s^snKn=CGBQuI$U3!y%DNIge8k|H);~ZlAv(Yi9VmH2RN&XSSGS z9rD2}I1c5j)H_B??jj*b5ZEG#@0vntT@2CI-IjuSz zWXK)uv4{%qvIh8W+YdLxm596T1d-p!5i1F`k?;EnkOndu>pbZaCK6yk z*9fb6!mrvVjI8h|p}`Z!q++%`$~@` z!TGZ-R&)-y7D#zVJM_TN+41H_Vzu$hef2=%`*FhhD1)FhR`_@HeaL&WRU26~G!A`s zFRlw%E=$`@)$5lBY0;cYj31|m`QCbuIRE8AQBnZLv7`_4pd2Ma=_-fPnht||;dE3b z;;$0YxeE#Vy+(d8NYE(+tYz2P%n`3T4(T zSTyjo|gJ)Vzlw zk1ITZDp5C9>izk!(U1E*@15VRyPuWbvb%6`+uL-6rZwg2Z;{M%cq7lQ)BpI;LI4tm zkhQawu_wYVkaZhuOz2v!HfepYyWQvA)ID4o`+N3twO8xlO}iHEiQ*}wd|r6JA{jfI z$?bmoxYWxfUZhWiJ4tuH-kT>(cs@}Qv&)-?({6Sw3jc+iyfr%??Ns0hLk39sO>^7= z5=b8@F%tpRR;KQX?$F%BTU=w`5WzmQiIAW6=W3Av%nuvpS&W_^Z<{%q@kH|26WxLz zex4l-)XWX}>DZA%&wp<40Sx<|h}Ss=Z>#D%n0ckY=^r{25Wv(zr*#nfsLugN%)kOz z16_dtbj|*-Igc29wydm0p(~GdCCjk2ip=W13NwJO5oub{ASm_vU1nPWNd>g5E5z6? z9-tJdrIaPgs$d4I-M$cfYmXtXp694WVrt#IYp8 zHcH?}pCk!SW|36bZMnV~E#i^RTK1oJS^YU}@&f9!KPq2iL^3AA6@9@h2Mu*;&=kX( zJp8r8J%)+bHDLuE6BTxUl9%=>V_}mgDa)Ho%xS;flHGd{Zr0Q0eDD15GX0&X0E)jS zr)5@{DE4E9l$OT_S?Fy;sI4QiWnxW~D;BciR+%AO_@*eDZ5Q;_psb|3Nt!m>eaFng zjs9bW>V)d~LO|7h3f<_xoWaq9P!W^vJ1UiSCv_CdDZ(KHFOpi zP;Oh^;~a8hW$IEfmYRoW=Ty?-Qgt&@vX90$EOR|7<} zbF0M8dI%A=Lj5_%Q2`GBHCZoSm?$m9;}-dwsoS2e)4t6{^Tuw#BP4DPfAY=NAQB=V z5Fc0ZAY*zo^W2IlkwcvDzE(8On}xR-i}U7d=S_9r#xm)MjS~mVugxG_>+|EH-~)^D7r`ss zH$mU!W70)q`=f?2;q3|qzwBeW4On&nE-HoUje!8<@x(CBj}Ecu%YPZRclvjhfT zw|IhHlFTR>LC6E@7kr0Ap1S7L6DW@24y<9=vjq&__=h-bw@U+aHkGssu%OKjyyxFw z$rwLkBs(d1#{Y&_U!8au!q9e#5)X&;7(=-EEqSvoG)X>kz#Bh5K@D*n`Y{9@=@gbT zsyJN$1V~dv>Y6^m>2oW#Pt$0ZOwci@o@8!S#weSb!njOKv%ykggE|_6@z|CE1Kv16HUZ@UdDI zD1fdS2q-sZ(B~K)*Ceuslc^6bc^AH+qgS7gAU{2|8Y=YWXoF32d!iMs3D?@P+Bj1? zELo(e=0wv_Rr3rwtA_ubX+jb}$rW2~=zSEB;*kG?EAA3t+X<>Zdc9zcB$Q#)k&I?G(2* z_eA4s0E*g#8qHU{CAggatJ^MFTz$%Dctv93_SrZ zM4f!sM&plI-lLB5B$$y{E(5Q4Pg6bF+$wT2k*g0w2og7{i`N0Ph`AdIvjtj@V{-m4 zTtj^NUcL?O$>HN{zse1H1{9S>mha#iUP={{`aNA;zk01p#YXm zD+O<@TREeQqotZFau#j{oYu`+)UB!-Fya*4;Q@9<0e*80cibwy;njFHApr7H4xU~D z=>z6yjlqo`Qtp0h1Q)PlRZ9ET!IN~-034q=(lnrV^&hJL3yC>uj&a(?(jEr@Yn~|1 zbY@HyqSqCFG;CyXIvn1>I>-bBOBoh`Y!koDAEyqCYlvtSDUGHLbUEIiS!9-=p1$6^ z*e<8<;{poWj=5VD7O4Ms9QN8skM^`D2P)@Q_dlY}Z`yToo|c^(ae(~EVzGo%vm@nZ z_eb`xHQ>m*HorOb3>Ce_eA>mpieE*xYuP>(cg>HXM42-YZil5Rmfa%tp9{Ufk<&L* z05t%>v})5xJrs~Qo+0FWfAkN|G0W2$8z>WfjZ`|It@e|vzx2un+n=oG#Y)j%8s$p( z)m~!T*~lFeplIe!tiX7hXU!D6isrdmG?Sk5*7tx;vzp_FF>X>W;#(mI5s7Nc6hJ&o zJS^N3!1`o}l#m)`7g8u@SuD1j;7MQc`RsLPTL?*ne!Ezz8^<;y4wFc9O>2gPKws^m zf7i(Qy;omjm0#XS2K9`8der^k+Gw=)q`ys8b6tHI!NOMEedBSwCt#wUi(~j4&s&!X zK;s+^fz!W5ZShZ|J;syVAqSAXvZV`WKMOhU?`6$!R*fmaBYi*R5{*)CHGVa(B8)bY z=Vun(YL;N`C%tx0Chy^zWIb?JGH+=+61s~W-wy58>L1uEAop^qU29~#opqAb^K#;h zflw`9+|CZbn}WXne@f7eRw~95YqG3{5nB&YX)hh|pXmC6e}2epN-UAk347{bU;whN zCBsCZHc=K5sK!~fY=Ja++4*>0epOm=$M~I4jAY!2!O^PL=Ydi$%T|ir|5lg8NJq8k zs`~j}&oBuf0?CN*osGcay=TN3l0aXPp=p%!AE4`x?uQ`lw^%Z`h~d|a)i(HB4#=Z$nBiYM!vK23@X~Ng0x{;oP-HFS!D8E&Sb4Hznp60eEGD`SIc60R z4Nc{dc-C@gG}toF<6_nqA6$Mdq%^w7$E8_p4*O(?V$Ix|+>rzsj16ylWHc@+W%%^#^lqSHp9p&pL-!GWaGbK3>Kv@5;UD1>4ykl!0= zP>;?c<=NJQKsR22TOgpl3zm*GcZ*}G6@KQw=SB@jNmE7|N}r-LVKY9U;Ft^#CmuQl zk(;;3K_L-xmEL}}t`;3qe*_`Lg=on0W`OaWacR$f&pe~tpag(FQfT4^W_K4>J3)66aewM3n9}8xL1SC zsHHp*3wG5gp`tYw=(FXJ%<^*lWiWdjV+FZ4h(;iouTFuTSXvt~WEBZ<)*p2xjvW+eWPS3=iKM`0rhxM672V2)^e}HHS$|B!L>`D z=HRm$AZ0ofgd81w3bYao249r`?RjUsKsbVAzMlGQd9?k6Z?3}00oAIofrw>EjSdhq z3Ol@i3#dOM0;!ykg0dC(@YMN1bori#K}$XvynKc8nYwe#+U{NX zNFT#tyc-k>eoN1J(E}+zt-0orXz4s^NryRo&Wm6THY(iO2I<(IYn{$5=T5;j&lP5m zx&a=H}?UEXG1i27*gSfH@y8rn*y9};5t+GNh zt-5>(oD{T#2{g?E9ij-~$z;~FAl!eNFNo=lZEduTV-@gH^tyE9znRpG6^d}zlo-9l zYMa+h^q88pw5ThaqMf5bNDNHtDkVzP>>b`Nji`9;4ch3B4z_Z?ErPvOP7|9qQv3)F z$F7HRGDUa+#ccN?7ZY^aWqkIM=z-#i?(Nbq1oke?PhCld48`reM1&x6^x8~O*`VJ* z+{iNhtk*gK(H;x3S;(9VxiC&+nFR#OfY%yB#+Rl897~Kbx5cC2mRIkXBpl~egBVm# z(vmR4ZPg~yH0vpb{5Hr?t}HnM4h^>eX8ycsYdKxA1I>(ELnCQS*Q#vt#|2P%B%~99 zup4SQc>#<3g(pu|J4#qGWl^3<3xO%Hd!x2P43;d~5&?5m)s?c^Z)VkR0jSpFoO7!Y z%t}O0lrLW00TL+^oKTO)f!QM*Jr6uZV1Dk%9Qe`FJ_YtJilA|F^f8v1vXpP@?|iB& z(Xx@X#Ukm2i3&#OaxpT=O{1vZq@UEcefpC)Fv9V}qb^Y^T`~2slBJu*EIH@#ROPa3 zmw|~* z!BSi2X@nh$hY$|aiCy<1k$fT#lGd8cRX!cjxo?Bfit(ZvNc5oGKV4r|AF1yr-}Sse zBYga%4|?t7np|jlEZ(aB`YJ|BinIhzJm25{1%D?`Aa#)cx~BR>yklm2fn6zp5U$%i zf_XDd39$0e_ESim6!`6hb8(XH3sBh}>Jc?3g}-cTu4$;0e$~!OjVxK!fKE+MQ?avG zNls;}E6F{6tdefYUV+am6=;*kfuWEykTLpzk{3qb7;<8(YOcjg>@zKCWUEE~Wz5>Q zLC*8UhXA*ybO8#7(e{9VX(P7BWrfdnj|2&Pd)8fQ{wtg`k_I?!z(b_ZMmqecPCl%E z{M&Y$<=k?s*huY|PaN`w5`qf>MhtAOlf^-oSW$V*>>qZoiUkYyUjYlEtlA(58MNPK zkhD5y)BT&Yvf;p3S82eeF`mdS?S zd=6W+uNMHd#@GIMy4;3_x8^kQNP z-oBy-RwKiIg0T8zZ9ZEVBJ|kaOV7Xg(3z zSpYm>k&TV8fOPhEHMvdRMWVw1J4p0bnGj^6K)F}wa{kLoXUJNVz=0^h9v=p}zXNw! zQ1RX#b+Q90JOh^@t_I*LgwXkYaeG{IS^X=o^fgh&Ja4H{D)r^ix+ zW6qvRKJJPOgOokISKA`+dC4!5IP+Xf6w~djt;`Tvh@hnWlsbTfHk{q%J zy?XLo<(i7OIA&TX)4;_)f8gh{L~;Ktrj)fc}j5?)tp*V4*o5 z1rA&=5xo{IBAoN%=`IbkE)-eRi0P$=dk+$)E-bablf@a6CED_4S`yzYTGUvCKP~A# zksfZ!l`g&`Ss;FgI@2`71(FIvJkW+ZXK*-Z?q9<1W>j@U3=_{!mNd~YiA%HdI@BKU zqYEQrwHMBiOZ{dsh6p_K-WZXu>?bFGoXjBG{p4FnouvfS0c)v|II@o#kx4i1ODPS<=@J z_R31mX=oX~+E*K|4vN(K*e&7!vNJkT=gt)G>%h5zUIZhd$Oveip z9BNn`s#D+zlC;ob0|gtO17gkz%A!9+mA2EY8X6kQXA{cOhK3o`X7ym$XFxS$15Bf; zyqp|$TeR>sJwCI}yD*s8mlvk%eX((dqAf-NcW$`2xXZvUuK`P2NMV|}52bSP?p1cC zHhk7Uw0kn%-X)PNv7!?wDp2l7;-w8oM7y07p{*nIA3pZlVas2q-ItBq4<;xpi>)*t zJRGY&Kf7HF~Gr%ljO#6EU7$5RZ;j@$FWE07f`|@T9#`w>T z88SvhG<(B;tW)Q|#*9Q9W^@`d%QHS(@cR;%gakzV=UVxD%!Z zf;8i=P_faPFb)(!ByiCV5Lhui6^xiH~&?W%@j_&~P-fKj9{q1dog2UbN$NJ>hzzen2e0b z_4Q>i4rNC2%u)V>VS{_R^U4?iag?f0G2ZMLA>u*~GJLe7Tgn6>hlUe43;eXESL$gh zYP#ezOZk!@@z9aVSY7yEu~h}Nc}?GOuK*xmdS~?xW!#2X&#ehz$v=MTxKpJPa~ zKuK>k)D=oPhBv6Cy;?EoK}ZF`$AZ zUn^&`2ts(!p}*0dyByUg`W(#+2vJZ%n*8gXGGU^{`J)UeKpOGD6#2;4Sqn&X`Km-G z$__sxw0KC9c>LRseJ>&&$x0uBeAP<;K@AWAYUzW)p-8DRfo64K%U9f1IG^5(fL&u@ zqKn$%RbRD+AJ+&n?ZNExG^JGr%7O&1YLI}zBxMh{%w|4LX{Qtr7}egTI@!K_3>~M+ zY_rzf(Se|33ySAMBo4$y1Mk16`*@jXE^=!;HgYv@pttj8e4LxwopFnL8Br==4|W54 z5cs8$A%zrGgf@uNaRbG!W5VC&1&^_z5f-(H z2kZ-l)p3?u~%=1Fo?J zU#8;#aQyqi;(Nvs|EW~)b#=>`VT>Zkvn6J_%t>phxpJ(^Q$BnJkXMO`b z=MS-rGyM+rI?+wWGW*3a9#>_2S6yC-Vm|BVxj#|DPDJ*3)dd*M?yhPWCw7yC&%ITj zg`UFaG-oG<_b!!Fj%8N<)@2D@1R~9g0bfCS3RO)7W`I~=Wbe?YzOyBw*(tJ{A;?RT zMyIt+N`3`I4(XHmYOBMGE#oFT78n;nZ5>(b@^*2`c%`rHKxy&SkH}P*BZ+p>v4Yd`^ zY@$THE4mcOt|}1rY8umVu3!ZB+2wQ0=z#w(+r!IRj1SpZ&57qQY)W1Am^rK{>>3=r zB(g)s>dqyq(qMv0Ug{X*$J$8prxY1Ue8cVyG9z>LNj3g$5~4CgZIoV2=PrZ;(yaJRwjAa3aM8PD$N90khAt4X^{tGyN~oGoR1_%~%)gR)~|Em=JSI zHL=ZSGRRZE4g(ffbXr!xq%?shk*L!1_m zNn?}N-8QbBSF>#~u8&~>#YdaH8@wuOocYUYBJUM+k^fkbzqjC234AN;_|bwh#*Q`U zLPGkFB_~jE2`h>8qYawD2J#>l<;)=p;00X%X?EpSQ!y1<|C093J>O@05P5IX=K57L z+lVhXy21p|m}K^of5#Pq_r?zWrz5P_uxp0pi}%%d!r0CI$CvS=4pNFU?hA$}4HLaC zo8t&3QF z>!b=(&X`H)rapxdacls0WQhyEni=v0OgJ$cXTQ^j@W+jK2_2xMe@A9{Y=f&gwsHPJ zj#CpUWJJC={50?fnI z#t%FoZ!xZ&f8sHr#MUgSl#1fz?wUJo>?IL5xMD*;D!{h}IX(^uLV6;d2F~7(fOGA8 z1%Du=Dgp)wez5}|6|{+6Jpw#1^}|poG?ntN*dov|lrUwZ;LWR#cO|CNdJ*Z){93>}c6vCZS;ary=v*$~cG;EZNNZSM7qT^3%VL5!^ znW@17;_18OG*MOLfBaz}VO@mM^TTcM*gjy47upaF`28OZ@T3TUpHF#H5eq?RN9wD& zl#*&npKXpuW1!D{1Ab`7%dn?5i1XwD1Al0mX9S_Cdt;Oc|4|qzZN%1b5gZK)^-vca z{msybn7H#@mm%RnHy@F6!RB-8G=e}>6M~WpOUSY47Z|GEJ?${QjHonlxCWmN*JG{o zv85uF_A_;=B*i09TX%p$^u-Hg=#{paE&J~A$gAeA1|2`T6iw#sdo95q&g0i6hsBn~ zOaR789GH-UnA*!l6rHbnThxznW@IRygu-PEbjsJ;?gji#_`X%mW~@2vz(X$4gy?+Xaw z%sTgoeM2a)=n=GO;%NW5zF(5Sq(_nfhAk;p*MBxf6b{$}JHtir5G~R?AT5?q*5 z?D?8px0W{aGB0IRdJ*WX3&0iB18$!(VG+a7u3|JUfSN$GiK~CTaG(3lYNgNzrO*Q>;8o(ocQm-?}J7u{=83th&QuKiT9-7}A2OKBxQN z$>-J#)R&waZ*~YmxEq5Y#MCB~K!`!JB8O-X^mIh{es4&-sBaiNK$N8=%ysgOEq9xX zb{`}386T`|*X^(0RmrA|A0yR{Q>k@sN?Uq~tO7EgsY<5(%YiYXZmB{!%_*}ycD#;s zpux^X&_~}}H#{SQHtlaXIicO^6R(f^wKa84E{w`#zUUDLdVx}`rV`09Nov*r3(jvp zFtehASy^J`J|6~qJK0WU1Ha?VQ-6IyezwBqu-BEGhQ1sGIUVH`AcXDXET1B5i8KEh(%|on_K}Zq(_dO&5;>j@3RJplvyfez6SykKcbv1~%dejO6mb*Nt0v%K zS%`v%tglA*T4jre69_QvzCR|Pt7@(`N{fpjlUGdV?lrgmUTU2)TiI-^y`*Jq$_(x< zOX}woSZ_dYSU+Rlo7E5Le50FD3_z>TG+l_ZTl`7~k3xm+R={34ED!OQpMLDMfLbk!)I{Z{9e z`W(cy`-Cs?(eXFu@F_j0-!CMzbANM5p7TM8!f+i^O317kdlGwW%iXXsg4i zP+;zgs~D`ZG8QTqVtHxoAQ&iM1hZxUq17RuJCz+&xfJ$+H8wQ3*z@x%c?4geCnta4 zeX{9eJ|MQYGg#WFBVxLm&lj(&5p!Qy$)$biQJVX#D$c>adB*Hp>n*~KJG~lbs;dKO z#22{yYr1_*>ezpm=5*Jm^w!njEckg|k`*iKLXn0bYjlFwy zx&3v);%w(6<3)~zsIRMMU2PfM^6T+!J`N?})urM*r_B|l?$0dB!G==2xx?s3XTL=2 zj`eUJ=21IMRZr<&BXP#<^O{v+*G#Ws^f~i`H8Ta56v29DV{gZZ|`-*?Nn`4o6YiY2ryXvmKy$A7MFbO zK@i%pvh?2&t9_& z#ET|P?+<%%V9~@`klCcEk7Y@hJ+e}ujPcdKcg6jay{*x12Er-GDh)5!>7Ram;3xj^ z?w25?fv%3XCk}1A0f@FBYWjPK`!U`gSE<6kN3Us}c0Y^k54AZ|9S?6;EtL&)tw#nX z1dA)Dy!g1b!__7|xYH8RsA;=Rryd!Kch9YTLSnnq)p1A4NE`l3UF_6Gnn*LTnb2{! zx%7R7v_~g)!KsIYx4UW!?NY6oxii8_yn;}5(+)Rc_A}S<=0NpKmJM})DW`vcfscXD zNel@;j<;3Tr#J7qB!GZ885m4yXU7=4$$H`J9MD?ONHUIgOaiOX=afounFVP7`)FyW zzqPD_n|p5L7Hmtz5suLv=xuwys4pgUZ{7xqI;`T>@qJ_4pt+2@GJQa4I7_s~InD9G zoR7!nuTXLp{;s9$49h+%|9d}QCCAy?0&FUxF3HYONeeW+&I&VkbFfnCY-n{x?cx{d zX{GJhZO5iBmaL~%OVGIvM$rFuAHh@}n;IV+bz=XeZQu@@TT|Pii0QIe;@-h%3S8q# zsc%m9#Gyq=!)LLdfqm8+_9{n(lq^X%Dvcfj=G zYlK$0?4v;r#|{=r7y_dbW)U1-RJ`-Q_;r;UAy{6$*ku3O)u?c)GUQkg>gHZ z@ZTtl=Jt)^2ydv#BKAj!h0qvHF@ESSq9q&VWxKOmRMax|H};bRaZhujz)t74WDTd) zj6{~^ywE9j)qy9)vldCmT~>;jH~&mPT{*Wx8HpX+GR$ijGYnyJ;thJQ){?vHS$_Lm zsYZ$CfwZjdaetFacp%9E}k-Z?(10Up4#L}^;uOYwFR%acWj+_E{*N>>~B$=t*6Al zk>Hqp^M}f4r|s~TB#R_o z<8boCcHmZka@&UYJ!`CFKle~={;}qvI6Qh$yb*ftEpaXCNptO`e~*(c=B*gT@#$fb zz1PvW@3JMU+0@#K?zvPoBacW)0G@$F4OzBWJ>e$5)>4CQZMv-k{94qUCN!IJu`!?6 zWSG5nf70;m`{YDmE&Wu`=Lnk!0xI{GcL=_?{Wea~GfBn2FRC9kgTUJxKdv9j%D9}=`ysHs4d?i4OcA~n!(#D@`?A(e+1rc8W4BO12KdzX96gtn{B^k_%$`v; zR^X*xThs5P4pF=(&qO6tP<$6gwXXA)XIA=(OL%M*N&ClIh@yWv+?2>-KBvq?)l+;u zUGIIWoIFaj?S2JMY~**y2S7twy;zy=lT%200EAcKlTW+OqEHbKC8HZ5W#gI*F|W-* zT`W>7rroBu#@!>FS>c`=8RF~?E*7ATd=t)16PR#=T`;#^aBqk+x65Iz-SdU5Q^|ae zy#=Y!oU|~TxpcSG1ERQjaVO0a%?Dbs8(*~q6u1E_Yyzsbh0NJEDx+m z(ejOVxhPGO8ZI{VP_~TI5r2~Ot{`Xe)>zi@GWOeT6*Y!R{nI2}<7uI*A&1?f<@&=> zl6n?n{!Mnc2*x4jj7l##mvhC*XQQF`@WkL46rZ1@aN z=S(otgL)7dQBlb%fE#vWwVGSA?=N`_7KmGFWY%Q@H&KU#+c1Ij4PI#>vz30e59S)f z1ao!0n-3DY>=-KIDdYK)<YcMCeLNjhtYbo3WB=WvsslgdcD_|#XaJvMDh%tIUh zJh*Dgk}l`Aa>~|xWcnw^GGXI!*h6y*5f>I!dsZ*;F@=|2g9Y;%v?LjM6Iwz(8o;}a zFMimE>($fC^+Tm~_dniCuUbbJQ6MHU>?ipSI$xGxa*UVeRfdZ9Q`nARJ=vXViV{h@rmh(w)f`kYI8C|1=@kRYZ zn7r1oit)2?yH>S93wWw_CGLpfJrYxGKRl?ai@E3!uxf`d1XQ2Ftiaj^WJUjEH6u@c z*vnOG)}^^kZ?hxU7o{FLh5Xj53P%3g(VADAi|K{BFVund`u0At|5ma8of7|oVk+Zi zGyXT7Jb7GLLFfXe@^OA5kc90M4iX2t(zFncCEFd1kRA2-53-ndmCiT9oN$RN?SB(FQcU%s)?h0hTWRI?EV$P^#<`N?X$p&#}*ABjWu38G*cayh) zbHbFDB%m*<4kDo?^OhtPbS^4`exJ$z=s|gfTiW!F^SC$Ip*3c{A^qG?nFeJ+YMZ>> z%A;N|^=!1u&V2Rr)XnJ~F#dZEL@&k*d3O(-XU2FhIx~15Z5Is09T*YZ>Q`!J?X+G@ z@$_K)Db3^{j-VR!)q0oHFdIhk*(al_?%DZ?C05zcPYoV_i8U&NvkMMo>ZCC1zlQJ9 z19sc-1kf$b-i=3pM~JrrGiq}dlN#-xC~5^e2S;ix=W^#Zj1mXT;nJ42X>!@<5`)&} zQH=&KqdI^O%Nz~DGR(*{zT(m^O#N}H6OsK*BsLLA(;uVsVgs6-P78r+zH%(8TuKCE zr=yFd51q~emHVzO1Y-Pk!lnF#O>C#6H0h6)<_N^N&!-A?HPnMe-mfT{Y5870yB+sQ zt<%y3RcFl5Gulu%dDDC4o_Pn-@X^kh*`=-lk(%v>M1UAJ7~}j-Ui^X9uXz47ZlS}z zcj0~10;4$F{sNhD<|%r?KNCSyg;}O4Jh&4%;1%*JR3bF&7&GS{vR>C;h0)vMo?_zB z1egGP%G$1=s`ALo$~tuc$C@sNQ;&4R&!5FxOwe+^xa(DH8b~M+=b<%MjaG`=Fy1XJ zY7ujc>57^nA$1+!#>`8dk*rKas3SAKkZT?uv>s@X$9uYieXRmXXXM)k*V*( zC>>h|dV)_~T#PS$4xxQ#76RgNlY|1-u6R7a9772uv-Sq<^*6Z5ihEnJneV;+q@aXG z%}JcJEbDlh^v(;{IU6!+UzX8(a?ZT%#kDYNNxM6HxpAxDbOm^dfisQAJ+bn4D2NY! zJ^fM~(8s&k6G3!qx&tHRM1{8Wdr1LADjgkeTrX6?SiEE#)KQYj#LQupCO$|pjGjKt z=Hoo<`l95e8?nVtrASC2du}@;^1=Pwf>ymSe)BAsq>zCWA2)de9tI7pv$1TfD*tYD z*NRBSQ^dbijBf&&pn^0TA*Vr#w;FF>eL*{@4BH1cm4DtkZmIkq8`>-JP}oPE(ruUI zr{AJ^O1z6NKcX*# zi+i^&NU8MH=1Pqo3oq=`(Bg}37vM&eq8R0*P58te%*)DHdo;pxy!kq}&WT~vHokK- zy&z9GgHd1^Xj(<9a z^kl!ht_DU-+86PsZO_5!y&b6edGh5BJ}Pczg!SR7i@iO#iz{z6*10@R5&i=|J4?P) zC^ze$_MM{V=Zqtyjxdb)`ONtFqro%Jr{RIvNtk%vox?VxjyTERv+g<g&SS8Hr>#jKcvg1si!<6y$W)DQdiVL8lDI6hb^Myd5H_Q6_#7=05BkPkA6Odirm z9ik{Rt(%>t*65GsA_@*e5sk&sKkD(T-yZa(`1{rzU8n|3=6D5Y6)`f!zRT2=+t2x8 z#p_+cHxn`tpL^i~KXoCyYx6U7k0ym9&W>2ke)88jnC7DIE3+Ga3|o_^DnY5}%o_;3 zgulcBBG?|1tWqf!bcwW84J2Lw4)OkXT;|JDzbe!811j*#pY?S!?tU-3-xamrbdDdM z^nNY1zKav*WDtsr7>OU%29Pv0!>od`jC(baGm#S`3x>-uE=c$)D}{!;DvqN&-ecPEoqKl8^g(U?URVP|_w4bu1Pi*-+z z`6IWPzT74Z|DM05#@rHQM>KtG0uT6BX7=3}x3Ggs&v#>5>%@3tX6%E1KGQ92J+Xpz zr}YB5Rbt63cvotzM-JY4`Uh+< zF2xY^VCo^%mN7`Ju7)r@rP**#>v(UwYTtUjf!ptD&-xm+m`|{%Muz+*V9-rq7_3o! zhdByL{If!EJojl>h}HKH&o-i{d(IqJ6HHGKYgtLLxWNR?_=fvWYUR9t=*TBwJfx) z&7-zC$4MPCs@h#X3A_gUPV~l{~I+3$mBp230`i{_+V zEtLk6?b!CN+=glK!Gej+>OakAXM~1+8Y?KpG$H_{N~jyZ4Lz)W_1a((b>eUZke*P9)w;}}QS}ZkJaBO2{fS2LbXqo~70u=G+j6=;o^&SJ_zC>y~3Bh5}Uq1s=zj%O;X`rItH<>|8R2H*8s=TKTKYG$pb1i|(6+WcD^LZuF4yvMvWBHY3KJWF{FUv*_#cpGX zlZ_h`mzDcIqZ710rZ_r_M8tg1ZL9r>=AKUylOH^IfOA$KE*~M?nKnqehU0A=nwe*& zVHDA^DP4PuBUBK(*jU4o_x)=8F~mHZi^FTmYNUH7GLRj>vc@bys7nF+##+%FzJxbl zrEiw6FL@N3B)qKIOj}3BD9@LCpq5&kB4$JEaj3cbiBHMobLJ1_m2F+3T{Gu7g`0cL;BsPr5ywLcl|OfK+gnXuoUn7_+0=vE7qFBL6;6G|w_<2TB>4 zOt%S7uM@eV5)!D19O#xHFuYrLn+ULQnTmG>kAyBom*hAReERE}rcv!VYRf$o^~*L?_U+tS;#nwCMG*Eu43=y?Wmqu>Y zt(^6iyhIxc?uyPa|B+LLESSi{dSei+h@XQkD2ay55ar|ELXP$i5^g%>hQ%;`sqAxFXjE^5rI^UXNgFAujGd+Pmp3d78L9c~u9S#tH&UD4@U-i_HqBgGIqTos>!hj>pjxFl$tgg5w;{%qaUruhwvaMdc#s0&Lr z<4^dy(q|4c^8i)mc|`JCKgijQ=Sz^+(u9QbZmU~m6D%YW7+hI%3Ps;0y`NNG*@ZPI zSXlP?x>f1KtG+a4s`XHP?{5)DTkMNlCYQbKp>agUbQ9}__D}h-Xvw=dO^<}BU(E=# z^MWkBkJ`y%su@(}>F>X7-P<|pOMkig`t?3KNeCT^Ju3^;FvG+CVL7R^waZ;zwNx~7 zAnecHZKTx($8E0uJ7w0hcwc@{Z)PxbrnX6T+70g$e9e=!iz@a6ov-0C>j zX=kf!?f>64eEn+goK!q@7cnLR%BBj|=MVP9hMAcO)+p^eD$9fsAmr3o=+46NXs8Qh zCkPI5Fqd;1xT8fV#LBgiX;7{@W-E^M(pi7r4!Wp75fJjPah(_1xQ|ahVCWMuu^WHS zM~#`yo%k8Nm{JP=F~7Z+^~asmpp$W}->U*1dDPmm#-O*r{p@$|e_%%OAs+6X`JD{t zPaZaFC&2inVJsv#z&&xFVV~p#zRqVoXVw%@tz}VJ3OX35$yiF%6vbk@wb;(=7k|QA zR#$k|&r6dNH8nl0Uw66~cAMAg=5&QE)d4I7Etq2@b<~$2x=lB@7=K7u7^wj@ydU+6zUuoMG_-3LF z8cmhhJc2*Bv9pc${HFdqsw7MPqpR$$skU)5KaL+azB$qm7!S@H@pHAFTrQxAlHY}g z+vYf8Sw9qA6^H217AF-ee`TQ!``cWA^S1Q`^2X#{Aa2RQZZZPfq-Rx7LwdF&n{d(H zGr#MZJAjH|qsVKDgP*_xQ2N z!E*PnK`?@c*m(yO3ly#34L?DErj6cK`zXSGAsODcaacml#qHOBM*2R-e4yHpH3X05pQAlG z&wHz&K-yWh-cF(<1p7VbLN1--$GQ#M`Az0BDTxVLr!ki_(+H}@qYLNl$YSjyO*+xr69%qCF`R~>o(q0d z5?4oL;+K2U+f`tm!wYy-e0+S>6?$eL%(O9I?Tf$9&i>hdwGAXN(>1OAEgRSr^Sr*i ztbdjyjgH5j#RPeX%KNxG-UO0=JN7mXUhvEwerA#0k3GU6r%k(4|s2t`u0 zcy3LWelIGDNw3bMHC-D>7X1B&ti)#I-qF^S#zV7VFttbY`gG~Gkc)-wGo`-ibZbib z%(3iG7Bc)+N}+5_L6n#*VkTYWFRyz;w&;X*IxjAP87OS(=0_uqPjBCPY7y0i5|;Xv z>A-)0dc+|O{1=Mkcs_2eNGq%|eFwhhe|o*;ZVEsQ876(r@sV`z<*nW#OfFCNb?dyi ze~pY_>O(}2hNQpe=5mMVF)HLdOAZB78;I(CPC7u%M{wH~W(16>oj*eV2R_+7Wv6A< z-&TVhm%5@pQj7vVLM|(1BMzAS#>zWuUgl5I24{~?n${A>H3#jQ98A7;^8D(^Zz74a zu0)PhTlP{uxWRsn@6${qFdK+R@B19645T`wLEAGwi&?zh>KZos*Dt^416$md)Ft!S zo@OO5--k!dx3oNYFue(wtXO&A+Pu&qVVr;Ohp-qw4^|)wbe@Ue(GtSyt&8vc4qD?@ zd!RgB#k!L5X`VMC^W|G)*_Buv)BmpJP=8x%~8w%14?XN13wb2AQ_T{|czr znHZukoGqLuyNQuQ;m3WP235}w8aegKu=ylH zN)msT#<7l6RqXi7(P1zi@vBbvomoA2A!w4-oT>PkMPeJwX3BKEi(HN>)paTKzcqoh z5}QXDrLLXpafA$Q*nT34mUP8X)_b~zO>bEGk^N%x59FR#2f^)EZCZW}#aV4UQ8z*{ z3FV0eS<%t(?lpY_0}fdYS@df#w})(M3Y0y5(O@XRTJ&Vs3G90KY|Yk zF5P@URp^~WQp+?G)TNT&AcOlxWVfzdrGqj|Zf-gLZKuYvN?z?-)>r1GE?&3bFAaef zRM?wX2=aIvFjDozG+|;%Ncg#Ov+!U6byNkQbO<*X{8&J#b}H#*BFmy}H~Fhk<{oU{ zbszqx`c0SpLPPy>E=9m72hRDDOq;eHY;(T87Co)GzxI|N|*)zZ#tOYnq3va^*a&KJvZ(UZJeM4Ov?310RdaY;{mbqT2Z>b1d(E= zpwDX|er!dgcuIkxiXLk1u;!&Z%^L9{3#p;s5##}e*tWayX2<*eznQP?EMXVXe8I;8GrLsRFr;+#d6(6h8L z6TC1?)3g|X!|(WJe>wj6;%XN*AJCU-T=D~{dFlXXfYyS_zlxcUB@)hxsJ&_ls&neP z0BliaROg>>%;hz|M|f#sZJQN?L$L<8I_Xf`nVFdr*8LlmV~Sf-I$)RUK=jHDhRR1v zD&vPdHKU6-d3-9$=7W-e(*5O*6s=q}BYe878Ox&-KEw~QB4b3JZxeyKkuOg<)N4QX zbyzIYdcoz9R=*ck_I8K)}{SSq-Y9up%^`c3qp_5y5>KTy`0>zJUfg_>I`$-kvMAb z)6$wB9UYC4Y;~<~%UG)A_5bukX##90?(a3^W8MnRd6Djkd&5bCP3n&?UMh(a*otW^f0#6&x|@EQC{@&&+=J^O)OEcRlEWUa?G-S%)s=cJJ9 zs>Mwj{+l*7HqMWQJ&CevYN+Xb_n;27+m)eQW^z>{+NZ(k_E z(5z014h}oCmX?gWQc|+!X6mhY0%Jj81L}Jg$BK@^ zR%qhH9&KS?bX44%)oH8 z8&t%=9Cs1|OUH6SZl!`#`GYNO3$SgG0^ImvaO3GP$qSZ_g*zq2(c+*eAhM8-TDJiq zJ8^-XstW#){Jmg(FoMliJ9Owot6)H&7v->7L|5i^z6p92lfKM2V&dFH5Hm^ zD+h;^d@r7Y&_PS)tPWduz;}Pp28l!HU<~T#xhFRrXWZW-Ft->ynAE8}jzU!C%Ze}EjN{1%Jqq!l)jO8z$3E5Ws#Fy5)_AMAW!4pil4uZ5vi zcuh{(n`KwtmUxP3d<2&_QG~3*wbgtyRxdDLl!Offw+&y}Q@kP7vsTd`!^Ih1BGFM( zUWvo&oy^z;UD613C+-NlqggK&%!YnoN?Z7)2j5{(`M<9OB|z2H*k}G2!2RbH#1E8- z8LRcFN0^`e=OyWiVN0#V|;FaG~^rawa3 z?GnFPxANa}q6%zQJCK;Og$ zad`fe`aQgysYh5Rm;9tbecp{YP$nyMRtAysZGB16J@33YNHO z%+o}ZxAE`dpaLNGppy_2#y}fL16GOtha^rMr(z}#RU8I8+|@3-JAsA}I-I=(CeVBP zHa1Ea;tDai$w1h22(Gk~cwizVDeH%|h~O5vlq42ltgMKSy!TntMR z8rT4N2NBd03fgNgf-?9PnIqb^=pnOtbvj>aDOSSY9;dMf3V=z)DCmj-86>3a`<)+m zWD@8w{?32cOaS@pmB`CehYK>2%GV2!*_Ru6r)M6JpZ6tHv78`H1dumQK(8y-vBO^_ zr6)ouL6#8w-k_<^-{WSw{~-p!B(Sqqw#c~%1(Z0X6RS#6$ORHe7fOWEO^($H`K1L> zhhDZn=`*@vA{f}*gN%mY6SfM33TU62y?rZHca3e553g9kEeX+R!}xK&@wV_C?edDM#2@`+rTl}QT9~Wf@F}_dJ{AN(g?ne1E%nX?XK|0Bf%Z4y9a!lj@g#E1(J7g??eD zo9Q@7+5PVw#s_2(L)eDEc7Z&;-=Z#I#V9xey}ZGYux`T!NrqmgXP*2@Rhp!F(t)rGc-;^WSR@{xJg;KKvb$Na6pZ zABLcD4E=c*Ou_$`_`p*nKLYyexP2eP^!{fN@IK^?eJx?W_IX8r|4|(T{3*&llPP*) H^7{V)iR9SIZWUpjXR?aB0LnLI& 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 AJOBUy diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 84340276..44b0ff7e 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -1,10 +1,3 @@ -public final class at/florianschuster/control/BuildersKt { - public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; - public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; - public static final fun createSynchronousController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; - public static synthetic fun createSynchronousController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; -} - public abstract interface class at/florianschuster/control/Controller { public abstract fun dispatch (Ljava/lang/Object;)V public abstract fun getCurrentState ()Ljava/lang/Object; @@ -40,6 +33,11 @@ public final class at/florianschuster/control/ControllerEvent$State : at/florian public final class at/florianschuster/control/ControllerEvent$Stub : at/florianschuster/control/ControllerEvent { } +public final class at/florianschuster/control/ControllerKt { + public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; + public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; +} + public abstract class at/florianschuster/control/ControllerLog { public static final field Companion Lat/florianschuster/control/ControllerLog$Companion; } @@ -78,6 +76,16 @@ public abstract interface class at/florianschuster/control/LoggerScope { public abstract fun getEvent ()Lat/florianschuster/control/ControllerEvent; } +public abstract interface class at/florianschuster/control/ManagedController : at/florianschuster/control/Controller { + public abstract fun cancel ()Ljava/lang/Object; + public abstract fun start ()Z +} + +public final class at/florianschuster/control/ManagedControllerKt { + public static final fun ManagedController (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/ManagedController; + public static synthetic fun ManagedController$default (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/ManagedController; +} + public abstract interface class at/florianschuster/control/MutatorScope { public abstract fun getActions ()Lkotlinx/coroutines/flow/Flow; public abstract fun getCurrentState ()Ljava/lang/Object; diff --git a/control-core/build.gradle.kts b/control-core/build.gradle.kts index 4f4b8de1..698816e5 100644 --- a/control-core/build.gradle.kts +++ b/control-core/build.gradle.kts @@ -43,8 +43,8 @@ pitest { "at.florianschuster.control.ExtensionsKt**", // too many inline collects // inlined invokeSuspend - "at.florianschuster.control.ControllerImplementation\$1\$2", - "at.florianschuster.control.ControllerImplementation\$1", + "at.florianschuster.control.ControllerImplementation\$stateJob\$1", + "at.florianschuster.control.ControllerImplementation\$stateJob\$1\$2", // lateinit var isInitialized "at.florianschuster.control.ControllerImplementation\$stubInitialized\$1" diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 77f0d593..c7ff727a 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -1,41 +1,40 @@ package at.florianschuster.control +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlin.coroutines.ContinuationInterceptor /** - * A [Controller] is an ui-independent class that controls the state of a view. The role of a + * A [Controller] is an UI-independent class that controls the state of a view. The role of a * [Controller] is to separate business-logic from view-logic. A [Controller] has no dependency * to the view, so it can easily be unit tested. * * - *
- *                  [Action] via [dispatch]
- *          +-----------------------------------+
- *          |                                   |
- *     +----+-----+                    +--------|-------+
- *     |          |                    |        v       |
- *     |   View   |                    |   Controller   |
- *     |    ^     |                    |                |
- *     +----|-----+                    +--------+-------+
- *          |                                   |
- *          +-----------------------------------+
- *                  [State] via [state]
- * 
+ * ``` + * dispatch(Action) + * ┌───────────────────────────────────┐ + * │ │ + * │ │ + * ┏━━━━━━━━━━┓ ┏━━━━━━━━▼━━━━━━━┓ + * ┃ ┃ ┃ ┃ + * ┃ View ┃ ┃ Controller ┃ + * ┃ ┃ ┃ ┃ + * ┗━━━━▲━━━━━┛ ┗━━━━━━━━━━━━━━━━┛ + * │ │ + * │ │ + * └───────────────────────────────────┘ + * state + * ``` * * The [Controller] creates an uni-directional stream of data as shown in the diagram above, by * handling incoming [Action]'s via [Controller.dispatch] and creating new [State]'s that * can be collected via [Controller.state]. - * - * Basic Principle: 1 [Action] -> [0..n] [Mutation] -> each 1 new [State] - * - * For implementation details look into: - * 1. [Mutator]: [Action] -> [Mutation] - * 2. [Reducer]: [Mutation] -> [State] - * 3. [Transformer] - * 4. [ControllerImplementation] - * - * To create a [Controller] use [CoroutineScope.createController]. */ interface Controller { @@ -55,6 +54,108 @@ interface Controller { val state: Flow } +/** + * Creates a [Controller] bound to the [CoroutineScope] via [ControllerImplementation]. + * If the [CoroutineScope] is cancelled, the internal state machine of the [Controller] completes. + * + * The principle of the created state machine is: + * + * 1 [Action] -> [0..n] [Mutation] -> each 1 new [State] + * + * ``` + * Action + * ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓ + * ┃ │ ┃ + * ┃ │ ┃ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ side effect ┏━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ┃ mutator ◀───────────────────────────────▶ service/usecase ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ ┗━━━━━━━━━━━━━━━━━━━━┛ + * ┃ │ ┃ + * ┃ │ 0..n mutations ┃ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ + * ┃ ┌───────────▶┃ reducer ┃ ┃ + * ┃ │ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └────────────┃ state ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛ + * ▼ + * state + * ``` + * + * For implementation details look into: + * 1. [Mutator]: This corresponds to [Action] -> [Mutation] + * 2. [Reducer]: This corresponds to [Mutation] -> [State] + * 3. [Transformer] + * 4. [ControllerImplementation] + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun CoroutineScope.createController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Mutator]. + */ + mutator: Mutator = { _ -> emptyFlow() }, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + /** + * Used for [ControllerLog] and as [CoroutineName] for the internal state machine. + */ + tag: String = defaultTag(), + /** + * Log configuration for [ControllerEvent]s. See [ControllerLog]. + */ + controllerLog: ControllerLog = ControllerLog.default, + + /** + * When the internal state machine [Flow] should be started. + * + * Default is [CoroutineStart.LAZY] -> [Flow] is started once [Controller.state], + * [Controller.currentState] or [Controller.dispatch] are accessed. + * + * Look into [CoroutineStart] to see how the options would affect the [Flow] start. + */ + coroutineStart: CoroutineStart = CoroutineStart.LAZY, + + /** + * Override to launch the internal state machine [Flow] in a different [CoroutineDispatcher] + * than the one used in the [CoroutineScope.coroutineContext]. + * + * [Mutator] and [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher +): Controller = ControllerImplementation( + scope = this, dispatcher = dispatcher, coroutineStart = coroutineStart, + + initialState = initialState, mutator = mutator, reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = mutationsTransformer, + statesTransformer = statesTransformer, + + tag = tag, controllerLog = controllerLog +) + /** * A [Mutator] takes an action and transforms it into a [Flow] of [0..n] mutations. * @@ -89,32 +190,17 @@ typealias Mutator = MutatorScope.( ) -> Flow /** - * The [MutatorScope] provides access to the [currentState] and the [actions] [Flow] of the - * [ControllerImplementation] in a [Mutator]. + * The [MutatorScope] provides access to the [currentState] and the [actions] [Flow] in a [Mutator]. */ interface MutatorScope { /** - * A generated property in [ControllerImplementation.MutatorScopeImpl], thus always - * providing the current [State] when accessed. + * A generated property, thus always providing the current [State] when accessed. */ val currentState: State /** - * Accessed after [Action] [Transformer] is applied. - * - * Use if a [Flow] inside the [Mutator] needs to be cancelled or transformed due to an - * incoming [Action]: - * - * ``` - * mutator = { action -> - * when(action) { - * is Action.Start -> flow { - * emit(someLongRunningSuspendingFunctionThatGeneratesAValue()) - * }.takeUntil(actions.filterIsInstance()) - * } - * } - * ``` + * The [Flow] of incoming actions, accessed after [Action] [Transformer] is applied. */ val actions: Flow } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt new file mode 100644 index 00000000..2dd7480b --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt @@ -0,0 +1,99 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.emptyFlow + +/** + * A [ManagedController] is a [Controller] that additionally provides the ability to [start] + * the internal state machine, but also requires to [cancel] the state machine to prevent leaks. + * + * Before using this, make sure to look into the [Controller] documentation. + */ +interface ManagedController : Controller { + + /** + * Starts the internal state machine of this [ManagedController]. + * + * Returns true if [ManagedController] was started, false if it was already started. + */ + fun start(): Boolean + + /** + * Cancels the internal state machine of this [ManagedController]. + * Once cancelled, the [ManagedController] cannot be re-started. + * + * Returns the last [State] produced by the internal state machine. + */ + fun cancel(): State +} + +/** + * Creates a [ManagedController] via [ControllerImplementation]. + * + * The internal state machine is started once [Controller.state], [Controller.currentState] + * or [Controller.dispatch] are accessed or when calling [ManagedController.start]. + * + * A [ManagedController] also HAS to be cancelled manually via [ManagedController.cancel] + * to avoid leaks. + */ +@Suppress("FunctionName") +@ExperimentalCoroutinesApi +@FlowPreview +fun ManagedController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Mutator]. + */ + mutator: Mutator = { _ -> emptyFlow() }, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + /** + * Used for [ControllerLog] and as [CoroutineName] for the internal state machine. + */ + tag: String = defaultTag(), + /** + * Log configuration for [ControllerEvent]s. See [ControllerLog]. + */ + controllerLog: ControllerLog = ControllerLog.default, + + /** + * The [CoroutineDispatcher] that the internal state machine is launched in. + * + * [Mutator] and [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = Dispatchers.Default +): ManagedController = ControllerImplementation( + scope = CoroutineScope(dispatcher), + dispatcher = dispatcher, + coroutineStart = CoroutineStart.LAZY, + + initialState = initialState, + mutator = mutator, + reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = mutationsTransformer, + statesTransformer = statesTransformer, + + tag = tag, + controllerLog = controllerLog +) \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/builders.kt b/control-core/src/main/kotlin/at/florianschuster/control/builders.kt deleted file mode 100644 index 28b49074..00000000 --- a/control-core/src/main/kotlin/at/florianschuster/control/builders.kt +++ /dev/null @@ -1,116 +0,0 @@ -package at.florianschuster.control - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf -import kotlin.coroutines.ContinuationInterceptor - -/** - * Creates a [Controller] bound to the [CoroutineScope] via [ControllerImplementation]. - */ -@ExperimentalCoroutinesApi -@FlowPreview -fun CoroutineScope.createController( - - /** - * The [ControllerImplementation] is started with this [State]. - */ - initialState: State, - - /** - * See [Mutator]. - */ - mutator: Mutator = { _ -> emptyFlow() }, - - /** - * See [Reducer]. - */ - reducer: Reducer = { _, previousState -> previousState }, - - /** - * See [Transformer]. - */ - actionsTransformer: Transformer = { it }, - mutationsTransformer: Transformer = { it }, - statesTransformer: Transformer = { it }, - - /** - * Set as [CoroutineName] for the [ControllerImplementation.state] context. - * Also used for logging if enabled via [controllerLog]. - */ - tag: String = defaultTag(), - - /** - * Log configuration for the [ControllerImplementation]. See [ControllerLog]. - */ - controllerLog: ControllerLog = ControllerLog.default, - - /** - * When the [ControllerImplementation.state] [Flow] should be started. The [Flow] is launched - * in [ControllerImplementation] init. - * - * Default is [CoroutineStart.LAZY] -> [Flow] is started once [ControllerImplementation.state], - * [ControllerImplementation.currentState] or [ControllerImplementation.dispatch] are accessed - * or if the [Job] in the [CoroutineScope] is started. - * - * Look into [CoroutineStart] to see how the options would affect the [Flow] start. - */ - coroutineStart: CoroutineStart = CoroutineStart.LAZY, - - /** - * Override to launch [ControllerImplementation.state] [Flow] in different [CoroutineDispatcher] - * than the one used in the [CoroutineScope.coroutineContext]. - */ - dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher -): Controller = ControllerImplementation( - scope = this, dispatcher = dispatcher, coroutineStart = coroutineStart, - - initialState = initialState, mutator = mutator, reducer = reducer, - actionsTransformer = actionsTransformer, - mutationsTransformer = mutationsTransformer, - statesTransformer = statesTransformer, - - tag = tag, controllerLog = controllerLog -) - -/** - * Creates a [Controller] with [CoroutineScope.createController] where [Action] == [Mutation]. - * - * The [Controller] can only deal with synchronous state reductions without - * any asynchronous side-effects. - */ -@ExperimentalCoroutinesApi -@FlowPreview -fun CoroutineScope.createSynchronousController( - initialState: State, - reducer: Reducer = { _, previousState -> previousState }, - - actionsTransformer: Transformer = { it }, - statesTransformer: Transformer = { it }, - - tag: String = defaultTag(), - controllerLog: ControllerLog = ControllerLog.default, - - coroutineStart: CoroutineStart = CoroutineStart.LAZY, - dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher -): Controller = createController( - initialState = initialState, - mutator = { action -> flowOf(action) }, - reducer = reducer, - actionsTransformer = actionsTransformer, - mutationsTransformer = { it }, - statesTransformer = statesTransformer, - - tag = tag, - controllerLog = controllerLog, - - coroutineStart = coroutineStart, - dispatcher = dispatcher -) \ No newline at end of file diff --git a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt index 5ac79ee8..73ade6f1 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt @@ -1,8 +1,5 @@ package at.florianschuster.control -/** - * Creates a default tag that a [ControllerImplementation] uses for debugging. - */ @Suppress("NOTHING_TO_INLINE") internal inline fun defaultTag(): String { val stackTrace = Throwable().stackTrace diff --git a/control-core/src/main/kotlin/at/florianschuster/control/event.kt b/control-core/src/main/kotlin/at/florianschuster/control/event.kt index d170ccce..014b203b 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/event.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/event.kt @@ -1,7 +1,7 @@ package at.florianschuster.control /** - * All events that are logged in a [ControllerImplementation]. + * All events that are logged in the internal state machine within a [Controller]. */ sealed class ControllerEvent( private val tag: String, @@ -9,62 +9,60 @@ sealed class ControllerEvent( ) { /** - * When the [ControllerImplementation] is created. + * When the implementation is created. */ class Created internal constructor( tag: String ) : ControllerEvent(tag, "created") /** - * When the [ControllerImplementation.state] stream is started. + * When the state machine is started. */ class Started internal constructor( tag: String ) : ControllerEvent(tag, "state stream started") /** - * When the [ControllerImplementation] receives an [Action]. + * When the state machine receives an [Action]. */ class Action internal constructor( tag: String, action: String ) : ControllerEvent(tag, "action: $action") /** - * When the [ControllerImplementation] mutator produces a new [Mutation]. + * When the [Mutator] produces a new [Mutation]. */ class Mutation internal constructor( tag: String, mutation: String ) : ControllerEvent(tag, "mutation: $mutation") /** - * When the [ControllerImplementation] reduces a new [State]. + * When the [Reducer] reduces a new [State]. */ class State internal constructor( tag: String, state: String ) : ControllerEvent(tag, "state: $state") /** - * When an error happens in [ControllerImplementation] stream. + * When an error happens during the execution of the internal state machine. */ class Error internal constructor( tag: String, cause: ControllerError ) : ControllerEvent(tag, "error: $cause") /** - * When the [ControllerImplementation.stub] is set to enabled. + * When the [ControllerStub] is enabled. */ class Stub internal constructor( tag: String ) : ControllerEvent(tag, "is now stubbed") /** - * When the [ControllerImplementation] stream is completed. + * When the internal state machine completes. */ class Completed internal constructor( tag: String ) : ControllerEvent(tag, "completed") - override fun toString(): String { - return "||| ||| $tag -> $message |||" - } + override fun toString(): String = "||| ||| $tag -> $message |||" } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 8f4072aa..ce9cf19f 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -41,12 +41,52 @@ internal class ControllerImplementation( internal val tag: String, internal val controllerLog: ControllerLog -) : Controller { +) : ManagedController { - internal val stateJob: Job + // region state machine private val actionChannel = BroadcastChannel(BUFFERED) - private val stateFlow = MutableStateFlow(initialState) + private val mutableStateFlow = MutableStateFlow(initialState) + + internal val stateJob: Job = scope.launch( + context = dispatcher + CoroutineName(tag), + start = coroutineStart + ) { + val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) + + val mutatorScope = mutatorScope({ currentState }, actionFlow) + val mutationFlow: Flow = actionFlow.flatMapMerge { action -> + controllerLog.log(ControllerEvent.Action(tag, action.toString())) + mutatorScope.mutator(action).catch { cause -> + val error = ControllerError.Mutate(tag, "$action", cause) + controllerLog.log(ControllerEvent.Error(tag, error)) + throw error + } + } + + val stateFlow: Flow = mutationsTransformer(mutationFlow) + .onEach { controllerLog.log(ControllerEvent.Mutation(tag, it.toString())) } + .scan(initialState) { previousState, mutation -> + runCatching { reducer(mutation, previousState) }.getOrElse { cause -> + val error = ControllerError.Reduce( + tag, "$previousState", "$mutation", cause + ) + controllerLog.log(ControllerEvent.Error(tag, error)) + throw error + } + } + + statesTransformer(stateFlow) + .onStart { controllerLog.log(ControllerEvent.Started(tag)) } + .onEach { state -> + controllerLog.log(ControllerEvent.State(tag, state.toString())) + mutableStateFlow.value = state + } + .onCompletion { controllerLog.log(ControllerEvent.Completed(tag)) } + .collect() + } + + // endregion // region stub @@ -59,79 +99,52 @@ internal class ControllerImplementation( override val state: Flow get() = if (stubInitialized) stub.stateFlow else { - if (!stateJob.isActive) startStateJob() - stateFlow + start() + mutableStateFlow } override val currentState: State get() = if (stubInitialized) stub.stateFlow.value else { - if (!stateJob.isActive) startStateJob() - stateFlow.value + start() + mutableStateFlow.value } override fun dispatch(action: Action) { if (stubInitialized) { stub.mutableDispatchedActions.add(action) } else { - if (!stateJob.isActive) startStateJob() + start() actionChannel.offer(action) } } - init { - stateJob = scope.launch( - context = dispatcher + CoroutineName(tag), - start = coroutineStart - ) { - val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) - - val mutatorScope = mutatorScope({ currentState }, actionFlow) - val mutationFlow: Flow = actionFlow.flatMapMerge { action -> - controllerLog.log(ControllerEvent.Action(tag, action.toString())) - mutatorScope.mutator(action).catch { cause -> - val error = ControllerError.Mutate(tag, "$action", cause) - controllerLog.log(ControllerEvent.Error(tag, error)) - throw error - } - } - - val stateFlow: Flow = mutationsTransformer(mutationFlow) - .onEach { controllerLog.log(ControllerEvent.Mutation(tag, it.toString())) } - .scan(initialState) { previousState, mutation -> - runCatching { reducer(mutation, previousState) }.getOrElse { cause -> - val error = ControllerError.Reduce( - tag, "$previousState", "$mutation", cause - ) - controllerLog.log(ControllerEvent.Error(tag, error)) - throw error - } - } + // endregion - statesTransformer(stateFlow) - .onStart { controllerLog.log(ControllerEvent.Started(tag)) } - .onEach { state -> - controllerLog.log(ControllerEvent.State(tag, state.toString())) - this@ControllerImplementation.stateFlow.value = state - } - .onCompletion { controllerLog.log(ControllerEvent.Completed(tag)) } - .collect() - } + // region managed controller - controllerLog.log(ControllerEvent.Created(tag)) + override fun start(): Boolean { + return if (stateJob.isActive) false else stateJob.start() } - internal fun startStateJob(): Boolean = kotlinx.atomicfu.locks.synchronized(this) { - return if (stateJob.isActive) false // double checked locking - else stateJob.start() + override fun cancel(): State { + stateJob.cancel() + return mutableStateFlow.value } // endregion -} -internal fun mutatorScope( - stateAccessor: () -> State, - actionFlow: Flow -): MutatorScope = object : MutatorScope { - override val currentState: State get() = stateAccessor() - override val actions: Flow = actionFlow -} \ No newline at end of file + init { + controllerLog.log(ControllerEvent.Created(tag)) + } + + companion object { + fun mutatorScope( + stateAccessor: () -> State, + actionFlow: Flow + ): MutatorScope = object : + MutatorScope { + override val currentState: State get() = stateAccessor() + override val actions: Flow = actionFlow + } + } +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/log.kt b/control-core/src/main/kotlin/at/florianschuster/control/log.kt index 6fc8cdfd..42d51246 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/log.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/log.kt @@ -13,7 +13,7 @@ interface LoggerScope { } /** - * Configuration to define how [ControllerEvent]'s are logged by a [ControllerImplementation]. + * Configuration to define how [ControllerEvent]'s are logged by a [Controller]. */ sealed class ControllerLog { @@ -39,17 +39,17 @@ sealed class ControllerLog { companion object { /** - * The default [ControllerLog] that is used by a [ControllerImplementation]. - * Set this to change the logger for all [ControllerImplementation]'s that do not specify one. + * The default [ControllerLog] that is used by all [Controller] builders. + * Set this to change the default logger for all builders that do not specify one. */ var default: ControllerLog = None } } -internal fun ControllerLog.log(event: ControllerEvent) { - logger?.invoke(loggerScope(event), event.toString()) -} - internal fun loggerScope(event: ControllerEvent) = object : LoggerScope { override val event: ControllerEvent = event +} + +internal fun ControllerLog.log(event: ControllerEvent) { + logger?.invoke(loggerScope(event), event.toString()) } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt deleted file mode 100644 index 63b89772..00000000 --- a/control-core/src/test/kotlin/at/florianschuster/control/BuildersTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package at.florianschuster.control - -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.flow.singleOrNull -import kotlinx.coroutines.test.runBlockingTest -import org.junit.Test -import kotlin.coroutines.ContinuationInterceptor -import kotlin.test.assertEquals - -internal class BuildersTest { - - @Test - fun `default parameters of controller builder`() = runBlockingTest { - val expectedInitialState = 42 - val expectedTag = defaultTag() - val sut = createController( - initialState = expectedInitialState, - tag = expectedTag - ) as ControllerImplementation - - assertEquals(this, sut.scope) - assertEquals(expectedInitialState, sut.initialState) - - assertEquals(null, sut.mutator.invoke(mutatorScope({ 1 }, flowOf(2)), 3).singleOrNull()) - assertEquals(1, sut.reducer.invoke(0, 1)) - - assertEquals(1, sut.actionsTransformer.invoke(flowOf(1)).single()) - assertEquals(2, sut.mutationsTransformer.invoke(flowOf(2)).single()) - assertEquals(3, sut.statesTransformer.invoke(flowOf(3)).single()) - - assertEquals(expectedTag, sut.tag) - assertEquals(ControllerLog.default, sut.controllerLog) - - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) - assertEquals( - coroutineContext[ContinuationInterceptor] as CoroutineDispatcher, - sut.dispatcher - ) - } - - @Test - fun `default parameters of synchronous controller builder`() = runBlockingTest { - val expectedInitialState = 42 - val expectedTag = defaultTag() - val sut = createSynchronousController( - initialState = expectedInitialState, - tag = expectedTag - ) as ControllerImplementation - - assertEquals(this, sut.scope) - assertEquals(expectedInitialState, sut.initialState) - - assertEquals(3, sut.mutator.invoke(mutatorScope({ 1 }, flowOf(2)), 3).single()) - assertEquals(1, sut.reducer.invoke(0, 1)) - - assertEquals(1, sut.actionsTransformer.invoke(flowOf(1)).single()) - assertEquals(2, sut.mutationsTransformer.invoke(flowOf(2)).single()) - assertEquals(3, sut.statesTransformer.invoke(flowOf(3)).single()) - - assertEquals(expectedTag, sut.tag) - assertEquals(ControllerLog.default, sut.controllerLog) - - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) - assertEquals( - coroutineContext[ContinuationInterceptor] as CoroutineDispatcher, - sut.dispatcher - ) - } -} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt deleted file mode 100644 index 0a163f82..00000000 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerStartTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package at.florianschuster.control - -import at.florianschuster.test.coroutines.TestCoroutineScopeRule -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import org.junit.Rule -import org.junit.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -internal class ControllerStartTest { - - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() - - @Test - fun `default start mode`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.DEFAULT) - - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `lazy start mode`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.currentState - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `manually start job`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.stateJob.start() - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `manually start via implementation_startStateJob`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.startStateJob() - assertTrue(sut.stateJob.isActive) - } - - @Test - fun `verify double checked locking`() { - val sut = testCoroutineScope.counterController(coroutineStart = CoroutineStart.LAZY) - - assertFalse(sut.stateJob.isActive) - sut.stateJob.start() - assertFalse(sut.startStateJob()) - } - - private fun CoroutineScope.counterController( - coroutineStart: CoroutineStart - ) = createSynchronousController( - initialState = 0, - coroutineStart = coroutineStart - ) as ControllerImplementation -} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt new file mode 100644 index 00000000..54f7080c --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt @@ -0,0 +1,39 @@ +package at.florianschuster.control + +import io.mockk.mockk +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class ControllerTest { + + @Test + fun `default parameters of controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val expectedTag = defaultTag() + val sut = createController( + initialState = expectedInitialState, + tag = expectedTag + ) as ControllerImplementation + + assertEquals(this, sut.scope) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(null, sut.mutator(mockk(), 3).singleOrNull()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(expectedTag, sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(CoroutineStart.LAZY, sut.coroutineStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } +} diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt index f381fb3f..bb88110a 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ExtensionsTest.kt @@ -28,8 +28,7 @@ internal class ExtensionsTest { @Test(expected = IllegalStateException::class) fun `bind lambda throws error`() = runBlockingTest { - val lambda = spyk<(Int) -> Unit>() - flow { error("test") }.bind(to = lambda).launchIn(this) + flow { error("test") }.bind { }.launchIn(this) } @Test diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt similarity index 85% rename from control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt index 6a1186f4..cafcff2c 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt @@ -1,12 +1,14 @@ package at.florianschuster.control import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test import kotlin.test.assertTrue -internal class EventTest { +internal class ImplementationEventTest { @Test fun `event message contains library name and tag`() { @@ -24,7 +26,7 @@ internal class EventTest { assertTrue(events.last() is ControllerEvent.Created) - sut.startStateJob() + sut.start() events.takeLast(2).let { lastEvents -> assertTrue(lastEvents[0] is ControllerEvent.Started) assertTrue(lastEvents[1] is ControllerEvent.State) @@ -40,7 +42,7 @@ internal class EventTest { sut.stub() assertTrue(events.last() is ControllerEvent.Stub) - sut.stateJob.cancel() + sut.cancel() assertTrue(events.last() is ControllerEvent.Completed) } @@ -70,7 +72,10 @@ internal class EventTest { private fun CoroutineScope.eventsController( events: MutableList - ) = createController( + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, initialState = 0, mutator = { action -> flow { @@ -82,8 +87,12 @@ internal class EventTest { check(mutation != reducerErrorValue) previousState }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationEventTest.EventsController", controllerLog = ControllerLog.Custom { events.add(event) } - ) as ControllerImplementation + ) companion object { private const val mutatorErrorValue = 42 diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt new file mode 100644 index 00000000..2d3e715e --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt @@ -0,0 +1,76 @@ +package at.florianschuster.control + +import at.florianschuster.test.coroutines.TestCoroutineScopeRule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.merge +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class ImplementationStartStopTest { + + @get:Rule + val testCoroutineScope = TestCoroutineScopeRule() + + @Test + fun `default start mode`() { + val sut = testCoroutineScope.createSimpleCounterController( + coroutineStart = CoroutineStart.DEFAULT + ) + + assertTrue(sut.stateJob.isActive) + } + + @Test + fun `lazy start mode`() { + val sut = testCoroutineScope.createSimpleCounterController( + coroutineStart = CoroutineStart.LAZY + ) + + assertFalse(sut.stateJob.isActive) + sut.currentState + assertTrue(sut.stateJob.isActive) + } + + @Test + fun `manually start implementation`() { + val sut = testCoroutineScope.createSimpleCounterController( + coroutineStart = CoroutineStart.LAZY + ) + + assertFalse(sut.stateJob.isActive) + sut.start() + assertTrue(sut.stateJob.isActive) + } + + @Test + fun `manually cancel implementation`() { + val sut = testCoroutineScope.createSimpleCounterController( + coroutineStart = CoroutineStart.LAZY + ) + + sut.start() + assertTrue(sut.stateJob.isActive) + sut.cancel() + assertFalse(sut.stateJob.isActive) + } + + private fun CoroutineScope.createSimpleCounterController( + coroutineStart: CoroutineStart = CoroutineStart.LAZY + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = coroutineStart, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { _, previousState -> previousState }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationStartStopTest.SimpleCounterController", + controllerLog = ControllerLog.None + ) +} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index f6f03666..dadc648a 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -5,9 +5,12 @@ import at.florianschuster.test.flow.emission import at.florianschuster.test.flow.emissionCount import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect +import at.florianschuster.test.flow.lastEmission import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow @@ -27,7 +30,7 @@ internal class ImplementationTest { @Test fun `initial state only emitted once`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() val testFlow = sut.state.testIn(testCoroutineScope) testFlow expect emissionCount(1) @@ -36,14 +39,14 @@ internal class ImplementationTest { @Test fun `state is created when accessing current state`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() assertEquals(listOf("initialState", "transformedState"), sut.currentState) } @Test fun `state is created when accessing action`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() sut.dispatch(listOf("action")) @@ -62,7 +65,7 @@ internal class ImplementationTest { @Test fun `each method is invoked`() { - val sut = testCoroutineScope.operationController() + val sut = testCoroutineScope.createOperationController() val testFlow = sut.state.testIn(testCoroutineScope) sut.dispatch(listOf("action")) @@ -81,27 +84,9 @@ internal class ImplementationTest { ) } - @Test - fun `synchronous controller`() { - val counterSut = testCoroutineScope.createSynchronousController( - tag = "counter", - initialState = 0, - reducer = { action, previousState -> previousState + action } - ) - - counterSut.dispatch(1) - counterSut.dispatch(2) - counterSut.dispatch(3) - - assertEquals(6, counterSut.currentState) - } - @Test fun `only distinct states are emitted`() { - val sut = testCoroutineScope.createSynchronousController( - 0, - reducer = { _, previousState -> previousState } - ) + val sut = testCoroutineScope.createAlwaysSameStateController() val testFlow = sut.state.testIn(testCoroutineScope) sut.dispatch(Unit) sut.dispatch(Unit) @@ -111,7 +96,7 @@ internal class ImplementationTest { @Test fun `collector receives latest and following states`() { - val sut = testCoroutineScope.counterController() // 0 + val sut = testCoroutineScope.createCounterController() // 0 sut.dispatch(Unit) // 1 sut.dispatch(Unit) // 2 @@ -126,7 +111,7 @@ internal class ImplementationTest { @Test fun `state flow throws error from mutator`() { val scope = TestCoroutineScope() - val sut = scope.counterController(mutatorErrorIndex = 2) + val sut = scope.createCounterController(mutatorErrorIndex = 2) sut.dispatch(Unit) sut.dispatch(Unit) sut.dispatch(Unit) @@ -137,7 +122,7 @@ internal class ImplementationTest { @Test fun `state flow throws error from reducer`() { val scope = TestCoroutineScope() - val sut = scope.counterController(reducerErrorIndex = 2) + val sut = scope.createCounterController(reducerErrorIndex = 2) sut.dispatch(Unit) sut.dispatch(Unit) @@ -148,7 +133,7 @@ internal class ImplementationTest { @Test fun `cancel via takeUntil`() { - val sut = testCoroutineScope.stopWatchController() + val sut = testCoroutineScope.createStopWatchController() sut.dispatch(StopWatchAction.Start) testCoroutineScope.advanceTimeBy(2000) @@ -185,11 +170,7 @@ internal class ImplementationTest { emit(42) } - val sut = testCoroutineScope.createSynchronousController( - initialState = 0, - actionsTransformer = { merge(it, globalState) }, - reducer = { action, previousState -> previousState + action } - ) + val sut = testCoroutineScope.createGlobalStateMergeController(globalState) val states = sut.state.testIn(testCoroutineScope) @@ -204,14 +185,48 @@ internal class ImplementationTest { fun `MutatorScope is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val sut = mutatorScope(stateAccessor, actions) + val sut = ControllerImplementation.mutatorScope(stateAccessor, actions) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) } - private fun CoroutineScope.operationController() = - createController, List, List>( + @Test + fun `cancelling the implementation will return the last state`() { + val sut = testCoroutineScope.createGlobalStateMergeController(emptyFlow()) + + val states = sut.state.testIn(testCoroutineScope) + + sut.dispatch(0) + sut.dispatch(1) + + assertEquals(1, sut.cancel()) + + sut.dispatch(2) + + states expect lastEmission(1) + } + + private fun CoroutineScope.createAlwaysSameStateController() = + ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { _, previousState -> previousState }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.AlwaysSameStateController", + controllerLog = ControllerLog.None + ) + + private fun CoroutineScope.createOperationController() = + ControllerImplementation, List, List>( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, // 1. ["initialState"] initialState = listOf("initialState"), @@ -235,13 +250,19 @@ internal class ImplementationTest { reducer = { mutation, previousState -> previousState + mutation }, // 6. ["initialState", "action", "transformedAction", "mutation", "transformedMutation"] + ["transformedState"] - statesTransformer = { states -> states.map { it + "transformedState" } } + statesTransformer = { states -> states.map { it + "transformedState" } }, + + tag = "ImplementationTest.OperationController", + controllerLog = ControllerLog.None ) - private fun CoroutineScope.counterController( + private fun CoroutineScope.createCounterController( mutatorErrorIndex: Int? = null, reducerErrorIndex: Int? = null - ) = createController( + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, initialState = 0, mutator = { action -> flow { @@ -252,7 +273,12 @@ internal class ImplementationTest { reducer = { _, previousState -> check(previousState != reducerErrorIndex) previousState + 1 - } + }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.CounterController", + controllerLog = ControllerLog.None ) private sealed class StopWatchAction { @@ -260,21 +286,46 @@ internal class ImplementationTest { object Stop : StopWatchAction() } - private fun CoroutineScope.stopWatchController() = createController( - initialState = 0, - mutator = { action -> - when (action) { - is StopWatchAction.Start -> { - flow { - while (true) { - delay(1000) - emit(1) - } - }.takeUntil(actions.filterIsInstance()) + private fun CoroutineScope.createStopWatchController() = + ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, + initialState = 0, + mutator = { action -> + when (action) { + is StopWatchAction.Start -> { + flow { + while (true) { + delay(1000) + emit(1) + } + }.takeUntil(actions.filterIsInstance()) + } + is StopWatchAction.Stop -> emptyFlow() } - is StopWatchAction.Stop -> emptyFlow() - } - }, - reducer = { mutation, previousState -> previousState + mutation } + }, + reducer = { mutation, previousState -> previousState + mutation }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.StopWatchController", + controllerLog = ControllerLog.None + ) + + private fun CoroutineScope.createGlobalStateMergeController( + globalState: Flow + ) = ControllerImplementation( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, + initialState = 0, + mutator = { flowOf(it) }, + reducer = { action, previousState -> previousState + action }, + actionsTransformer = { merge(it, globalState) }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "ImplementationTest.GlobalStateMergeController", + controllerLog = ControllerLog.None ) } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt similarity index 98% rename from control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt index 83184af3..0a41e9b7 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerLogTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt @@ -13,7 +13,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -internal class ControllerLogTest { +internal class LogTest { @Test fun `setting default logger`() { diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt new file mode 100644 index 00000000..7eb20b88 --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt @@ -0,0 +1,42 @@ +package at.florianschuster.control + +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Test +import kotlin.coroutines.ContinuationInterceptor +import kotlin.test.assertEquals + +internal class ManagedControllerTest { + + @Test + fun `default parameters of managed controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val expectedTag = defaultTag() + val sut = ManagedController( + expectedInitialState, + tag = expectedTag, + dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher + ) as ControllerImplementation + + assertEquals(scopeDispatcher, sut.scope.scopeDispatcher) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(null, sut.mutator(mockk(), 3).singleOrNull()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(expectedTag, sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(CoroutineStart.LAZY, sut.coroutineStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } +} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 51c18377..650a9e50 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -5,7 +5,9 @@ import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf import org.junit.Rule import org.junit.Test import java.lang.IllegalArgumentException @@ -33,7 +35,6 @@ internal class StubTest { @Test fun `stub is initialized only after accessing stub()`() { val sut = testCoroutineScope.createStringController() - as ControllerImplementation, List, List> assertFalse(sut.stubInitialized) assertFailsWith { sut.stub.dispatchedActions } @@ -101,10 +102,18 @@ internal class StubTest { } private fun CoroutineScope.createStringController() = - createSynchronousController, List>( - tag = "string_controller", + ControllerImplementation, List, List>( + scope = this, + dispatcher = scopeDispatcher, + coroutineStart = CoroutineStart.LAZY, initialState = initialState, - reducer = { previousState, mutation -> previousState + mutation } + mutator = { flowOf(it) }, + reducer = { previousState, mutation -> previousState + mutation }, + actionsTransformer = { it }, + mutationsTransformer = { it }, + statesTransformer = { it }, + tag = "StubTest.StringController", + controllerLog = ControllerLog.None ) companion object { diff --git a/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt b/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt new file mode 100644 index 00000000..b91c981b --- /dev/null +++ b/control-core/src/test/kotlin/at/florianschuster/control/TestHelper.kt @@ -0,0 +1,8 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.ContinuationInterceptor + +internal val CoroutineScope.scopeDispatcher: CoroutineDispatcher + get() = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher diff --git a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt index 6202b20e..01d3a724 100644 --- a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt +++ b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt @@ -29,9 +29,10 @@ internal class CounterScreenTest { @Before fun setup() { + TODO("use correct controller") controller = TestCoroutineScope().createCounterController().apply { stub() } composeTestRule.setContent { - val state by controller.collectState() + val state by controller.collectAsState() CounterScreen(counterState = state, action = controller::dispatch) } } diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt index 35958be7..0960b1a4 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt @@ -4,37 +4,81 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.Composable import androidx.compose.getValue +import androidx.compose.mutableStateOf +import androidx.compose.setValue import androidx.ui.core.ContextAmbient import androidx.ui.core.setContent import androidx.ui.foundation.Text import androidx.ui.foundation.isSystemInDarkTheme import androidx.ui.graphics.Color +import androidx.ui.layout.Column +import androidx.ui.material.Button import androidx.ui.material.ColorPalette import androidx.ui.material.MaterialTheme import androidx.ui.material.Scaffold import androidx.ui.material.TopAppBar import androidx.ui.material.darkColorPalette import androidx.ui.material.lightColorPalette -import at.florianschuster.control.kotlincounter.createCounterController +import at.florianschuster.control.ControllerLog +import at.florianschuster.control.kotlincounter.CounterAction +import at.florianschuster.control.kotlincounter.CounterController +import at.florianschuster.control.kotlincounter.CounterMutation +import at.florianschuster.control.kotlincounter.CounterState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow internal class AppActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { AppScreen() } + // setContent { AppScreen() } + setContent { + var lel by mutableStateOf(true) + Column { + Button(onClick = { lel = !lel }) { Text(text = "Click me") } + if (lel) AppScreen() + } + + } } } @Composable private fun AppScreen() { - val controller = ComposeCoroutineScope().createCounterController() + val controller: CounterController = ComposableController( + initialState = CounterState(value = 0, loading = false), + mutator = { action -> + when (action) { + CounterAction.Increment -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.IncreaseValue) + emit(CounterMutation.SetLoading(false)) + } + CounterAction.Decrement -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.DecreaseValue) + emit(CounterMutation.SetLoading(false)) + } + } + }, + reducer = { mutation, previousState -> + when (mutation) { + is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) + is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) + is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading) + } + }, + controllerLog = ControllerLog.Println + ) MaterialTheme(colors = AppColors.currentColorPalette) { Scaffold( topAppBar = { TopAppBar(title = { Text(text = ContextAmbient.current.getString(R.string.app_name)) }) }, bodyContent = { - val controllerState by controller.collectState() + val controllerState by controller.collectAsState() CounterScreen(counterState = controllerState, action = controller::dispatch) } ) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt index b82c29e4..8fdfa6b3 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt @@ -1,35 +1,78 @@ package at.florianschuster.control.androidcountercomposeexample import androidx.compose.Composable +import androidx.compose.CompositionLifecycleObserver import androidx.compose.State import androidx.compose.collectAsState -import androidx.compose.onDispose import androidx.compose.remember import at.florianschuster.control.Controller -import kotlinx.coroutines.CoroutineScope +import at.florianschuster.control.ControllerLog +import at.florianschuster.control.ManagedController +import at.florianschuster.control.Mutator +import at.florianschuster.control.Reducer +import at.florianschuster.control.Transformer +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.flow.emptyFlow /** - * Creates a [CoroutineScope] with [coroutineContext] that lives until [onDispose]. + * Collects values from the [Controller.state] and represents its latest value via [State]. + * Every time state is emitted, the returned [State] will be updated causing + * re-composition of every [State.value] usage. */ @Composable -internal fun ComposeCoroutineScope( - coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate -): CoroutineScope { - val scope = remember(coroutineContext) { CoroutineScope(coroutineContext) } - onDispose { scope.cancel() } - return scope +internal fun Controller<*, *, S>.collectAsState(): State { + return state.collectAsState(initial = currentState) } /** - * Collects values from the [Controller.state] and represents its latest value via [State]. - * Every time state is emitted, the returned [State] will be updated causing - * re-composition of every [State.value] usage. + * An intermediate solution for creating a [Controller] used in [Composable]s. + * + * The [Controller] state machine is started once the [Controller] is used in a composition and + * cancelled once it is no longer used. + * + * The [Controller] will be kept across recompositions via [remember]. If any of the parameters + * change, a new instance of the [Controller] will be created. */ @Composable -internal fun Controller<*, *, S>.collectState(): State { - return state.collectAsState(initial = currentState) +internal fun ComposableController( + initialState: State, + mutator: Mutator = { _ -> emptyFlow() }, + reducer: Reducer = { _, previousState -> previousState }, + + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + tag: String = "TODO defaultTag()", + controllerLog: ControllerLog = ControllerLog.default, + + dispatcher: CoroutineDispatcher = Dispatchers.Default +): Controller = remember( + initialState, mutator, reducer, + actionsTransformer, mutationsTransformer, statesTransformer, + tag, controllerLog, + dispatcher +) { + ComposableLifecycleController( + ManagedController( + initialState, mutator, reducer, + actionsTransformer, mutationsTransformer, statesTransformer, + tag, controllerLog, + dispatcher + ) + ) +} + +private class ComposableLifecycleController( + private val controller: ManagedController +) : Controller by controller, CompositionLifecycleObserver { + + override fun onEnter() { + controller.start() + } + + override fun onLeave() { + controller.cancel() + } } \ No newline at end of file diff --git a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt index 449a9230..a008dbf4 100644 --- a/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt +++ b/examples/android-counter/src/androidTest/kotlin/at/florianschuster/control/androidcounter/CounterViewTest.kt @@ -8,8 +8,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 +import at.florianschuster.control.ControllerStub import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterController import at.florianschuster.control.kotlincounter.CounterState import at.florianschuster.control.kotlincounter.createCounterController import at.florianschuster.control.stub @@ -21,12 +21,13 @@ import kotlin.test.assertEquals @RunWith(AndroidJUnit4::class) internal class CounterViewTest { - private lateinit var controller: CounterController + private lateinit var stub: ControllerStub @Before fun setup() { CounterView.CounterControllerProvider = { scope -> - controller = scope.createCounterController().apply { stub() } + val controller = scope.createCounterController() + stub = controller.stub() controller } launchFragmentInContainer() @@ -38,7 +39,7 @@ internal class CounterViewTest { onView(withId(R.id.increaseButton)).perform(click()) // then - assertEquals(CounterAction.Increment, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Increment, stub.dispatchedActions.last()) } @Test @@ -47,7 +48,7 @@ internal class CounterViewTest { onView(withId(R.id.decreaseButton)).perform(click()) // then - assertEquals(CounterAction.Decrement, controller.stub().dispatchedActions.last()) + assertEquals(CounterAction.Decrement, stub.dispatchedActions.last()) } @Test @@ -56,7 +57,7 @@ internal class CounterViewTest { val testValue = 1 // when - controller.stub().emitState(CounterState(value = testValue)) + stub.emitState(CounterState(value = testValue)) // then onView(withId(R.id.valueTextView)).check(matches(withText("$testValue"))) @@ -65,7 +66,7 @@ internal class CounterViewTest { @Test fun whenStateOffersLoadingProgressBarIsVisible() { // when - controller.stub().emitState(CounterState(loading = true)) + stub.emitState(CounterState(loading = true)) // then onView(withId(R.id.loadingProgressBar)).check(matches(isDisplayed())) From 4409e5c41992b2f93b06e7b8dc44585868f50cfb Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 24 May 2020 22:12:54 +0200 Subject: [PATCH 02/31] Add CompositionController --- .../android-counter-compose/build.gradle.kts | 1 - .../CounterScreenTest.kt | 26 +++---- .../androidcountercomposeexample/App.kt | 56 +-------------- .../CounterController.kt | 61 ++++++++++++++++ .../CounterScreen.kt | 27 +++---- .../extensions.kt | 70 +++++++------------ 6 files changed, 107 insertions(+), 134 deletions(-) create mode 100644 examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt diff --git a/examples/android-counter-compose/build.gradle.kts b/examples/android-counter-compose/build.gradle.kts index 25589ff4..eb38abc8 100644 --- a/examples/android-counter-compose/build.gradle.kts +++ b/examples/android-counter-compose/build.gradle.kts @@ -42,7 +42,6 @@ android { dependencies { implementation(project(":control-core")) - implementation(project(":examples:kotlin-counter")) implementation(Libs.appcompat) implementation(Libs.lifecycle_runtime_ktx) diff --git a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt index 01d3a724..6a3e6dbf 100644 --- a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt +++ b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt @@ -1,17 +1,12 @@ package at.florianschuster.control.androidcountercomposeexample -import androidx.compose.getValue import androidx.ui.test.assertIsDisplayed import androidx.ui.test.createComposeRule import androidx.ui.test.doClick import androidx.ui.test.findByTag import androidx.ui.test.findByText -import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterController -import at.florianschuster.control.kotlincounter.CounterState -import at.florianschuster.control.kotlincounter.createCounterController +import at.florianschuster.control.ControllerStub import at.florianschuster.control.stub -import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Before import org.junit.Rule import org.junit.Test @@ -25,16 +20,13 @@ internal class CounterScreenTest { @get:Rule val composeTestRule = createComposeRule() - private lateinit var controller: CounterController + private lateinit var stub: ControllerStub @Before fun setup() { - TODO("use correct controller") - controller = TestCoroutineScope().createCounterController().apply { stub() } - composeTestRule.setContent { - val state by controller.collectAsState() - CounterScreen(counterState = state, action = controller::dispatch) - } + val controller = CounterController() + stub = controller.stub() + composeTestRule.setContent { CounterScreen(controller) } } @Test @@ -46,7 +38,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterAction.Increment, controller.stub().dispatchedActions.last()) + assertEquals(CounterController.Action.Increment, stub.dispatchedActions.last()) } @Test @@ -58,7 +50,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterAction.Decrement, controller.stub().dispatchedActions.last()) + assertEquals(CounterController.Action.Decrement, stub.dispatchedActions.last()) } @Test @@ -67,7 +59,7 @@ internal class CounterScreenTest { val testValue = 42 // when - controller.stub().emitState(CounterState(value = testValue)) + stub.emitState(CounterController.State(value = testValue)) // then findByText("$testValue").assertIsDisplayed() @@ -76,7 +68,7 @@ internal class CounterScreenTest { @Test fun whenStateOffersNotLoadingProgressBarDoesNotExist() { // when - controller.stub().emitState(CounterState(loading = false)) + stub.emitState(CounterController.State(loading = false)) // then findByTag("progressIndicator").assertDoesNotExist() diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt index 0960b1a4..ac29f08e 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt @@ -3,84 +3,34 @@ package at.florianschuster.control.androidcountercomposeexample import android.os.Bundle import androidx.activity.ComponentActivity import androidx.compose.Composable -import androidx.compose.getValue -import androidx.compose.mutableStateOf -import androidx.compose.setValue import androidx.ui.core.ContextAmbient import androidx.ui.core.setContent import androidx.ui.foundation.Text import androidx.ui.foundation.isSystemInDarkTheme import androidx.ui.graphics.Color -import androidx.ui.layout.Column -import androidx.ui.material.Button import androidx.ui.material.ColorPalette import androidx.ui.material.MaterialTheme import androidx.ui.material.Scaffold import androidx.ui.material.TopAppBar import androidx.ui.material.darkColorPalette import androidx.ui.material.lightColorPalette -import at.florianschuster.control.ControllerLog -import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterController -import at.florianschuster.control.kotlincounter.CounterMutation -import at.florianschuster.control.kotlincounter.CounterState -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.flow internal class AppActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // setContent { AppScreen() } - setContent { - var lel by mutableStateOf(true) - Column { - Button(onClick = { lel = !lel }) { Text(text = "Click me") } - if (lel) AppScreen() - } - - } + setContent { App() } } } @Composable -private fun AppScreen() { - val controller: CounterController = ComposableController( - initialState = CounterState(value = 0, loading = false), - mutator = { action -> - when (action) { - CounterAction.Increment -> flow { - emit(CounterMutation.SetLoading(true)) - delay(500) - emit(CounterMutation.IncreaseValue) - emit(CounterMutation.SetLoading(false)) - } - CounterAction.Decrement -> flow { - emit(CounterMutation.SetLoading(true)) - delay(500) - emit(CounterMutation.DecreaseValue) - emit(CounterMutation.SetLoading(false)) - } - } - }, - reducer = { mutation, previousState -> - when (mutation) { - is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) - is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) - is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading) - } - }, - controllerLog = ControllerLog.Println - ) +private fun App() { MaterialTheme(colors = AppColors.currentColorPalette) { Scaffold( topAppBar = { TopAppBar(title = { Text(text = ContextAmbient.current.getString(R.string.app_name)) }) }, - bodyContent = { - val controllerState by controller.collectAsState() - CounterScreen(counterState = controllerState, action = controller::dispatch) - } + bodyContent = { CounterScreen() } ) } } diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt new file mode 100644 index 00000000..88a67737 --- /dev/null +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -0,0 +1,61 @@ +package at.florianschuster.control.androidcountercomposeexample + +import at.florianschuster.control.ControllerLog +import at.florianschuster.control.ManagedController +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import java.io.Serializable + +class CounterController( + initialState: State = State(value = 0, loading = false) +) : CompositionController { + + sealed class Action { + object Increment : Action() + object Decrement : Action() + data class SetValue(val value: Int) : Action() + } + + sealed class Mutation { + object IncreaseValue : Mutation() + object DecreaseValue : Mutation() + data class SetLoading(val loading: Boolean) : Mutation() + data class SetValue(val value: Int) : Mutation() + } + + data class State( + val value: Int = 0, + val loading: Boolean = false + ) : Serializable + + override val controller = ManagedController( + initialState = initialState, + mutator = { action -> + when (action) { + Action.Increment -> flow { + emit(Mutation.SetLoading(true)) + delay(500) + emit(Mutation.IncreaseValue) + emit(Mutation.SetLoading(false)) + } + Action.Decrement -> flow { + emit(Mutation.SetLoading(true)) + delay(500) + emit(Mutation.DecreaseValue) + emit(Mutation.SetLoading(false)) + } + is Action.SetValue -> flowOf(Mutation.SetValue(action.value)) + } + }, + reducer = { mutation, previousState -> + when (mutation) { + is Mutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) + is Mutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) + is Mutation.SetLoading -> previousState.copy(loading = mutation.loading) + is Mutation.SetValue -> previousState.copy(value = mutation.value) + } + }, + controllerLog = ControllerLog.Println + ) +} diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index db1dc2ae..91699195 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -1,6 +1,8 @@ package at.florianschuster.control.androidcountercomposeexample import androidx.compose.Composable +import androidx.compose.getValue +import androidx.compose.remember import androidx.ui.core.Alignment import androidx.ui.core.Modifier import androidx.ui.core.tag @@ -16,20 +18,19 @@ import androidx.ui.material.MaterialTheme import androidx.ui.material.TextButton import androidx.ui.tooling.preview.Preview import androidx.ui.unit.dp -import at.florianschuster.control.kotlincounter.CounterAction -import at.florianschuster.control.kotlincounter.CounterState @Composable internal fun CounterScreen( - counterState: CounterState, - action: (CounterAction) -> Unit = {} + injectedController: CounterController = CounterController() ) { + val controller = remember { injectedController } + val counterState by controller.collectAsState() Stack(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().gravity(Alignment.Center), horizontalArrangement = Arrangement.SpaceEvenly ) { - TextButton(onClick = { action(CounterAction.Decrement) }) { + TextButton(onClick = { controller.dispatch(CounterController.Action.Decrement) }) { Text(text = "-", style = MaterialTheme.typography.h4) } Text( @@ -38,7 +39,7 @@ internal fun CounterScreen( style = MaterialTheme.typography.h3, modifier = Modifier.tag("valueText") ) - TextButton(onClick = { action(CounterAction.Increment) }) { + TextButton(onClick = { controller.dispatch(CounterController.Action.Increment) }) { Text(text = "+", style = MaterialTheme.typography.h4) } } @@ -53,18 +54,10 @@ internal fun CounterScreen( } } -@Preview(name = "Loading") +@Preview @Composable -private fun CounterScreenPreviewLoading() { +private fun CounterScreenPreview() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterScreen(counterState = CounterState(value = 21, loading = true)) - } -} - -@Preview(name = "Not Loading") -@Composable -private fun CounterScreenPreviewNotLoading() { - MaterialTheme(colors = AppColors.currentColorPalette) { - CounterScreen(counterState = CounterState(value = 21, loading = false)) + CounterScreen() } } \ No newline at end of file diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt index 8fdfa6b3..bec4ccc5 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt @@ -4,21 +4,19 @@ import androidx.compose.Composable import androidx.compose.CompositionLifecycleObserver import androidx.compose.State import androidx.compose.collectAsState -import androidx.compose.remember +import androidx.ui.savedinstancestate.Saver +import androidx.ui.savedinstancestate.rememberSavedInstanceState import at.florianschuster.control.Controller -import at.florianschuster.control.ControllerLog import at.florianschuster.control.ManagedController -import at.florianschuster.control.Mutator -import at.florianschuster.control.Reducer -import at.florianschuster.control.Transformer -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.Flow +import java.io.Serializable /** - * Collects values from the [Controller.state] and represents its latest value via [State]. - * Every time state is emitted, the returned [State] will be updated causing - * re-composition of every [State.value] usage. + * Collects values from the [Controller.state] and represents its latest value via + * [androidx.compose.State]. + * + * Every time a new [Controller.state] is emitted, the returned [androidx.compose.State] + * will be updated causing re-composition. */ @Composable internal fun Controller<*, *, S>.collectAsState(): State { @@ -26,47 +24,25 @@ internal fun Controller<*, *, S>.collectAsState(): State { } /** - * An intermediate solution for creating a [Controller] used in [Composable]s. - * - * The [Controller] state machine is started once the [Controller] is used in a composition and - * cancelled once it is no longer used. + * A [Controller] delegate that implements [CompositionLifecycleObserver]. * - * The [Controller] will be kept across recompositions via [remember]. If any of the parameters - * change, a new instance of the [Controller] will be created. + * The state machine of [controller] is started once [CompositionController] is used in a + * composition and cancelled once [CompositionController] is no longer used. */ -@Composable -internal fun ComposableController( - initialState: State, - mutator: Mutator = { _ -> emptyFlow() }, - reducer: Reducer = { _, previousState -> previousState }, +interface CompositionController : + Controller, CompositionLifecycleObserver { - actionsTransformer: Transformer = { it }, - mutationsTransformer: Transformer = { it }, - statesTransformer: Transformer = { it }, + val controller: ManagedController - tag: String = "TODO defaultTag()", - controllerLog: ControllerLog = ControllerLog.default, + // region Controller - dispatcher: CoroutineDispatcher = Dispatchers.Default -): Controller = remember( - initialState, mutator, reducer, - actionsTransformer, mutationsTransformer, statesTransformer, - tag, controllerLog, - dispatcher -) { - ComposableLifecycleController( - ManagedController( - initialState, mutator, reducer, - actionsTransformer, mutationsTransformer, statesTransformer, - tag, controllerLog, - dispatcher - ) - ) -} + override fun dispatch(action: Action) = controller.dispatch(action) + override val currentState: State get() = controller.currentState + override val state: Flow get() = controller.state + + // endregion -private class ComposableLifecycleController( - private val controller: ManagedController -) : Controller by controller, CompositionLifecycleObserver { + // region CompositionLifecycleObserver override fun onEnter() { controller.start() @@ -75,4 +51,6 @@ private class ComposableLifecycleController( override fun onLeave() { controller.cancel() } + + // endregion } \ No newline at end of file From c42135f8dd67811ea6d78e4ff7bf7561d63fbeee Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sun, 24 May 2020 22:50:18 +0200 Subject: [PATCH 03/31] Remove unused imports --- .../control/androidcountercomposeexample/extensions.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt index bec4ccc5..ea2c3a34 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt @@ -4,12 +4,9 @@ import androidx.compose.Composable import androidx.compose.CompositionLifecycleObserver import androidx.compose.State import androidx.compose.collectAsState -import androidx.ui.savedinstancestate.Saver -import androidx.ui.savedinstancestate.rememberSavedInstanceState import at.florianschuster.control.Controller import at.florianschuster.control.ManagedController import kotlinx.coroutines.flow.Flow -import java.io.Serializable /** * Collects values from the [Controller.state] and represents its latest value via From 3cdc2291fcba6b453a43e236069c4b350eb390e3 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Mon, 25 May 2020 09:25:15 +0200 Subject: [PATCH 04/31] Add additional implementation start test --- .../control/ImplementationStartStopTest.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt index 2d3e715e..ce48de63 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt @@ -41,8 +41,22 @@ internal class ImplementationStartStopTest { coroutineStart = CoroutineStart.LAZY ) + assertFalse(sut.stateJob.isActive) + val started = sut.start() + assertTrue(started) + assertTrue(sut.stateJob.isActive) + } + + @Test + fun `manually start implementation when already started`() { + val sut = testCoroutineScope.createSimpleCounterController( + coroutineStart = CoroutineStart.LAZY + ) + assertFalse(sut.stateJob.isActive) sut.start() + val started = sut.start() + assertFalse(started) assertTrue(sut.stateJob.isActive) } @@ -52,8 +66,9 @@ internal class ImplementationStartStopTest { coroutineStart = CoroutineStart.LAZY ) - sut.start() + val started = sut.start() assertTrue(sut.stateJob.isActive) + assertTrue(started) sut.cancel() assertFalse(sut.stateJob.isActive) } From 02d7177d9af3b0d4b5f803e0501fca11d9794a3b Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Tue, 26 May 2020 10:55:01 +0200 Subject: [PATCH 05/31] Rename CompositionControllerDelegate --- .../androidcountercomposeexample/CounterController.kt | 4 ++-- .../control/androidcountercomposeexample/extensions.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt index 88a67737..cf965567 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.flow.flowOf import java.io.Serializable class CounterController( - initialState: State = State(value = 0, loading = false) -) : CompositionController { + initialState: State = State() +) : CompositionControllerDelegate { sealed class Action { object Increment : Action() diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt index ea2c3a34..27bca932 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt @@ -23,10 +23,10 @@ internal fun Controller<*, *, S>.collectAsState(): State { /** * A [Controller] delegate that implements [CompositionLifecycleObserver]. * - * The state machine of [controller] is started once [CompositionController] is used in a - * composition and cancelled once [CompositionController] is no longer used. + * The state machine of [controller] is started once [CompositionControllerDelegate] is used in a + * composition and cancelled once [CompositionControllerDelegate] is no longer used. */ -interface CompositionController : +interface CompositionControllerDelegate : Controller, CompositionLifecycleObserver { val controller: ManagedController From bd1ba4e643240915d617f3b3091628a5ca5e0512 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 09:14:00 +0200 Subject: [PATCH 06/31] Update android tools and compose dependencies --- buildSrc/src/main/kotlin/Versions.kt | 12 ++++++------ gradle/wrapper/gradle-wrapper.properties | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 018892b2..7279262d 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -25,11 +25,11 @@ object Versions { const val androidx_test: String = "1.2.0" - const val androidx_ui: String = "0.1.0-dev11" + const val androidx_ui: String = "0.1.0-dev12" const val io_ktor: String = "1.3.2" // available: "1.3.2-1.4-M1-release-99" - const val com_android_tools_build_gradle: String = "4.1.0-alpha09" + const val com_android_tools_build_gradle: String = "4.1.0-alpha10" const val org_jlleitschuh_gradle_ktlint_gradle_plugin: String = "9.2.1" @@ -47,13 +47,13 @@ object Versions { const val gradle_pitest_plugin: String = "1.5.1" - const val compose_compiler: String = "0.1.0-dev11" + const val compose_compiler: String = "0.1.0-dev12" const val constraintlayout: String = "2.0.0-beta4" const val espresso_core: String = "3.2.0" - const val lint_gradle: String = "27.1.0-alpha09" + const val lint_gradle: String = "27.1.0-alpha10" const val appcompat: String = "1.1.0" @@ -65,12 +65,12 @@ object Versions { const val ktlint: String = "0.36.0" - const val aapt2: String = "4.1.0-alpha09-6422342" + const val aapt2: String = "4.1.0-alpha10-6481518" const val mockk: String = "1.10.0" /** - * Current version: "6.4.1" + * Current version: "6.5-milestone-1" * See issue 19: How to update Gradle itself? * https://github.com/jmfayard/buildSrcVersions/issues/19 */ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index dc7f3890..ba3903f0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-milestone-1-all.zip From 258b431c6e75c2832a79b6601008018286086245 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 09:15:39 +0200 Subject: [PATCH 07/31] Add kdoc referencing ManagedController in Controller builder --- .../src/main/kotlin/at/florianschuster/control/Controller.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index c7ff727a..ae64e626 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -94,6 +94,8 @@ interface Controller { * 2. [Reducer]: This corresponds to [Mutation] -> [State] * 3. [Transformer] * 4. [ControllerImplementation] + * + * To create a [Controller] that is not bound to a [CoroutineScope] look into [ManagedController]. */ @ExperimentalCoroutinesApi @FlowPreview From d11525ef0495f1c8c1ec94f9d7668e18dc9ffe4f Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 09:17:24 +0200 Subject: [PATCH 08/31] Rename collectAsState function --- .../control/androidcountercomposeexample/CounterController.kt | 4 ---- .../control/androidcountercomposeexample/CounterScreen.kt | 3 ++- .../control/androidcountercomposeexample/extensions.kt | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt index cf965567..8536b2b0 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -14,14 +14,12 @@ class CounterController( sealed class Action { object Increment : Action() object Decrement : Action() - data class SetValue(val value: Int) : Action() } sealed class Mutation { object IncreaseValue : Mutation() object DecreaseValue : Mutation() data class SetLoading(val loading: Boolean) : Mutation() - data class SetValue(val value: Int) : Mutation() } data class State( @@ -45,7 +43,6 @@ class CounterController( emit(Mutation.DecreaseValue) emit(Mutation.SetLoading(false)) } - is Action.SetValue -> flowOf(Mutation.SetValue(action.value)) } }, reducer = { mutation, previousState -> @@ -53,7 +50,6 @@ class CounterController( is Mutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) is Mutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) is Mutation.SetLoading -> previousState.copy(loading = mutation.loading) - is Mutation.SetValue -> previousState.copy(value = mutation.value) } }, controllerLog = ControllerLog.Println diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index 91699195..a983bea0 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -24,7 +24,8 @@ internal fun CounterScreen( injectedController: CounterController = CounterController() ) { val controller = remember { injectedController } - val counterState by controller.collectAsState() + val counterState by controller.collectState() + Stack(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().gravity(Alignment.Center), diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt index 27bca932..c6296449 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.Flow * will be updated causing re-composition. */ @Composable -internal fun Controller<*, *, S>.collectAsState(): State { +internal fun Controller<*, *, S>.collectState(): State { return state.collectAsState(initial = currentState) } From e7069a5a9577d23d7cb4deab89bfc9cdc86cf08c Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 11:33:50 +0200 Subject: [PATCH 09/31] Split up CounterScreen --- .../CounterController.kt | 4 +-- .../CounterScreen.kt | 25 +++++++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt index 8536b2b0..d30ac65c 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -4,8 +4,6 @@ import at.florianschuster.control.ControllerLog import at.florianschuster.control.ManagedController import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf -import java.io.Serializable class CounterController( initialState: State = State() @@ -25,7 +23,7 @@ class CounterController( data class State( val value: Int = 0, val loading: Boolean = false - ) : Serializable + ) override val controller = ManagedController( initialState = initialState, diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index a983bea0..0791db28 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -25,13 +25,20 @@ internal fun CounterScreen( ) { val controller = remember { injectedController } val counterState by controller.collectState() + CounterComponent(counterState, controller::dispatch) +} +@Composable +private fun CounterComponent( + counterState: CounterController.State, + dispatch: (CounterController.Action) -> Unit = {} +) { Stack(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().gravity(Alignment.Center), horizontalArrangement = Arrangement.SpaceEvenly ) { - TextButton(onClick = { controller.dispatch(CounterController.Action.Decrement) }) { + TextButton(onClick = { dispatch(CounterController.Action.Decrement) }) { Text(text = "-", style = MaterialTheme.typography.h4) } Text( @@ -40,7 +47,7 @@ internal fun CounterScreen( style = MaterialTheme.typography.h3, modifier = Modifier.tag("valueText") ) - TextButton(onClick = { controller.dispatch(CounterController.Action.Increment) }) { + TextButton(onClick = { dispatch(CounterController.Action.Increment) }) { Text(text = "+", style = MaterialTheme.typography.h4) } } @@ -55,10 +62,18 @@ internal fun CounterScreen( } } -@Preview +@Preview("not loading") +@Composable +private fun CounterComponentPreview() { + MaterialTheme(colors = AppColors.currentColorPalette) { + CounterComponent(CounterController.State(value = -1, loading = false)) + } +} + +@Preview("loading") @Composable -private fun CounterScreenPreview() { +private fun CounterComponentPreviewLoading() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterScreen() + CounterComponent(CounterController.State(value = 1, loading = true)) } } \ No newline at end of file From 6c112aeec6f260146fd30b87ae9160142bca2f22 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 11:40:41 +0200 Subject: [PATCH 10/31] Add ControllerStart --- .idea/misc.xml | 2 +- .../at/florianschuster/control/Controller.kt | 12 +--- .../control/ManagedController.kt | 10 +-- .../florianschuster/control/implementation.kt | 13 ++-- .../at/florianschuster/control/start.kt | 26 ++++++++ .../florianschuster/control/ControllerTest.kt | 3 +- ...mplementationEventTest.kt => EventTest.kt} | 6 +- .../control/ImplementationTest.kt | 11 ++-- .../control/ManagedControllerTest.kt | 3 +- ...mentationStartStopTest.kt => StartTest.kt} | 62 +++++++++++-------- .../at/florianschuster/control/StubTest.kt | 3 +- 11 files changed, 86 insertions(+), 65 deletions(-) create mode 100644 control-core/src/main/kotlin/at/florianschuster/control/start.kt rename control-core/src/test/kotlin/at/florianschuster/control/{ImplementationEventTest.kt => EventTest.kt} (94%) rename control-core/src/test/kotlin/at/florianschuster/control/{ImplementationStartStopTest.kt => StartTest.kt} (52%) diff --git a/.idea/misc.xml b/.idea/misc.xml index 7bfef59d..d5d35ec4 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index ae64e626..a3caa1ec 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -3,7 +3,6 @@ package at.florianschuster.control import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow @@ -131,14 +130,9 @@ fun CoroutineScope.createController( controllerLog: ControllerLog = ControllerLog.default, /** - * When the internal state machine [Flow] should be started. - * - * Default is [CoroutineStart.LAZY] -> [Flow] is started once [Controller.state], - * [Controller.currentState] or [Controller.dispatch] are accessed. - * - * Look into [CoroutineStart] to see how the options would affect the [Flow] start. + * When the internal state machine [Flow] should be started. See [ControllerStart]. */ - coroutineStart: CoroutineStart = CoroutineStart.LAZY, + controllerStart: ControllerStart = ControllerStart.Lazy, /** * Override to launch the internal state machine [Flow] in a different [CoroutineDispatcher] @@ -148,7 +142,7 @@ fun CoroutineScope.createController( */ dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher ): Controller = ControllerImplementation( - scope = this, dispatcher = dispatcher, coroutineStart = coroutineStart, + scope = this, dispatcher = dispatcher, controllerStart = controllerStart, initialState = initialState, mutator = mutator, reducer = reducer, actionsTransformer = actionsTransformer, diff --git a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt index 2dd7480b..411a95ad 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt @@ -3,7 +3,6 @@ package at.florianschuster.control import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview @@ -36,11 +35,8 @@ interface ManagedController : Controller ManagedController( ): ManagedController = ControllerImplementation( scope = CoroutineScope(dispatcher), dispatcher = dispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Managed, initialState = initialState, mutator = mutator, diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index ce9cf19f..c19bb53e 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch internal class ControllerImplementation( internal val scope: CoroutineScope, internal val dispatcher: CoroutineDispatcher, - internal val coroutineStart: CoroutineStart, + internal val controllerStart: ControllerStart, internal val initialState: State, internal val mutator: Mutator, @@ -50,7 +50,7 @@ internal class ControllerImplementation( internal val stateJob: Job = scope.launch( context = dispatcher + CoroutineName(tag), - start = coroutineStart + start = CoroutineStart.LAZY ) { val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) @@ -99,13 +99,13 @@ internal class ControllerImplementation( override val state: Flow get() = if (stubInitialized) stub.stateFlow else { - start() + if (controllerStart is ControllerStart.Lazy) start() mutableStateFlow } override val currentState: State get() = if (stubInitialized) stub.stateFlow.value else { - start() + if (controllerStart is ControllerStart.Lazy) start() mutableStateFlow.value } @@ -113,7 +113,7 @@ internal class ControllerImplementation( if (stubInitialized) { stub.mutableDispatchedActions.add(action) } else { - start() + if (controllerStart is ControllerStart.Lazy) start() actionChannel.offer(action) } } @@ -135,6 +135,9 @@ internal class ControllerImplementation( init { controllerLog.log(ControllerEvent.Created(tag)) + if (controllerStart is ControllerStart.Immediately) { + start() + } } companion object { diff --git a/control-core/src/main/kotlin/at/florianschuster/control/start.kt b/control-core/src/main/kotlin/at/florianschuster/control/start.kt new file mode 100644 index 00000000..40fc7ad9 --- /dev/null +++ b/control-core/src/main/kotlin/at/florianschuster/control/start.kt @@ -0,0 +1,26 @@ +package at.florianschuster.control + +import kotlinx.coroutines.CoroutineScope + +/** + * Options for [Controller] builder such as [CoroutineScope.createController] or + * [ManagedController] to define when the internal state machine should be started. + */ +sealed class ControllerStart { + + /** + * The state machine is started once [Controller.state], [Controller.currentState] or + * [Controller.dispatch] are accessed. + */ + object Lazy : ControllerStart() + + /** + * The state machine is iImmediately started once the [Controller] is built. + */ + object Immediately : ControllerStart() + + /** + * The state machine is started once [ManagedController.start] is called. + */ + internal object Managed : ControllerStart() +} \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt index 54f7080c..4fca80f2 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt @@ -1,7 +1,6 @@ package at.florianschuster.control import io.mockk.mockk -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.singleOrNull @@ -33,7 +32,7 @@ internal class ControllerTest { assertEquals(expectedTag, sut.tag) assertEquals(ControllerLog.default, sut.controllerLog) - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) + assertEquals(ControllerStart.Lazy, sut.controllerStart) assertEquals(scopeDispatcher, sut.dispatcher) } } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt similarity index 94% rename from control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index cafcff2c..f3bb57b7 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationEventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -1,14 +1,12 @@ package at.florianschuster.control import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test import kotlin.test.assertTrue -internal class ImplementationEventTest { +internal class EventTest { @Test fun `event message contains library name and tag`() { @@ -75,7 +73,7 @@ internal class ImplementationEventTest { ) = ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> flow { diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index dadc648a..364cd2cd 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -8,7 +8,6 @@ import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.lastEmission import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -211,7 +210,7 @@ internal class ImplementationTest { ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { flowOf(it) }, reducer = { _, previousState -> previousState }, @@ -226,7 +225,7 @@ internal class ImplementationTest { ControllerImplementation, List, List>( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, // 1. ["initialState"] initialState = listOf("initialState"), @@ -262,7 +261,7 @@ internal class ImplementationTest { ) = ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> flow { @@ -290,7 +289,7 @@ internal class ImplementationTest { ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { action -> when (action) { @@ -318,7 +317,7 @@ internal class ImplementationTest { ) = ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = 0, mutator = { flowOf(it) }, reducer = { action, previousState -> previousState + action }, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt index 7eb20b88..1f9de493 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt @@ -2,7 +2,6 @@ package at.florianschuster.control import io.mockk.mockk import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.singleOrNull @@ -36,7 +35,7 @@ internal class ManagedControllerTest { assertEquals(expectedTag, sut.tag) assertEquals(ControllerLog.default, sut.controllerLog) - assertEquals(CoroutineStart.LAZY, sut.coroutineStart) + assertEquals(ControllerStart.Managed, sut.controllerStart) assertEquals(scopeDispatcher, sut.dispatcher) } } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt similarity index 52% rename from control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt rename to control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index ce48de63..f2532f55 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationStartStopTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -1,56 +1,61 @@ package at.florianschuster.control -import at.florianschuster.test.coroutines.TestCoroutineScopeRule import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.merge -import org.junit.Rule +import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -internal class ImplementationStartStopTest { - - @get:Rule - val testCoroutineScope = TestCoroutineScopeRule() +internal class StartTest { @Test fun `default start mode`() { - val sut = testCoroutineScope.createSimpleCounterController( - coroutineStart = CoroutineStart.DEFAULT - ) + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Immediately) assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) } @Test fun `lazy start mode`() { - val sut = testCoroutineScope.createSimpleCounterController( - coroutineStart = CoroutineStart.LAZY - ) + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) assertFalse(sut.stateJob.isActive) sut.currentState assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) } @Test - fun `manually start implementation`() { - val sut = testCoroutineScope.createSimpleCounterController( - coroutineStart = CoroutineStart.LAZY - ) + fun `managed start mode`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Managed) assertFalse(sut.stateJob.isActive) + sut.currentState + assertFalse(sut.stateJob.isActive) + val started = sut.start() assertTrue(started) assertTrue(sut.stateJob.isActive) + + sut.cancel() + assertFalse(sut.stateJob.isActive) } @Test - fun `manually start implementation when already started`() { - val sut = testCoroutineScope.createSimpleCounterController( - coroutineStart = CoroutineStart.LAZY + fun `managed start mode, start when already started`() { + val sut = TestCoroutineScope().createSimpleCounterController( + controllerStart = ControllerStart.Managed ) assertFalse(sut.stateJob.isActive) @@ -61,24 +66,27 @@ internal class ImplementationStartStopTest { } @Test - fun `manually cancel implementation`() { - val sut = testCoroutineScope.createSimpleCounterController( - coroutineStart = CoroutineStart.LAZY + fun `managed start mode, cancel implementation`() { + val sut = TestCoroutineScope().createSimpleCounterController( + controllerStart = ControllerStart.Managed ) - val started = sut.start() + val started = sut.start() assertTrue(sut.stateJob.isActive) assertTrue(started) + + sut.dispatch(42) + sut.cancel() assertFalse(sut.stateJob.isActive) } private fun CoroutineScope.createSimpleCounterController( - coroutineStart: CoroutineStart = CoroutineStart.LAZY + controllerStart: ControllerStart ) = ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - coroutineStart = coroutineStart, + controllerStart = controllerStart, initialState = 0, mutator = { flowOf(it) }, reducer = { _, previousState -> previousState }, diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt index 650a9e50..fc8abf2d 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StubTest.kt @@ -5,7 +5,6 @@ import at.florianschuster.test.flow.emissions import at.florianschuster.test.flow.expect import at.florianschuster.test.flow.testIn import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import org.junit.Rule @@ -105,7 +104,7 @@ internal class StubTest { ControllerImplementation, List, List>( scope = this, dispatcher = scopeDispatcher, - coroutineStart = CoroutineStart.LAZY, + controllerStart = ControllerStart.Lazy, initialState = initialState, mutator = { flowOf(it) }, reducer = { previousState, mutation -> previousState + mutation }, From b4d60d7d57e40fa0c71a2f2feabc0f64378f1ec7 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 11:47:11 +0200 Subject: [PATCH 11/31] Update api --- control-core/api/control-core.api | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 44b0ff7e..8dd8a0ae 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -34,8 +34,8 @@ public final class at/florianschuster/control/ControllerEvent$Stub : at/florians } public final class at/florianschuster/control/ControllerKt { - public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; - public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; + public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; + public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; } public abstract class at/florianschuster/control/ControllerLog { @@ -59,6 +59,17 @@ public final class at/florianschuster/control/ControllerLog$Println : at/florian public static final field INSTANCE Lat/florianschuster/control/ControllerLog$Println; } +public abstract class at/florianschuster/control/ControllerStart { +} + +public final class at/florianschuster/control/ControllerStart$Immediately : at/florianschuster/control/ControllerStart { + public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Immediately; +} + +public final class at/florianschuster/control/ControllerStart$Lazy : at/florianschuster/control/ControllerStart { + public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Lazy; +} + public abstract interface class at/florianschuster/control/ControllerStub { public abstract fun emitState (Ljava/lang/Object;)V public abstract fun getDispatchedActions ()Ljava/util/List; From 8626977f44b2fe0e2d02605cb359d0b31aeabf74 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 11:48:17 +0200 Subject: [PATCH 12/31] Log ControllerStart in Created Event --- .../kotlin/at/florianschuster/control/event.kt | 4 ++-- .../florianschuster/control/implementation.kt | 2 +- .../kotlin/at/florianschuster/control/start.kt | 13 ++++++++++--- .../at/florianschuster/control/EventTest.kt | 13 +++++++++---- .../at/florianschuster/control/LogTest.kt | 17 ++++++++--------- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/event.kt b/control-core/src/main/kotlin/at/florianschuster/control/event.kt index 014b203b..319050d8 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/event.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/event.kt @@ -12,8 +12,8 @@ sealed class ControllerEvent( * When the implementation is created. */ class Created internal constructor( - tag: String - ) : ControllerEvent(tag, "created") + tag: String, controllerStart: String + ) : ControllerEvent(tag, "created with controllerStart: $controllerStart") /** * When the state machine is started. diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index c19bb53e..03df3a44 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -134,7 +134,7 @@ internal class ControllerImplementation( // endregion init { - controllerLog.log(ControllerEvent.Created(tag)) + controllerLog.log(ControllerEvent.Created(tag, controllerStart.toString())) if (controllerStart is ControllerStart.Immediately) { start() } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/start.kt b/control-core/src/main/kotlin/at/florianschuster/control/start.kt index 40fc7ad9..5eaf6081 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/start.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/start.kt @@ -12,15 +12,22 @@ sealed class ControllerStart { * The state machine is started once [Controller.state], [Controller.currentState] or * [Controller.dispatch] are accessed. */ - object Lazy : ControllerStart() + object Lazy : ControllerStart() { + override fun toString(): String = "Lazy" + } /** * The state machine is iImmediately started once the [Controller] is built. */ - object Immediately : ControllerStart() + object Immediately : ControllerStart() { + override fun toString(): String = "Immediately" + } /** * The state machine is started once [ManagedController.start] is called. */ - internal object Managed : ControllerStart() + internal object Managed : ControllerStart() { + override fun toString(): String = "Managed" + } + } \ No newline at end of file diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index f3bb57b7..c0fb5876 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -11,7 +11,7 @@ internal class EventTest { @Test fun `event message contains library name and tag`() { val tag = "some_tag" - val event = ControllerEvent.Created(tag) + val event = ControllerEvent.Completed(tag) assertTrue(event.toString().contains("control")) assertTrue(event.toString().contains(tag)) @@ -20,9 +20,13 @@ internal class EventTest { @Test fun `ControllerImplementation logs events correctly`() { val events = mutableListOf() - val sut = TestCoroutineScope().eventsController(events) + val sut = TestCoroutineScope().eventsController( + events, + controllerStart = ControllerStart.Managed + ) assertTrue(events.last() is ControllerEvent.Created) + assertTrue(events.last().toString().contains(ControllerStart.Managed.toString())) sut.start() events.takeLast(2).let { lastEvents -> @@ -69,11 +73,12 @@ internal class EventTest { } private fun CoroutineScope.eventsController( - events: MutableList + events: MutableList, + controllerStart: ControllerStart = ControllerStart.Lazy ) = ControllerImplementation( scope = this, dispatcher = scopeDispatcher, - controllerStart = ControllerStart.Lazy, + controllerStart = controllerStart, initialState = 0, mutator = { action -> flow { diff --git a/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt index 0a41e9b7..d5767cfe 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt @@ -42,8 +42,8 @@ internal class LogTest { sut.log(CreatedEvent) assertEquals(CreatedEvent.toString(), capturedLogMessage.captured) - sut.log(DestroyedEvent) - assertEquals(DestroyedEvent.toString(), capturedLogMessage.captured) + sut.log(CompletedEvent) + assertEquals(CompletedEvent.toString(), capturedLogMessage.captured) verify(exactly = 2) { out.println(any()) } } @@ -57,21 +57,20 @@ internal class LogTest { sut.log(CreatedEvent) assertEquals(CreatedEvent.toString(), capturedLogMessage.captured) - sut.log(DestroyedEvent) - assertEquals(DestroyedEvent.toString(), capturedLogMessage.captured) + sut.log(CompletedEvent) + assertEquals(CompletedEvent.toString(), capturedLogMessage.captured) } @Test fun `LoggerScope factory function`() { - val event = ControllerEvent.Created(tag) - val scope = loggerScope(event) + val scope = loggerScope(CreatedEvent) - assertEquals(event, scope.event) + assertEquals(CreatedEvent, scope.event) } companion object { private const val tag = "TestTag" - private val CreatedEvent: ControllerEvent = ControllerEvent.Created(tag) - private val DestroyedEvent: ControllerEvent = ControllerEvent.Created(tag) + private val CreatedEvent: ControllerEvent = ControllerEvent.Created(tag, "lazy") + private val CompletedEvent: ControllerEvent = ControllerEvent.Completed(tag) } } \ No newline at end of file From b8d81b47c184cb37c381230ff00ac18f33c17e39 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 12:07:08 +0200 Subject: [PATCH 13/31] Dump api --- control-core/api/control-core.api | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 8dd8a0ae..15f3c36c 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -64,10 +64,12 @@ public abstract class at/florianschuster/control/ControllerStart { public final class at/florianschuster/control/ControllerStart$Immediately : at/florianschuster/control/ControllerStart { public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Immediately; + public fun toString ()Ljava/lang/String; } public final class at/florianschuster/control/ControllerStart$Lazy : at/florianschuster/control/ControllerStart { public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Lazy; + public fun toString ()Ljava/lang/String; } public abstract interface class at/florianschuster/control/ControllerStub { From f34d35ee34ad4de92f45e9d7ae9f20641b9d2209 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 12:07:53 +0200 Subject: [PATCH 14/31] Add new line at end of control-core files --- .../src/main/kotlin/at/florianschuster/control/Controller.kt | 2 +- .../kotlin/at/florianschuster/control/ManagedController.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/defaultTag.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/errors.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/extensions.kt | 2 +- control-core/src/main/kotlin/at/florianschuster/control/log.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/start.kt | 3 +-- .../src/main/kotlin/at/florianschuster/control/stub.kt | 2 +- .../control/androidcountercomposeexample/App.kt | 2 +- .../control/androidcountercomposeexample/CounterScreen.kt | 2 +- 10 files changed, 10 insertions(+), 11 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index a3caa1ec..2e0d3666 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -257,4 +257,4 @@ typealias Reducer = (mutation: Mutation, previousState: State) * } * ``` */ -typealias Transformer = (emissions: Flow) -> Flow \ No newline at end of file +typealias Transformer = (emissions: Flow) -> Flow diff --git a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt index 411a95ad..527ec02e 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/ManagedController.kt @@ -92,4 +92,4 @@ fun ManagedController( tag = tag, controllerLog = controllerLog -) \ No newline at end of file +) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt index 73ade6f1..3e9f24f0 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/defaultTag.kt @@ -5,4 +5,4 @@ internal inline fun defaultTag(): String { val stackTrace = Throwable().stackTrace check(stackTrace.size >= 2) { "Stacktrace didn't have enough elements." } return stackTrace[1].className.split("$").first().split(".").last() -} \ No newline at end of file +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt index 2d924da3..1ab3d3b7 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/errors.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/errors.kt @@ -29,4 +29,4 @@ internal sealed class ControllerError( message = "Reducer error in $tag, previousState = $previousState, mutation = $mutation", cause = cause ) -} \ No newline at end of file +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt index a08dd577..c9e1d7a3 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/extensions.kt @@ -88,4 +88,4 @@ fun Flow.takeUntil(other: Flow): Flow = flow { } } -private class TakeUntilException : CancellationException() \ No newline at end of file +private class TakeUntilException : CancellationException() diff --git a/control-core/src/main/kotlin/at/florianschuster/control/log.kt b/control-core/src/main/kotlin/at/florianschuster/control/log.kt index 42d51246..efc4718c 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/log.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/log.kt @@ -52,4 +52,4 @@ internal fun loggerScope(event: ControllerEvent) = object : LoggerScope { internal fun ControllerLog.log(event: ControllerEvent) { logger?.invoke(loggerScope(event), event.toString()) -} \ No newline at end of file +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/start.kt b/control-core/src/main/kotlin/at/florianschuster/control/start.kt index 5eaf6081..d7ced2dd 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/start.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/start.kt @@ -29,5 +29,4 @@ sealed class ControllerStart { internal object Managed : ControllerStart() { override fun toString(): String = "Managed" } - -} \ No newline at end of file +} diff --git a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt index a1ba5074..55afea0f 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/stub.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/stub.kt @@ -59,4 +59,4 @@ internal class ControllerStubImplementation( override fun emitState(state: State) { stateFlow.value = state } -} \ No newline at end of file +} diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt index ac29f08e..cd991abc 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/App.kt @@ -55,4 +55,4 @@ internal object AppColors { secondary = secondary ) } -} \ No newline at end of file +} diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index 0791db28..e43d594f 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -76,4 +76,4 @@ private fun CounterComponentPreviewLoading() { MaterialTheme(colors = AppColors.currentColorPalette) { CounterComponent(CounterController.State(value = 1, loading = true)) } -} \ No newline at end of file +} From fe582c1311767e442bfb4d129eb720d462cdb9b6 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 12:10:03 +0200 Subject: [PATCH 15/31] Add ControllerStart.toString test --- .../test/kotlin/at/florianschuster/control/StartTest.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index f2532f55..1a84ac0c 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -6,11 +6,19 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScope import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue internal class StartTest { + @Test + fun `start mode toString`() { + assertEquals("Lazy", ControllerStart.Lazy.toString()) + assertEquals("Immediately", ControllerStart.Immediately.toString()) + assertEquals("Managed", ControllerStart.Managed.toString()) + } + @Test fun `default start mode`() { val scope = TestCoroutineScope(Job()) From b079c9edd2b8b238f5080a7b14fa4bc927be5c25 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Thu, 28 May 2020 16:58:31 +0200 Subject: [PATCH 16/31] Replace toString with dedicated logName value for ControllerStart --- control-core/api/control-core.api | 2 -- .../kotlin/at/florianschuster/control/implementation.kt | 2 +- .../src/main/kotlin/at/florianschuster/control/start.kt | 8 +++++--- .../test/kotlin/at/florianschuster/control/EventTest.kt | 2 +- .../test/kotlin/at/florianschuster/control/StartTest.kt | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 15f3c36c..8dd8a0ae 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -64,12 +64,10 @@ public abstract class at/florianschuster/control/ControllerStart { public final class at/florianschuster/control/ControllerStart$Immediately : at/florianschuster/control/ControllerStart { public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Immediately; - public fun toString ()Ljava/lang/String; } public final class at/florianschuster/control/ControllerStart$Lazy : at/florianschuster/control/ControllerStart { public static final field INSTANCE Lat/florianschuster/control/ControllerStart$Lazy; - public fun toString ()Ljava/lang/String; } public abstract interface class at/florianschuster/control/ControllerStub { diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index 03df3a44..a28aab86 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -134,7 +134,7 @@ internal class ControllerImplementation( // endregion init { - controllerLog.log(ControllerEvent.Created(tag, controllerStart.toString())) + controllerLog.log(ControllerEvent.Created(tag, controllerStart.logName)) if (controllerStart is ControllerStart.Immediately) { start() } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/start.kt b/control-core/src/main/kotlin/at/florianschuster/control/start.kt index d7ced2dd..46e6ccef 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/start.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/start.kt @@ -8,25 +8,27 @@ import kotlinx.coroutines.CoroutineScope */ sealed class ControllerStart { + internal abstract val logName: String + /** * The state machine is started once [Controller.state], [Controller.currentState] or * [Controller.dispatch] are accessed. */ object Lazy : ControllerStart() { - override fun toString(): String = "Lazy" + override val logName: String = "Lazy" } /** * The state machine is iImmediately started once the [Controller] is built. */ object Immediately : ControllerStart() { - override fun toString(): String = "Immediately" + override val logName: String = "Immediately" } /** * The state machine is started once [ManagedController.start] is called. */ internal object Managed : ControllerStart() { - override fun toString(): String = "Managed" + override val logName: String = "Managed" } } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt index c0fb5876..ad0165d5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/EventTest.kt @@ -26,7 +26,7 @@ internal class EventTest { ) assertTrue(events.last() is ControllerEvent.Created) - assertTrue(events.last().toString().contains(ControllerStart.Managed.toString())) + assertTrue(events.last().toString().contains(ControllerStart.Managed.logName)) sut.start() events.takeLast(2).let { lastEvents -> diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index 1a84ac0c..fb0b3336 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -13,10 +13,10 @@ import kotlin.test.assertTrue internal class StartTest { @Test - fun `start mode toString`() { - assertEquals("Lazy", ControllerStart.Lazy.toString()) - assertEquals("Immediately", ControllerStart.Immediately.toString()) - assertEquals("Managed", ControllerStart.Managed.toString()) + fun `start mode logName`() { + assertEquals("Lazy", ControllerStart.Lazy.logName) + assertEquals("Immediately", ControllerStart.Immediately.logName) + assertEquals("Managed", ControllerStart.Managed.logName) } @Test From 5bfb8238993b2538f03328553c4738b9d887842f Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 00:28:09 +0200 Subject: [PATCH 17/31] Fix examples --- .../control/androidcounter/MainActivity.kt | 11 ++++++----- examples/android-github/build.gradle.kts | 3 +++ .../control/androidgithub/MainActivity.kt | 11 ++++++----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt index 1c925885..b3a66cd5 100644 --- a/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt +++ b/examples/android-counter/src/main/kotlin/at/florianschuster/control/androidcounter/MainActivity.kt @@ -2,15 +2,16 @@ package at.florianschuster.control.androidcounter import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit internal class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - if (savedInstanceState != null) return - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, CounterView()) - .commit() + if (savedInstanceState == null) { + supportFragmentManager.commit { + replace(android.R.id.content, CounterView()) + } + } } } \ No newline at end of file diff --git a/examples/android-github/build.gradle.kts b/examples/android-github/build.gradle.kts index b2041cdc..8ec7fb7b 100644 --- a/examples/android-github/build.gradle.kts +++ b/examples/android-github/build.gradle.kts @@ -33,6 +33,9 @@ android { sourceSets["test"].java.srcDir("src/test/kotlin") sourceSets["androidTest"].java.srcDir("src/androidTest/kotlin") sourceSets["debug"].java.srcDir("src/debug/kotlin") + packagingOptions { + exclude("META-INF/*.kotlin_module") + } } dependencies { diff --git a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt index 220b93f9..7e299679 100644 --- a/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt +++ b/examples/android-github/src/main/kotlin/at/florianschuster/control/androidgithub/MainActivity.kt @@ -2,16 +2,17 @@ package at.florianschuster.control.androidgithub import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.commit import at.florianschuster.control.androidgithub.search.GithubView internal class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - if (savedInstanceState != null) return - supportFragmentManager.beginTransaction() - .replace(android.R.id.content, GithubView()) - .commit() + if (savedInstanceState == null) { + supportFragmentManager.commit { + replace(android.R.id.content, GithubView()) + } + } } } \ No newline at end of file From 710e641d7111fe904d9e1303f2a931f8391a5492 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 00:43:12 +0200 Subject: [PATCH 18/31] Readded synchronous controller --- control-core/api/control-core.api | 2 + .../at/florianschuster/control/Controller.kt | 58 +++++++++++++++++++ .../florianschuster/control/ControllerTest.kt | 26 +++++++++ examples/android-counter/build.gradle.kts | 2 +- 4 files changed, 87 insertions(+), 1 deletion(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 8dd8a0ae..70f51d03 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -36,6 +36,8 @@ public final class at/florianschuster/control/ControllerEvent$Stub : at/florians public final class at/florianschuster/control/ControllerKt { public static final fun createController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; public static synthetic fun createController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; + public static final fun createSynchronousController (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;)Lat/florianschuster/control/Controller; + public static synthetic fun createSynchronousController$default (Lkotlinx/coroutines/CoroutineScope;Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lat/florianschuster/control/ControllerStart;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/Controller; } public abstract class at/florianschuster/control/ControllerLog { diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 2e0d3666..0bcb9314 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlin.coroutines.ContinuationInterceptor /** @@ -152,6 +154,62 @@ fun CoroutineScope.createController( tag = tag, controllerLog = controllerLog ) +/** + * Creates a [Controller] bound to a [CoroutineScope] where [Action] == [Mutation]. + * This means that the [Controller] can only deal with synchronous state reductions without + * any asynchronous side-effects. + */ +@ExperimentalCoroutinesApi +@FlowPreview +fun CoroutineScope.createSynchronousController( + + /** + * The initial [State] for the internal state machine. + */ + initialState: State, + /** + * See [Reducer]. + */ + reducer: Reducer = { _, previousState -> previousState }, + + /** + * See [Transformer]. + */ + actionsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + /** + * Used for [ControllerLog] and as [CoroutineName] for the internal state machine. + */ + tag: String = defaultTag(), + /** + * Log configuration for [ControllerEvent]s. See [ControllerLog]. + */ + controllerLog: ControllerLog = ControllerLog.default, + + /** + * When the internal state machine [Flow] should be started. See [ControllerStart]. + */ + controllerStart: ControllerStart = ControllerStart.Lazy, + + /** + * Override to launch the internal state machine [Flow] in a different [CoroutineDispatcher] + * than the one used in the [CoroutineScope.coroutineContext]. + * + * [Reducer] will run on this [CoroutineDispatcher]. + */ + dispatcher: CoroutineDispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher +): Controller = ControllerImplementation( + scope = this, dispatcher = dispatcher, controllerStart = controllerStart, + + initialState = initialState, mutator = { flowOf(it) }, reducer = reducer, + actionsTransformer = actionsTransformer, + mutationsTransformer = { it }, + statesTransformer = statesTransformer, + + tag = tag, controllerLog = controllerLog +) + /** * A [Mutator] takes an action and transforms it into a [Flow] of [0..n] mutations. * diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt index 4fca80f2..dc82a4bf 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt @@ -35,4 +35,30 @@ internal class ControllerTest { assertEquals(ControllerStart.Lazy, sut.controllerStart) assertEquals(scopeDispatcher, sut.dispatcher) } + + @Test + fun `default parameters of synchronous controller builder`() = runBlockingTest { + val expectedInitialState = 42 + val expectedTag = defaultTag() + val sut = createSynchronousController( + initialState = expectedInitialState, + tag = expectedTag + ) as ControllerImplementation + + assertEquals(this, sut.scope) + assertEquals(expectedInitialState, sut.initialState) + + assertEquals(3, sut.mutator(mockk(), 3).single()) + assertEquals(1, sut.reducer(0, 1)) + + assertEquals(1, sut.actionsTransformer(flowOf(1)).single()) + assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) + assertEquals(3, sut.statesTransformer(flowOf(3)).single()) + + assertEquals(expectedTag, sut.tag) + assertEquals(ControllerLog.default, sut.controllerLog) + + assertEquals(ControllerStart.Lazy, sut.controllerStart) + assertEquals(scopeDispatcher, sut.dispatcher) + } } diff --git a/examples/android-counter/build.gradle.kts b/examples/android-counter/build.gradle.kts index b9b948f9..5b698f51 100644 --- a/examples/android-counter/build.gradle.kts +++ b/examples/android-counter/build.gradle.kts @@ -40,7 +40,7 @@ dependencies { implementation(Libs.flowbinding_android) implementation(Libs.flowbinding_core) implementation(Libs.lifecycle_runtime_ktx) - debugImplementation(Libs.fragment_ktx) + implementation(Libs.fragment_ktx) debugImplementation(Libs.fragment_testing) testImplementation(Libs.coroutines_test_extensions) From 7745500aecb161f8599889b46fd61339f8c27896 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 00:45:27 +0200 Subject: [PATCH 19/31] Update synchronous Controller kdoc --- .../src/main/kotlin/at/florianschuster/control/Controller.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 0bcb9314..8a3ddf60 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -156,8 +156,12 @@ fun CoroutineScope.createController( /** * Creates a [Controller] bound to a [CoroutineScope] where [Action] == [Mutation]. + * * This means that the [Controller] can only deal with synchronous state reductions without * any asynchronous side-effects. + * + * Internally for the state machine that means that each [Action] is simply pushed through + * the [Mutator] as it is and directly reaches the [Reducer]. */ @ExperimentalCoroutinesApi @FlowPreview From b0ee3b8d7cda1bc09635e4f35f190e37756497ac Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 09:21:39 +0200 Subject: [PATCH 20/31] Add syncronous Controller kdoc --- .../at/florianschuster/control/Controller.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 8a3ddf60..10013c42 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -156,12 +156,32 @@ fun CoroutineScope.createController( /** * Creates a [Controller] bound to a [CoroutineScope] where [Action] == [Mutation]. - * * This means that the [Controller] can only deal with synchronous state reductions without * any asynchronous side-effects. * - * Internally for the state machine that means that each [Action] is simply pushed through - * the [Mutator] as it is and directly reaches the [Reducer]. + * Internally - for the state machine - that means that each [Action] is simply pushed through + * the [Mutator] as it is and thus directly reaches the [Reducer]. + * + * ``` + * Action + * ┏━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┓ + * ┃ │ ┃ + * ┃ ┏━━━━━▼━━━━━┓ ┃ + * ┃ ┌───────────▶┃ reducer ┃ ┃ + * ┃ │ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ previous │ ┃ + * ┃ │ state │ new state ┃ + * ┃ │ │ ┃ + * ┃ │ ┏━━━━━▼━━━━━┓ ┃ + * ┃ └────────────┃ state ┃ ┃ + * ┃ ┗━━━━━━━━━━━┛ ┃ + * ┃ │ ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━│━━━━━━━━━━━━━━━━━┛ + * ▼ + * state + * ``` + * + * If the [CoroutineScope] is cancelled, the internal state machine of the [Controller] completes. */ @ExperimentalCoroutinesApi @FlowPreview From 3a96ae21ec886657aee991c0648a5124fe257fb9 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 09:21:52 +0200 Subject: [PATCH 21/31] Update StartTest --- .../src/test/kotlin/at/florianschuster/control/StartTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index fb0b3336..0207745e 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -50,6 +50,8 @@ internal class StartTest { assertFalse(sut.stateJob.isActive) sut.currentState + sut.state + sut.dispatch(1) assertFalse(sut.stateJob.isActive) val started = sut.start() From e5771ef7127d8a8aa6dd2ce3038cdcc34dbf9683 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 11:59:32 +0200 Subject: [PATCH 22/31] Use defaultTag directly in builder tests --- .../at/florianschuster/control/ControllerTest.kt | 12 ++++-------- .../at/florianschuster/control/DefaultTagTest.kt | 2 +- .../florianschuster/control/ManagedControllerTest.kt | 4 +--- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt index dc82a4bf..455343f8 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ControllerTest.kt @@ -13,10 +13,8 @@ internal class ControllerTest { @Test fun `default parameters of controller builder`() = runBlockingTest { val expectedInitialState = 42 - val expectedTag = defaultTag() val sut = createController( - initialState = expectedInitialState, - tag = expectedTag + initialState = expectedInitialState ) as ControllerImplementation assertEquals(this, sut.scope) @@ -29,7 +27,7 @@ internal class ControllerTest { assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) assertEquals(3, sut.statesTransformer(flowOf(3)).single()) - assertEquals(expectedTag, sut.tag) + assertEquals(defaultTag(), sut.tag) assertEquals(ControllerLog.default, sut.controllerLog) assertEquals(ControllerStart.Lazy, sut.controllerStart) @@ -39,10 +37,8 @@ internal class ControllerTest { @Test fun `default parameters of synchronous controller builder`() = runBlockingTest { val expectedInitialState = 42 - val expectedTag = defaultTag() val sut = createSynchronousController( - initialState = expectedInitialState, - tag = expectedTag + initialState = expectedInitialState ) as ControllerImplementation assertEquals(this, sut.scope) @@ -55,7 +51,7 @@ internal class ControllerTest { assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) assertEquals(3, sut.statesTransformer(flowOf(3)).single()) - assertEquals(expectedTag, sut.tag) + assertEquals(defaultTag(), sut.tag) assertEquals(ControllerLog.default, sut.controllerLog) assertEquals(ControllerStart.Lazy, sut.controllerStart) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt index c86b3aaf..e04d50f5 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/DefaultTagTest.kt @@ -16,7 +16,7 @@ internal class DefaultTagTest { } @Test - fun `defaultTag in anonymous class`() { + fun `defaultTag in anonymous object`() { val sut = object { val tag = defaultTag() } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt index 1f9de493..dbdb75c2 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ManagedControllerTest.kt @@ -15,10 +15,8 @@ internal class ManagedControllerTest { @Test fun `default parameters of managed controller builder`() = runBlockingTest { val expectedInitialState = 42 - val expectedTag = defaultTag() val sut = ManagedController( expectedInitialState, - tag = expectedTag, dispatcher = coroutineContext[ContinuationInterceptor] as CoroutineDispatcher ) as ControllerImplementation @@ -32,7 +30,7 @@ internal class ManagedControllerTest { assertEquals(2, sut.mutationsTransformer(flowOf(2)).single()) assertEquals(3, sut.statesTransformer(flowOf(3)).single()) - assertEquals(expectedTag, sut.tag) + assertEquals(defaultTag(), sut.tag) assertEquals(ControllerLog.default, sut.controllerLog) assertEquals(ControllerStart.Managed, sut.controllerStart) From 30c759d2794775df24887fb6c65a9012f06172b3 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 18:54:29 +0200 Subject: [PATCH 23/31] Update Changelog --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e36e9e5..f6db35a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,12 @@ ## `[X.X.X]` - Unreleased - binary compatibility is now verified on each `[build]` & `[publish]`. -- `Controller.stub` is now marked as `@TestOnly` + +## `[0.11.0]` - 2020-XX-XX + +- `CoroutineScope.createController` and `CoroutineScope.createSynchronousController` now accept a custom `ControllerStart` parameter instead of `CoroutineStart`. +- Add `ManagedController`. +- `Controller.stub` is now marked as `@TestOnly`. ## `[0.10.0]` - 2020-05-11 @@ -12,5 +17,5 @@ ## `[0.9.0]` - 2020-05-10 -- `ControllerImplemenation` now uses `MutableStateFlow` instead of `ConflatedBroadCastChannel` internally. +- `ControllerImplementation` now uses `MutableStateFlow` instead of `ConflatedBroadCastChannel` internally. - `Controller.state` emissions are now distinct by default (via `StateFlow`). From 87c09bf03c3db867dca33fecf575806b430017d1 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Fri, 29 May 2020 18:54:35 +0200 Subject: [PATCH 24/31] Remove unused import --- .../src/main/kotlin/at/florianschuster/control/Controller.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 10013c42..4c7f2991 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlin.coroutines.ContinuationInterceptor From d668047262741b681cf5bda4b11a00a71bca8df9 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 11:59:25 +0200 Subject: [PATCH 25/31] Add CompositionController --- buildSrc/src/main/kotlin/Libs.kt | 6 +- .../CounterScreenTest.kt | 10 +-- .../src/main/AndroidManifest.xml | 5 +- .../CompositionController.kt | 81 +++++++++++++++++ .../CounterController.kt | 86 +++++++++---------- .../CounterScreen.kt | 12 +-- .../extensions.kt | 53 ------------ .../src/main/AndroidManifest.xml | 3 +- 8 files changed, 144 insertions(+), 112 deletions(-) create mode 100644 examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt delete mode 100644 examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index 92e630a6..cdc82b21 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -23,14 +23,14 @@ object Libs { /** * https://github.com/reactivecircus/FlowBinding */ - const val flowbinding_android: String = - "io.github.reactivecircus.flowbinding:flowbinding-android:" + + const val flowbinding_core: String = "io.github.reactivecircus.flowbinding:flowbinding-core:" + Versions.io_github_reactivecircus_flowbinding /** * https://github.com/reactivecircus/FlowBinding */ - const val flowbinding_core: String = "io.github.reactivecircus.flowbinding:flowbinding-core:" + + const val flowbinding_android: String = + "io.github.reactivecircus.flowbinding:flowbinding-android:" + Versions.io_github_reactivecircus_flowbinding /** diff --git a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt index 6a3e6dbf..168c3c60 100644 --- a/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt +++ b/examples/android-counter-compose/src/androidTest/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreenTest.kt @@ -20,7 +20,7 @@ internal class CounterScreenTest { @get:Rule val composeTestRule = createComposeRule() - private lateinit var stub: ControllerStub + private lateinit var stub: ControllerStub @Before fun setup() { @@ -38,7 +38,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterController.Action.Increment, stub.dispatchedActions.last()) + assertEquals(CounterAction.Increment, stub.dispatchedActions.last()) } @Test @@ -50,7 +50,7 @@ internal class CounterScreenTest { } // then - assertEquals(CounterController.Action.Decrement, stub.dispatchedActions.last()) + assertEquals(CounterAction.Decrement, stub.dispatchedActions.last()) } @Test @@ -59,7 +59,7 @@ internal class CounterScreenTest { val testValue = 42 // when - stub.emitState(CounterController.State(value = testValue)) + stub.emitState(CounterState(value = testValue)) // then findByText("$testValue").assertIsDisplayed() @@ -68,7 +68,7 @@ internal class CounterScreenTest { @Test fun whenStateOffersNotLoadingProgressBarDoesNotExist() { // when - stub.emitState(CounterController.State(loading = false)) + stub.emitState(CounterState(loading = false)) // then findByTag("progressIndicator").assertDoesNotExist() diff --git a/examples/android-counter-compose/src/main/AndroidManifest.xml b/examples/android-counter-compose/src/main/AndroidManifest.xml index de3a4b63..73de3f7f 100644 --- a/examples/android-counter-compose/src/main/AndroidManifest.xml +++ b/examples/android-counter-compose/src/main/AndroidManifest.xml @@ -12,9 +12,12 @@ android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" tools:ignore="AllowBackup,GoogleAppIndexingWarning"> - + + diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt new file mode 100644 index 00000000..a518a927 --- /dev/null +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt @@ -0,0 +1,81 @@ +package at.florianschuster.control.androidcountercomposeexample + +import androidx.compose.Composable +import androidx.compose.CompositionLifecycleObserver +import androidx.compose.State +import androidx.compose.collectAsState +import at.florianschuster.control.Controller +import at.florianschuster.control.ControllerLog +import at.florianschuster.control.ManagedController +import at.florianschuster.control.Mutator +import at.florianschuster.control.Reducer +import at.florianschuster.control.Transformer +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.emptyFlow + +/** + * Collects values from the [Controller.state] and represents its latest value via + * [androidx.compose.State]. + * + * Every time a new [Controller.state] is emitted, the returned [androidx.compose.State] + * will be updated causing re-composition. + */ +@Composable +internal fun Controller<*, *, S>.collectState(): State { + return state.collectAsState(initial = currentState) +} + +/** + * Creates a [Controller] that can be used inside a composition. + * + * Internally, it implements [CompositionLifecycleObserver] and starts its state machine + * when [CompositionLifecycleObserver.onEnter] is called and cancels it when + * [CompositionLifecycleObserver.onLeave] is called. + */ +@Suppress("FunctionName") +@ExperimentalCoroutinesApi +@FlowPreview +internal fun CompositionController( + initialState: State, + mutator: Mutator = { _ -> emptyFlow() }, + reducer: Reducer = { _, previousState -> previousState }, + + actionsTransformer: Transformer = { it }, + mutationsTransformer: Transformer = { it }, + statesTransformer: Transformer = { it }, + + tag: String = defaultTag(), + controllerLog: ControllerLog = ControllerLog.default, + + dispatcher: CoroutineDispatcher = Dispatchers.Default +): Controller = CompositionLifecycleObserverController( + ManagedController( + initialState, mutator, reducer, + actionsTransformer, mutationsTransformer, statesTransformer, + tag, controllerLog, + dispatcher + ) +) + +private class CompositionLifecycleObserverController( + private val delegate: ManagedController +) : CompositionLifecycleObserver, Controller by delegate { + + override fun onEnter() { + delegate.start() + } + + override fun onLeave() { + delegate.cancel() + } +} + +@Suppress("NOTHING_TO_INLINE") +private inline fun defaultTag(): String { + val stackTrace = Throwable().stackTrace + check(stackTrace.size >= 2) { "Stacktrace didn't have enough elements." } + return stackTrace[1].className.split("$").first().split(".").last() +} \ No newline at end of file diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt index d30ac65c..78be6334 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -1,55 +1,55 @@ package at.florianschuster.control.androidcountercomposeexample +import at.florianschuster.control.Controller import at.florianschuster.control.ControllerLog -import at.florianschuster.control.ManagedController import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -class CounterController( - initialState: State = State() -) : CompositionControllerDelegate { +typealias CounterController = Controller - sealed class Action { - object Increment : Action() - object Decrement : Action() - } +sealed class CounterAction { + object Increment : CounterAction() + object Decrement : CounterAction() +} - sealed class Mutation { - object IncreaseValue : Mutation() - object DecreaseValue : Mutation() - data class SetLoading(val loading: Boolean) : Mutation() - } +sealed class CounterMutation { + object IncreaseValue : CounterMutation() + object DecreaseValue : CounterMutation() + data class SetLoading(val loading: Boolean) : CounterMutation() +} - data class State( - val value: Int = 0, - val loading: Boolean = false - ) +data class CounterState( + val value: Int = 0, + val loading: Boolean = false +) - override val controller = ManagedController( - initialState = initialState, - mutator = { action -> - when (action) { - Action.Increment -> flow { - emit(Mutation.SetLoading(true)) - delay(500) - emit(Mutation.IncreaseValue) - emit(Mutation.SetLoading(false)) - } - Action.Decrement -> flow { - emit(Mutation.SetLoading(true)) - delay(500) - emit(Mutation.DecreaseValue) - emit(Mutation.SetLoading(false)) - } +@Suppress("FunctionName") +internal fun CounterController( + initialState: CounterState = CounterState() +): CounterController = CompositionController( + initialState = initialState, + mutator = { action -> + when (action) { + CounterAction.Increment -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.IncreaseValue) + emit(CounterMutation.SetLoading(false)) } - }, - reducer = { mutation, previousState -> - when (mutation) { - is Mutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) - is Mutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) - is Mutation.SetLoading -> previousState.copy(loading = mutation.loading) + CounterAction.Decrement -> flow { + emit(CounterMutation.SetLoading(true)) + delay(500) + emit(CounterMutation.DecreaseValue) + emit(CounterMutation.SetLoading(false)) } - }, - controllerLog = ControllerLog.Println - ) -} + } + }, + reducer = { mutation, previousState -> + when (mutation) { + is CounterMutation.IncreaseValue -> previousState.copy(value = previousState.value + 1) + is CounterMutation.DecreaseValue -> previousState.copy(value = previousState.value - 1) + is CounterMutation.SetLoading -> previousState.copy(loading = mutation.loading) + } + }, + controllerLog = ControllerLog.Println +) \ No newline at end of file diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt index e43d594f..b2eb66b2 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterScreen.kt @@ -30,15 +30,15 @@ internal fun CounterScreen( @Composable private fun CounterComponent( - counterState: CounterController.State, - dispatch: (CounterController.Action) -> Unit = {} + counterState: CounterState, + dispatch: (CounterAction) -> Unit = {} ) { Stack(modifier = Modifier.fillMaxSize()) { Row( modifier = Modifier.fillMaxWidth().gravity(Alignment.Center), horizontalArrangement = Arrangement.SpaceEvenly ) { - TextButton(onClick = { dispatch(CounterController.Action.Decrement) }) { + TextButton(onClick = { dispatch(CounterAction.Decrement) }) { Text(text = "-", style = MaterialTheme.typography.h4) } Text( @@ -47,7 +47,7 @@ private fun CounterComponent( style = MaterialTheme.typography.h3, modifier = Modifier.tag("valueText") ) - TextButton(onClick = { dispatch(CounterController.Action.Increment) }) { + TextButton(onClick = { dispatch(CounterAction.Increment) }) { Text(text = "+", style = MaterialTheme.typography.h4) } } @@ -66,7 +66,7 @@ private fun CounterComponent( @Composable private fun CounterComponentPreview() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterComponent(CounterController.State(value = -1, loading = false)) + CounterComponent(CounterState(value = -1, loading = false)) } } @@ -74,6 +74,6 @@ private fun CounterComponentPreview() { @Composable private fun CounterComponentPreviewLoading() { MaterialTheme(colors = AppColors.currentColorPalette) { - CounterComponent(CounterController.State(value = 1, loading = true)) + CounterComponent(CounterState(value = 1, loading = true)) } } diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt deleted file mode 100644 index c6296449..00000000 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/extensions.kt +++ /dev/null @@ -1,53 +0,0 @@ -package at.florianschuster.control.androidcountercomposeexample - -import androidx.compose.Composable -import androidx.compose.CompositionLifecycleObserver -import androidx.compose.State -import androidx.compose.collectAsState -import at.florianschuster.control.Controller -import at.florianschuster.control.ManagedController -import kotlinx.coroutines.flow.Flow - -/** - * Collects values from the [Controller.state] and represents its latest value via - * [androidx.compose.State]. - * - * Every time a new [Controller.state] is emitted, the returned [androidx.compose.State] - * will be updated causing re-composition. - */ -@Composable -internal fun Controller<*, *, S>.collectState(): State { - return state.collectAsState(initial = currentState) -} - -/** - * A [Controller] delegate that implements [CompositionLifecycleObserver]. - * - * The state machine of [controller] is started once [CompositionControllerDelegate] is used in a - * composition and cancelled once [CompositionControllerDelegate] is no longer used. - */ -interface CompositionControllerDelegate : - Controller, CompositionLifecycleObserver { - - val controller: ManagedController - - // region Controller - - override fun dispatch(action: Action) = controller.dispatch(action) - override val currentState: State get() = controller.currentState - override val state: Flow get() = controller.state - - // endregion - - // region CompositionLifecycleObserver - - override fun onEnter() { - controller.start() - } - - override fun onLeave() { - controller.cancel() - } - - // endregion -} \ No newline at end of file diff --git a/examples/android-counter/src/main/AndroidManifest.xml b/examples/android-counter/src/main/AndroidManifest.xml index a8d04f0a..c94aba2f 100644 --- a/examples/android-counter/src/main/AndroidManifest.xml +++ b/examples/android-counter/src/main/AndroidManifest.xml @@ -15,8 +15,9 @@ + - + From 4c6a02091c4b37309d7349996d5b0ec5cedbc963 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 13:51:48 +0200 Subject: [PATCH 26/31] Update CompositionController kdoc --- .../androidcountercomposeexample/CompositionController.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt index a518a927..44521317 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CompositionController.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.flow.emptyFlow * [androidx.compose.State]. * * Every time a new [Controller.state] is emitted, the returned [androidx.compose.State] - * will be updated causing re-composition. + * will be updated causing re-composition of every [androidx.compose.State.value] usage. */ @Composable internal fun Controller<*, *, S>.collectState(): State { @@ -31,9 +31,9 @@ internal fun Controller<*, *, S>.collectState(): State { /** * Creates a [Controller] that can be used inside a composition. * - * Internally, it implements [CompositionLifecycleObserver] and starts its state machine - * when [CompositionLifecycleObserver.onEnter] is called and cancels it when - * [CompositionLifecycleObserver.onLeave] is called. + * Internally, a [ManagedController] is created that uses [CompositionLifecycleObserver] + * to start its state machine when [CompositionLifecycleObserver.onEnter] is called and + * cancels it when [CompositionLifecycleObserver.onLeave] is called. */ @Suppress("FunctionName") @ExperimentalCoroutinesApi From f5d005030e9283ec10b56d5beb5dd6a2c7cc7332 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 17:34:03 +0200 Subject: [PATCH 27/31] Compose counter types are now internal --- .../androidcountercomposeexample/CounterController.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt index 78be6334..e9c3e337 100644 --- a/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt +++ b/examples/android-counter-compose/src/main/kotlin/at/florianschuster/control/androidcountercomposeexample/CounterController.kt @@ -5,20 +5,20 @@ import at.florianschuster.control.ControllerLog import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -typealias CounterController = Controller +internal typealias CounterController = Controller -sealed class CounterAction { +internal sealed class CounterAction { object Increment : CounterAction() object Decrement : CounterAction() } -sealed class CounterMutation { +internal sealed class CounterMutation { object IncreaseValue : CounterMutation() object DecreaseValue : CounterMutation() data class SetLoading(val loading: Boolean) : CounterMutation() } -data class CounterState( +internal data class CounterState( val value: Int = 0, val loading: Boolean = false ) From 1e956c0c1e26fd94ab4d11afbeaea1c5b534335d Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 17:47:16 +0200 Subject: [PATCH 28/31] Rename lambda scopes to context --- control-core/api/control-core.api | 4 +-- .../at/florianschuster/control/Controller.kt | 8 ++--- .../florianschuster/control/implementation.kt | 32 +++++++++---------- .../kotlin/at/florianschuster/control/log.kt | 10 +++--- .../control/ImplementationTest.kt | 4 +-- .../at/florianschuster/control/LogTest.kt | 7 ++-- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/control-core/api/control-core.api b/control-core/api/control-core.api index 70f51d03..2068d064 100644 --- a/control-core/api/control-core.api +++ b/control-core/api/control-core.api @@ -85,7 +85,7 @@ public final class at/florianschuster/control/ExtensionsKt { public static synthetic fun takeUntil$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } -public abstract interface class at/florianschuster/control/LoggerScope { +public abstract interface class at/florianschuster/control/LoggerContext { public abstract fun getEvent ()Lat/florianschuster/control/ControllerEvent; } @@ -99,7 +99,7 @@ public final class at/florianschuster/control/ManagedControllerKt { public static synthetic fun ManagedController$default (Ljava/lang/Object;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ljava/lang/String;Lat/florianschuster/control/ControllerLog;Lkotlinx/coroutines/CoroutineDispatcher;ILjava/lang/Object;)Lat/florianschuster/control/ManagedController; } -public abstract interface class at/florianschuster/control/MutatorScope { +public abstract interface class at/florianschuster/control/MutatorContext { public abstract fun getActions ()Lkotlinx/coroutines/flow/Flow; public abstract fun getCurrentState ()Ljava/lang/Object; } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt index 4c7f2991..70fc9cfb 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/Controller.kt @@ -262,14 +262,12 @@ fun CoroutineScope.createSynchronousController( * } * ``` */ -typealias Mutator = MutatorScope.( - action: Action -) -> Flow +typealias Mutator = MutatorContext.(action: Action) -> Flow /** - * The [MutatorScope] provides access to the [currentState] and the [actions] [Flow] in a [Mutator]. + * The [MutatorContext] provides access to the [currentState] and the [actions] [Flow] in a [Mutator]. */ -interface MutatorScope { +interface MutatorContext { /** * A generated property, thus always providing the current [State] when accessed. diff --git a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt index a28aab86..d09f06aa 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/implementation.kt @@ -27,20 +27,20 @@ import kotlinx.coroutines.launch @ExperimentalCoroutinesApi @FlowPreview internal class ControllerImplementation( - internal val scope: CoroutineScope, - internal val dispatcher: CoroutineDispatcher, - internal val controllerStart: ControllerStart, + val scope: CoroutineScope, + val dispatcher: CoroutineDispatcher, + val controllerStart: ControllerStart, - internal val initialState: State, - internal val mutator: Mutator, - internal val reducer: Reducer, + val initialState: State, + val mutator: Mutator, + val reducer: Reducer, - internal val actionsTransformer: Transformer, - internal val mutationsTransformer: Transformer, - internal val statesTransformer: Transformer, + val actionsTransformer: Transformer, + val mutationsTransformer: Transformer, + val statesTransformer: Transformer, - internal val tag: String, - internal val controllerLog: ControllerLog + val tag: String, + val controllerLog: ControllerLog ) : ManagedController { // region state machine @@ -54,10 +54,10 @@ internal class ControllerImplementation( ) { val actionFlow: Flow = actionsTransformer(actionChannel.asFlow()) - val mutatorScope = mutatorScope({ currentState }, actionFlow) + val mutatorContext = createMutatorContext({ currentState }, actionFlow) val mutationFlow: Flow = actionFlow.flatMapMerge { action -> controllerLog.log(ControllerEvent.Action(tag, action.toString())) - mutatorScope.mutator(action).catch { cause -> + mutatorContext.mutator(action).catch { cause -> val error = ControllerError.Mutate(tag, "$action", cause) controllerLog.log(ControllerEvent.Error(tag, error)) throw error @@ -141,11 +141,11 @@ internal class ControllerImplementation( } companion object { - fun mutatorScope( + fun createMutatorContext( stateAccessor: () -> State, actionFlow: Flow - ): MutatorScope = object : - MutatorScope { + ): MutatorContext = object : + MutatorContext { override val currentState: State get() = stateAccessor() override val actions: Flow = actionFlow } diff --git a/control-core/src/main/kotlin/at/florianschuster/control/log.kt b/control-core/src/main/kotlin/at/florianschuster/control/log.kt index efc4718c..7a0ef85c 100644 --- a/control-core/src/main/kotlin/at/florianschuster/control/log.kt +++ b/control-core/src/main/kotlin/at/florianschuster/control/log.kt @@ -3,12 +3,12 @@ package at.florianschuster.control /** * A logger used by [ControllerLog] to log [ControllerEvent]'s. */ -typealias Logger = LoggerScope.(message: String) -> Unit +typealias Logger = LoggerContext.(message: String) -> Unit /** - * The scope of a [Logger]. Contains the [ControllerEvent] that is being logged. + * The context of a [Logger]. Contains the [ControllerEvent] that is being logged. */ -interface LoggerScope { +interface LoggerContext { val event: ControllerEvent } @@ -46,10 +46,10 @@ sealed class ControllerLog { } } -internal fun loggerScope(event: ControllerEvent) = object : LoggerScope { +internal fun createLoggerContext(event: ControllerEvent) = object : LoggerContext { override val event: ControllerEvent = event } internal fun ControllerLog.log(event: ControllerEvent) { - logger?.invoke(loggerScope(event), event.toString()) + logger?.invoke(createLoggerContext(event), event.toString()) } diff --git a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt index 364cd2cd..2e14ec90 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/ImplementationTest.kt @@ -181,10 +181,10 @@ internal class ImplementationTest { } @Test - fun `MutatorScope is built correctly`() { + fun `MutatorContext is built correctly`() { val stateAccessor = { 1 } val actions = flowOf(1) - val sut = ControllerImplementation.mutatorScope(stateAccessor, actions) + val sut = ControllerImplementation.createMutatorContext(stateAccessor, actions) assertEquals(stateAccessor(), sut.currentState) assertEquals(actions, sut.actions) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt index d5767cfe..e0d6ecfc 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/LogTest.kt @@ -62,10 +62,9 @@ internal class LogTest { } @Test - fun `LoggerScope factory function`() { - val scope = loggerScope(CreatedEvent) - - assertEquals(CreatedEvent, scope.event) + fun `LoggerContext factory function`() { + val sut = createLoggerContext(CreatedEvent) + assertEquals(CreatedEvent, sut.event) } companion object { From d1ccb3099be10f55f95543a120a030faa965f7fd Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 17:47:37 +0200 Subject: [PATCH 29/31] Add additional start tests --- .../at/florianschuster/control/StartTest.kt | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt index 0207745e..7981b875 100644 --- a/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt +++ b/control-core/src/test/kotlin/at/florianschuster/control/StartTest.kt @@ -23,7 +23,6 @@ internal class StartTest { fun `default start mode`() { val scope = TestCoroutineScope(Job()) val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Immediately) - assertTrue(sut.stateJob.isActive) scope.cancel() @@ -34,8 +33,21 @@ internal class StartTest { fun `lazy start mode`() { val scope = TestCoroutineScope(Job()) val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) + assertFalse(sut.stateJob.isActive) + + sut.currentState + assertTrue(sut.stateJob.isActive) + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `lazy start mode with currentState`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) assertFalse(sut.stateJob.isActive) + sut.currentState assertTrue(sut.stateJob.isActive) @@ -43,12 +55,38 @@ internal class StartTest { assertFalse(sut.stateJob.isActive) } + @Test + fun `lazy start mode with state`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) + assertFalse(sut.stateJob.isActive) + + sut.state + assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + + @Test + fun `lazy start mode with dispatch`() { + val scope = TestCoroutineScope(Job()) + val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Lazy) + assertFalse(sut.stateJob.isActive) + + sut.dispatch(1) + assertTrue(sut.stateJob.isActive) + + scope.cancel() + assertFalse(sut.stateJob.isActive) + } + @Test fun `managed start mode`() { val scope = TestCoroutineScope(Job()) val sut = scope.createSimpleCounterController(controllerStart = ControllerStart.Managed) - assertFalse(sut.stateJob.isActive) + sut.currentState sut.state sut.dispatch(1) @@ -67,8 +105,8 @@ internal class StartTest { val sut = TestCoroutineScope().createSimpleCounterController( controllerStart = ControllerStart.Managed ) - assertFalse(sut.stateJob.isActive) + sut.start() val started = sut.start() assertFalse(started) @@ -80,15 +118,16 @@ internal class StartTest { val sut = TestCoroutineScope().createSimpleCounterController( controllerStart = ControllerStart.Managed ) + assertFalse(sut.stateJob.isActive) val started = sut.start() assertTrue(sut.stateJob.isActive) assertTrue(started) sut.dispatch(42) - - sut.cancel() + val lastState = sut.cancel() assertFalse(sut.stateJob.isActive) + assertEquals(42, lastState) } private fun CoroutineScope.createSimpleCounterController( @@ -99,7 +138,7 @@ internal class StartTest { controllerStart = controllerStart, initialState = 0, mutator = { flowOf(it) }, - reducer = { _, previousState -> previousState }, + reducer = { mutation, previousState -> previousState + mutation }, actionsTransformer = { it }, mutationsTransformer = { it }, statesTransformer = { it }, From a6311a256eecee9db7fd18ff447cc6f54fea4d24 Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 18:12:33 +0200 Subject: [PATCH 30/31] Remove blank lines --- examples/android-counter-compose/build.gradle.kts | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/android-counter-compose/build.gradle.kts b/examples/android-counter-compose/build.gradle.kts index eb38abc8..87d3a634 100644 --- a/examples/android-counter-compose/build.gradle.kts +++ b/examples/android-counter-compose/build.gradle.kts @@ -29,11 +29,9 @@ android { sourceSets["test"].java.srcDir("src/test/kotlin") sourceSets["androidTest"].java.srcDir("src/androidTest/kotlin") sourceSets["debug"].java.srcDir("src/debug/kotlin") - buildFeatures { compose = true } - composeOptions { kotlinCompilerVersion = "1.3.70-dev-withExperimentalGoogleExtensions-20200424" kotlinCompilerExtensionVersion = Versions.androidx_ui From 269545f5dd63291ef4200a99be2a3e138d855c6b Mon Sep 17 00:00:00 2001 From: Florian Schuster Date: Sat, 30 May 2020 18:46:12 +0200 Subject: [PATCH 31/31] Update Changelog --- CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6db35a1..0ac409ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,15 @@ # changelog -## `[X.X.X]` - Unreleased +## `[1.0.0]` - Unreleased -- binary compatibility is now verified on each `[build]` & `[publish]`. +- binary compatibility will now be verified on every release. -## `[0.11.0]` - 2020-XX-XX +## `[0.11.0]` - 2020-05-30 - `CoroutineScope.createController` and `CoroutineScope.createSynchronousController` now accept a custom `ControllerStart` parameter instead of `CoroutineStart`. - Add `ManagedController`. - `Controller.stub` is now marked as `@TestOnly`. +- binary compatibility is now verified on each `[build]` & `[publish]`. ## `[0.10.0]` - 2020-05-11