From f37ff9c17967ccc2908972f2fc437f2aaf23e538 Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Tue, 31 May 2022 11:53:32 +0100 Subject: [PATCH 01/20] Docs: updated domain model (#50) * docs: updated domain model * Updated model --- img/DomainModel.png | Bin 31642 -> 41787 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/img/DomainModel.png b/img/DomainModel.png index f8422bda6810790d5e6deddd9264f7f87378958d..fe7ced93d6a031f713731570569e2c3c3990dd7e 100644 GIT binary patch literal 41787 zcmd42byOX}7be)ehkMZAfdqGV50C^6!JXhvfZ+15K+q5f?gV#tc(}W}ySu}_{APA% z_nev8+5KbB_Bs8ey1S~ntLpZ5zk9!*N(wRec_WzC12W5ExaHjzPNB{ub!-^og0Pvj+0QL<4Kp-9f2!g1>+{f?!F|a};r;qYdNZSY$sC2Kc>7!sR zLx7L(#Z_GA51)LTNVb}HUs}8vY$NSaRlgrG!ry)ED3Xvp%(5lH#o0;fs_|d^VbpGs zXF^+s{QVRY^C!5^k5%mqqq@sx#zkPYF`P}pzEWhNIyhN}C?-plu4yv$=T40Ggi|};8+*7gj_8OB9Ewj)|g33lMlQI zR|B?0@E%Va^crRGCFftE-6E=&G@-=5Dwf4=h_gZk@bTh@*tbsQH@noj*2HH~fuo3ADkTWz0jqlb<2B{-tDh+2QJnK<* zGI5)X%(|zM9_o|3Ro?!|XZorLq}RJW@h#~0d2!-sHy!pZ=ZlC-1kagSf)>OaZJl)i zNNRW-vmFu@EklWA1klLfd?o6pdiE1pMdc9Wtg0d=)m5?_Zb9Plw$Z^+@jawBsmuL7 zBt(~%A!KbRTwV9d7)zzdFGRQ8i@%(m*~Lgy>GRlPBYW{W{AlH4MYyoL_l8* zt^{(H6z%4w@Qsk@ad!j;Kq8Pih9>9;NlqcmT;c%|GshgPrGTKU&vcQ}SzFlQ5>Xhv zbHuV!aFCTDtlR~rK)b)kV5N;%Hh(8!DJYQ7x3Dddtt z9~9eOcpv`kK^ed{-nqV_KIt^vHf`%wo}Ao4m^o(70G7)Q<~(L$<`Klk6%xJD}AX*4zE3ELCV9)rnmtUAxB1+k#w3mWq>bi6l8tY2Rfvx2=%Vtg*J}ZR3q$jIC^k8^cLm8 zwqBxyIs`=b)y%S3FcJR0O|zW28LRxNJ}oTF%9COlP-w4RpS~tAn?|tK9{K(fOuIu^ z^Tp|QU}rpBq*MYKEZ;5?rPu1Muu`K&PVQEl9Ts^Y95B=t6cWVLU(+GxAn zSKS?nM^AN(O6PtrxTpswwu8@p(7o z6d1pdljm#OeJ{EfF2cra+fsI+bsX3};0*uzv(W>}q)988_7n$||0mGgg)_YEUYNf!+VB zHHWkN43d$Zzk>PLU)!VWljl2%55iau@IL(6z?SgDCyM-EvfashhhCGSfU%cufnLc^ ze5!MCtd~g73W#1^H~bS=l0+uYVC=qhKpmOgOy103VHTv+Vs0^9_t9PL3gCL`gpaA} z;7&WuMyXgkjWjSqBkX1{MEmy!2{XI5lhP*dw;{J%h^}|PxmJC__FZ;%XgCQ}Oefrh z*XoV>CJ=xfXPF3ISaaNQp4X^GgDm5|N^h+tG)S-I9oI(MA|tGk73_oTa6&36MjDtfLLjzs0+G%G1>T*f|N`8_l1i7iX6Zy41zl)a7-#P4pd1t%PO(`GajO7Uh?6x+Z8 zla~L(=ldk#L!lK+MfN86Q)4tTjt^1TUzs@BXAkjD%~DBrDO_*ReV2=hk9<>;^?yfm zrYmlOba<0p=^tNN?=gBT-+shmyhVS0M3p)c26xErojRjwQ&5yVz(G=fxy7_v$RI76q(Cv&KtH$>|KPjL9)SB$txMO=;->m6s%S>bAm z;k`{R`*0=RK^plEwpZoq0h4S|}7g9}s$oio3Y|mDik-9BFPVpuyn7jsr&wq)x zO<;Bm@sez0Lg@8dfZ+eq>nzY!G&NOE{1FGtBK?t06Q+ExoVX30ULMDk+Nt=!>KEiL z5Mu5|Rs*U9k{NkmHSQYE92_(Mu^`F-><*k3 zd<^F<0&km3N*J2_@Ke-Cu;2yxAxs3GGT=4+7)o@CSu-~cL@oJV-r{ykuX?osAJsnH z!}0bu<3xptYmWqT`=v~2h52&$Z1|C4*_cvi#1suuvr;j)8~p|bQ&C3XebhJhBp{NH zN(iF`{u*8)C$Sv6YCam^TRz(#&E!f`3_$yU*k@I(qwPv9TOz1cxjYP2slqzeoMH;d zGuB}OE=qH`H3Alw;7LAh!5jKVxCWuR2#V5kKq>N~>Lxh4qb!vWpwf#a@xp|`V45|k zDg9|6WF`+tmo;hyd-z9p09>=*HXZRI*5|ExN{d0kOy5+K%2YOfmiIn^c%Qj_MfTruP*OB^g~ zG=BKpZ+nV+!@sbgo+@Hkrhxe@aQQIR>x*=8alr_u5kV{A%EpFD*%|Zkb_2*NJQgWDW5;LDm3M*16egmCT_jRe@m(nhBNYBw z@{||4xo&vOc@K|FsXSr~S~!A78!R_*#OE%Fm>*BCb~VoYV?Ka+s7nB%lUi6%)xIaA zeQ1Q>n=^6$@0TP=t@&nPSrv?0s-WtTTya1vu!x0S;Rdx&RSFOUd9u_%A$}8{^!l*- z#El|IQL6ajAw9@QyvL~xv8S8hy7X02sx+C0lQ0Y=LJqoA@j>1+O?)&?eB2Dnp2zqNhg8QN#NzYWBu z@2yiaem*vHo8Rpql=w^40n$z)DIk@mXu+n!CnA`&E~nEr!)Ry1pA^<=4ea!7Bgbs817L!-FT`N$HDxocv}>N8uh6XC#-n1}<+OH1^N zL%OS)_vNXrgriz$hMda^uC5e=hKFKjE6(Fv{C#RsKaG7tTWBg70@kQWqdw3E#8Z8F zRO719>PVO>`QwgHm5s;rB-`kIZG!6|tP$tNSPVo3Rr_V{G;7&`yY%eBQCzX4))TS6 z7RY@QtjQvNAf9f~2_#X-I;7KnaPW1GDGOo_;-zpwxZ>)Ms2J+`+`(6r3}+r&j|H2W zW+7m0Mr~39E&pvK2_J-sixg^7e-7O3G?2aQfj4eEeni{TBQf%W@iCaPb24Eay1ntK zb>-yb%WZJrbb8@iyagqoQ}4CLVcebI{5(L{Mfl^!{$tWB*cwTgH?w|a-$TG^a6dQV z&rNs3%p_Nm$B(MFF8m$0C*+MN0!Oj;u*aUi6_+ zcH+hRL7fSE-UifyPXSA9_=Zn$LHVUZzj2^&tC!OX$T^YxN zuc6LEvx;+tRZM%lwV?Ay$R?+4es{6araFg@O+ACft&HO@ki(!;UDAI-+NMqdk`@2e z7%qYa^X54ae20#|X9zT-0p@w?e#^fG4uApMxUP-t{W$16vzjMD;|W-!S?Y7XW!a=4 zoGXyPqlwI(4fCHrIa^}YmXP>38h6SDkJ{hlo%(2XYY2X=S1Z;MGzxm){zb6 z`lIniAYx!Qno7Ctwn}yVEjMwAUPxqW(0UQLV-&h5+ROEWg#^QqBCtBs-MQ!qSh?;^=(~X=fX~g2I)i38Q{(fmxbOUs)`e~4 z=9fL3#+K!mv0TZY4Iuka6SgM|UUYBq-6t$tJp+&ruX_-qPwfzUrJ2U-O`p?5Av4)t zZfQaT2D;qQSb1>~6a=2=FJ3cnq?ua6YKToTzHau{^d^xAR5JRL(0fjZZS4hC_-NiF zqvRJA&1QnrgNu4P)}-`<{EW*0b@(>PwhN6#I#j4_b-&>u3L&Qy)q)O>D(iP#tDbAg zdEc^}S$utXK56>Hn~LV|X_@s@_LIfqp++lfQZN(di`bGEK_7=cK8YNf*}_z9B`2#8#D zo}IB>SFKC(f3+%%YHXcVFG;mvJ-O)FzDw2lk*Q1*Z`prhanPm-%?E0^_BsU2#=KP< z;Ku|vIIMB64Ix3kcT=Q5T2$%(CWm_Us*EefH!_fh?FoPuInTo(;FC_T5~U}gW+}$R zi{s3hW~dfAljsKUq41107oIs$>Jj{sdeB-nq4reBBUojkY%dpgsJxsEL0|*xK(rv( zDW@9QcHhT_(B!ax8~|URIvPr_> zX`a)1!}#u2*o}1VFc%t-4<)Q1-Q?M&d2Kbh?XN)^icoM6bS)?nLx&u3)W|g?Ic4dH z%mL?Mc>3h0x{_ZtrfuUL$*_0NUHic}$)TC`=q|bi&+Q=YMsbT-3C1|NEgqE$T}>X9 zquo{%-G>skU?ZfVqJeJO#{Qr`zGF=bO!gV-L^+=^C&tyNTjof)UG3UCs z(Azj`>d6}0wYxa~QRAH$cVhvgZ$wgWT)FNS{v9yq`d4;Y>*l5((ci#+a?P6M=GGc2 zoi(Pvvn7=k?pFK4PaMv(fA_>vdN|@{g3D+E>WP{+om*RW91;GO<_-JA^0IY`G7T?% zEIpnD$}-*(H~>4}+p)Zp#;hy%~fxwJ6H=&XChyjonnf&Q&-qXC@K3!(6 ztr>2Um@)O?;oH**5w&2EWEy~$!40z1mk3__pl)J>{RN+ULBeLhXk_8XT#CvG zhD-wkiD<)RafMmY%9?TVdQ1C0EgtrBE0(JC`DQTQ}C9dM|D6Ej1~Ne9_|Xme|O z#Ni>??wTDvcK%9y?6krg@5ofZ1qfX&6^lUWH$tJ(WyL}qem3%mcSdQo@zgM)2i&g8 zTaIIQ1VwhMnVR}Q8cippW*Sxyy%v7OFPHk10Ze1EjOl@XmC-{;>&SPI4IKFustG6=# zfUTVtTtr-dbF&tX%7?NNqP?I!hgtLe#n`ATt`2O8aM)iY=WF1^IGZYs#+y}r9Sr2w~r-1`hp?-sdvmPKA!*tW&u*B1{Y#KKM!Br-V2IGQTemSaz$zF{Y+U7B&oUS24XT5=D7{?d^ z@n#g*rB#Z1a<-o8WzeSfJ#-TU@tSstum)bcVf~SGZY6P;xNO40p=+|nZ^Caqs1{@; z)h+6&z##K+|JraO;rkcMdZ`Vh9zGU`Ee(5Ossy6Ebulg8?3()I!x`o~vTvhFhckAI z7rN&KcO<@GN(oX>^l#X|;+G$i-`Xhe(POs5?#%vp zuKk%3xGn12#R=Aw`cGV{w>a+uF>843+9W!cDj~g;<-Z73v{E=TUc|#Mi~igxZwsQT z`VN=FDMLqq%qb3xQzHJ?~ z#~-Pwf)jUjf%?Ywc4%BF)zYYl@~@>Stn@N|b&;AM!PtLT0$>p;44@Db^(O$kmnzSdefL3=^T2 z_?rV|O?_>2RFLYpx9ApO6ou(dU*&Y{stp6-La@~ z2R*KrBtF0>pPm5#zE*`ko>W0=&HJh43EO34&=8INMo3cDda>$*vu?Uj&~;{SHNVnd ztf6lMYLQS7D-8FNiULw|f3Piyq2A`zShRpD)mdFYVrUpmYqt>V|= z=Ap$*d~x;jU2G6j7>+o^<#Z_ef;$@mcP@kJgDFN|Hdf~IVT35x5Fl&s z{!sR$M7B`t2_vn>b{ti-?v!rwLJ~-9tR26YUhgqTd*(4X6LBrlY_;-4xlgPeUk=mK zTVB#p03QT>sX!Vg6V`t(D+(PxMyq)h-Jl{#7)2J73$AI|w!I}QMVh^9)iS&ZBRzmk zC6ew114Ox`Q|50+S76`SOXNG?)+*)hPPPevq3Sm6qU0!lg0CzbWPfEM(B&x zJ~jj;qo)Q-GrHQIup%xCNv6AOZf7#d-E_QyaE#lLd@j_q-oO`uSVGc_e}PNS5KsF* zF65mt<_^Fb9OXMe1L!P@D=br$387bj1|aZBHCcQAip#|j8S|q7?$>)Jmi4Gs&|x}q zSzC}1?I9NWP$$mkipsdi#Dr2_s5~3jtu5jrJBK*4AXQM=638A+>AfISvQP~no@ioH zor_1dO6tW>zp$oZv;k-yX_o;S4O}EOtrzWrR42HKp&XJy%+jbQLoVOt4ZWk9?}qaS z73IRc;GZ@1Es+onk_{U;ttCSv;dlAexf~oDL{wLL?FmKQR)}49-WOJ!eCaVwdBMESeU_UcE8^6BcSUki_$4Aw3c$oq+`iE&RXbQUG> zH~%~L-s|fV(JVBJQg5er_=c7-*87s#hWp5$#3LjLpN^`h`)CM%Zf<0_*H_`YK_l5! z8;t1lt5X%tHnoeJ-p(Pe43$<7DjY+NS36gcSvc=@uc~2-PiZsw6NYIPzeq29BW@du z__L4BixvoVnFkf_)44OI6s1&^5(^^<@oU>070%BkMCrj}T{+>6ox2YsYJIg+eX%Gk zHgiKGneTZ>?c#Bj(_j2m&j6j)UNulKP(|Y(8T{89Iv6ItyrZ*^>n@&G-#Y_To%8Ns zboQ#&DxzAA6?a+lX;Y7`KFFnzDJ@De)xQB$A8XXp*SrK;Gpa$z6>V;VBcr(dos=u4 z4n*iDSkYb%e`7)mkryMO*7Zq8ud4OvyVs*!J*i>lyRbd^o$Wq2&LyIsiFL#g6Ft0w zaf0Y@6c9-!^P)Ddai8z3gILHhNabaJ<#Uf|486Y+4r-~S@iHIEq~Yi_|N2U01@5$S z5SRZl`6uw4&J+k5fA_RuzXl)K3$r#K2U?MY}DBEOC38+i{q&_tCpvIWIJ!H+g zgJT+rn{x{(s8%}Xx_`TJWHoec>1lX6s%5H7E{PEt6@BdVtAm;*NPSHIH&PnC<7+R1 zPV_!{JML>=x8E=h?~mRAx!ZTPa3%=o#D5vg0v`ytp<@Is4(QTAHz$)Qml57`J+{o|PPblbFNWRVz-i+(mGJom=TCrQnZfq)7`E z%8Jw*r2L38+Zh(@L2uzdk@b9mZ?oHMn!MKM&e1b$6_$*+2Y2r1HR^v5CgRQ>^sbMS zhkfW$K=oIK2qa;Ymtp9Nl%zSyD{tY1t{(oa*xzF2WRzy398H|Yd@?~K%A6?p8h#0| zz&c`lf0e`Q^o_QNHdWcvK;K^fg&!16{1)MW_l$4U+*~m!LPUc;mzkEEhl)aj&eN5p z#-BCGF1ctZk4&9q^?YJo)Qo)6XE;YEKmJ#JGZ1(n{el? zyholjZzaJ~rpWL=(Zk1otJD@6;k)3}vJ@flUvf@~wAJ6Z?0yS_5om1pqwkZMdsOgI zYcNac+lo~|3qkG@<+;;4XOQnbr*?r$+GI{(Rcf|%BHcc>$PomVo_PMfn#=+QJTA8N z=0NNTmU1Y;JWo};;APXDYPC0A+4sg2%q%SV&iZpVAKp)~C>%&ZF$|jbK|pz{)V;vK z#j~yTjMgRheIsbAT9MnUCqwv3-b%g2r>zFcC6IgjF6YO-?N<^Wbu+3?IT#lexbggm ziBO|s{d1Gr^oKBktDGcU@8RoZK8CSV;=z6cDQo+YHWsIjr_FT-Vph@6FiHkfrS{rP zf1jJ6q^a0H)UnYpHal6s#0lm1hjw2Zl`nGblCM}E)4us6cThgSbN-7OaUj@9b==*p zAzd8g57~j+J>r-gyXO%DYWd@01ZpitV^7A_>(2#oo~bAU;(yV*GFtvvl9cpynG~C9 zlYZLrZZ0U=Ouq^4SLTw3lOT4JrZ^{^e4{(P4KXH;WH!vbmJ`8>FW4s6JDDmM9Mi2U z7_y3JfaCh}WIAnq`kjA}~&+5r}7f|5sKd{a4hiv2?E$oRHP zaD)Uq(pxB{+H0>)n7a^XA8uo>?0=7Q3D<%+2bauXz_W3JHY$t!k9|W*G*xA}3Jt(s z$Dt;jO)gL42`0J85S;!_qaN5+e)9c>w`G6T=9u>$5{RA8x(2%R${e23kykFEeH(j=N6b~a4qjKDZYOc()OO&gDbiZyS-Ru+@ zJn}=(F>T%WZbwQ7)whY7PQ^72j?cz}Q%3JzI@G;2&FRvdZHXU}+NIO5*~e?f$2CgR z*;B=DgM1Lkr`J7#X$$jHFVgK-z9gzC89KtD)$%?Rm7vN#c>}g~??Fl8RIP55$iqVf zkcgE64fW+iU{&l!b4WGoh1iMfAy13{e$9I!lo(H_2zY;elR8!NIJ?P;P}``N_L^vZ zaTpP{H|ncuAKe~!r7x-?Vo&3tr5|D4#RyCC2Wdy%DIGI#gV*AO)A5RVdI*Xc)%y<+ z{2w2yzfr0Eoty)xsUiACX&t+M-p!q$q-M_NFNglic0Ft12IDgcsA!S|Vq+`mEt0;E z-NbJz+Jd4LsvTR&8Z7DRj)Qv~J3&zbe?Wydz21VO`;`K!kvq4&`o}hUs7z*FC0>}w zqU#+*mO|48&d!K`4rH*t;Jb4nJE>;sP@Ho+^8zQI;hiSa>Ee9gC8F#HSHL z7!N5?Z>zA+Krx73XrwBE^D_1+)iTh`5Lbd#1JP-if#$v_~Jt%s6c_z zpWYzt7R8LortUE{MBr@IWx#Mvu7jrMZ4tEpi`p8|3MD@F-dm8WjC(@|EvFg@7KY1s zqb(m_&_}%ELGB1mcq1scs;h!q5Bg&*FS*Mf#6tOjXDoqcqx>jjlti=BGDf3Rz?hKaP`s>u{Q( zRxA~m{z+3TTMj{`fV!+}GK|z-e~>P`@CleUnEk386HLK^4d1#99xf7P)IhPP-Vd&s zp{#fWW{|+1VN>w4JvPbB0J733l05>2IGu*U4+SrWqH6y>CKx#D%_LOw@FXE0=1A~` zMK}-kNCt7Hb0FL8s$O%SUG`oH!`}(J{^?ElG-b0LX6iK*)>`%Lr|tyb*S6N~#xm^H z9#->?>aC!E=*Fv2b;QBLENOx0LGk1=ZahKE*K}SEq2_2tbCYnIjxz5jv-854Xc30( zZSZYL<3-QBde_ESoX8H={b0|ALn4Ip_Up-{=P9NvWoCGyRYF7>5)$Fim2Fz69}+=4 z+fxyZ#z*lk3Aauj3%kl}^iO)|UPVYXtu}X|etl~y+5@g}CZ=T`<>?uoXon9kIX3}Z zN6y^KGzEs0&juNww5j?eV!y8}Q>QVXYHZgo4!>nc`hGlbQ%xaZXSuTZS1=rvb8Bz-g^IVw!VJFd&I~p1q zx5^wiK=RA%4?eq`hIQ<6@+?Skm5|hTw>CPK#ag=*kSn&0D|5yd3bjyw8nXwJxa5{f zzNp>_@eBusuLiVm^z{bxIr{#YfGm&Jkf>9Z_qlRh5#B}3&!1@>I*=VnHZK3YfybPg z#;hq}UKNJ;e-j*h`x=BSldj&%cYcSrDrS?E`e%8Dr3fzI31U$ko%Q|^lHb`?8ZM$= zxvVc!x=W|}%{G6=Y6Igz=ax9m8a(Y@I4U&oW zN=!+^0pK3^Ty+0`?Q3Q%Ed#Zq)}6Rcvu_0JEh5$=x~&HVOTbp{AJEhljg_~V@3l?@?ueJUHDQq%4;qETD- zus@M-`4b%^m{)fHskVMmi08P(nU;G1Ym&t%lxY8RoVBCdEc#SlHBueQ%DK(J(r_J_ zoM=a*@yYU3i(eq=_`?@|R6p(#8`->&rC-ZZL(B8yZ;Pi_Yg(Qrg93vd@YCU(s`g$n7M1G~k^ch8^<$cpW!jX|uU2OWFS$Md+Zj^8u>R%fceVKU z1{bu`xI8L?F--l=aZX>$;11$(qo&#W#7esn+{%z&QsABmQ@~ zkUfDC()o#&z+8Z!{qvi}67}>EVz2jcbj$)7#<|yFiQpceTLWzS2G;slK^aE|ECLae z1U_{cr=#G^XD?s`gkL9q)YTs zYglx9HrDa5mHAbc!e?W*Uj>Yq7VZ?wF#j;-iZB(!*5a@Co(K2z!v#yzK{+{(1H8<*QcU+F%llCgA_wp)hv%5B-Iq&IE1H;nDt=mj^U;A*w^VeQ=?vK6$RCnmr z#O%(WBQ1Sx^vb$VEG^?5W#`-Rh zlJfcs3`U^Xl|(cHs#;fRCfRA0HF8hqdX=7Rxr1t1ST+(kXx84`JUeX$tof%D%gt1Z za1GCZP0Veh4JL#qHqpXp&&N9qyerUPZ|=n6b8|+qoj~cybe#)b5mrn*LuHpbEGL#J z`eZX!%F(=TTdN<#RPsr*uDb~RyBzx*l4GJ6hA7cHg4scPduGVTNIQ=!J>Cb>rUJjD z-?p{O8%y$qM7&qF$(RkP%=%ZyoU6pEx-abk-&)4cEHwcXeFa;_sr@f~P*9u?-xyY)KWmS`7A82*6F z&P_ActNr;CagAH^lxYJwhJqOf|t5{G2pwr}6${YKn@`8Di) zEt%YEdOu|RT?NzZZukHCXTf{I+iPX7R?Vj?`WZNnec~Jl7a^1g#*0!i2(6lO-jmJw zQ?-GmTq;Ia($1r3ms{9%WE|2}g#{=Wd3D2|>?f(&(;a>>m3eqg`TiUhj7S*Hi`)mh z-U%=EtXu|}pP)yq;rR-A0i`$VH_Ep_@T0;@)i`>TGcq_NbILJo45peetU`+(0e!es ztLi4!aS=navcc+5#$$Zw(8X#lg3YoY9KJXMRgJCE3rw$r>?B1Yo1olPWwd{YyR}+*#{}C7-nM zMmgK<7TZ>AA1s;4#@0B$oYJ5zyOb$`H0Xr9?rZC14{N9i@@9gf*^c<0#7}-}GhIPG zPU$tCZzJU3XhRX>@ofUfa#eyg52aUEVu^G^K0f@SCtjmoqalc`sM~88x<_hGD_)+P zR?$G_`=O3r65^$DnQuo5R9Q^Z=lh_t%XnSn+}zC!lC)Yl@zZRbyj+*sAMoZ-75+6B z%n=@-f$3*Di)x0Sh(_36h_eRRiE@GC_}@jp-;|M=2NY;>2d7|v+zGk5g5F(35BF-U z=!yEAC;Y8tYY zAdjEUr)5cNFbnvI4U<)UStq&3-4?MG@>o^Niu~qhtoNC+^L?|i6CBOI<@a9&SII;u z)|5W8yBl~Ca@oDn_)pStYmD!n&&4LnOtfewI~*6fCK%8tP0Ldkz>icNyUJm#ZBT-{ zAvAH|V8pmvh;lpmc`-TdkgWk*pSlg|T=i_X-5k$()!TwLz^jk11mDp_weA@m7o~)PyIW;G0OV1R_hWI!h9`Ei{*mF9phgG^J<%{R7oWj|u*XEkE4N7#~!F%Yc5K^UocoR45%$Q9mZOPE;= zwHFAUiP(s{-@kW9qY2`6h1sZD-(dBB){Xexe8kZf=LtD_0RzHuDAKtuU@V^Yj z-AV+*9}4slF>t!EXbMuuRg{`%MCSs}h^KeE#r39bUHQVIr=S?=3&El_kuSU^GXgCEP!onCLRGNoft0RjE>B7XOhe6FPb6jWt1K zt%WR+xWCY#ad8E-Ab3VtNf_0`qPf|h9-IG~bk0U$LXD9I%kA+!yiFsu&3=Y2nZmV) z7qQ*X!V-4-;}HonZd}$>!bGn5*ZhU0;|x&cPOKBC5QuPQAG4LprgkOMNJNsJD*e&E z^`WC!o6h*TxsCI|gf8;vD6FwV(iGRE8AVCHtaD(_Q|@UZKAb3lzsoWG9HyDHE97-k zyI}mU?-JNF<>qCj3B`gx|DRNn2}Zt0eq#vO5`z%_>+{tW@p%MAcDHt#-y`r=)^Q#A ztt7hBeGltGi{?(S6#5ApKi?ymxr1qUHobb4Xw@j+?-go#C7i_%`|EwS6m9DD$@3l=p;d#&7k%#y-rVX(iN7@-W{L}` zvKBqp;dc`r6tvCxX4#sT3oP4VoBM8W)Pw&)1$CIVCz$?Mk!}H%S3L871dM{z76uoh zMQhe<4KP}8j-KC63M=Wr^2+%2vs1L~ZVjbH-RTd5(ys+5>`psF^hv!xqjLt>>J904u^?zNi`n%G%Bwp&TTgeBJ|5W?Gqv8f= zG8#tn|41JTmJj*(+m}Y_Jc{`LF#G>E?RCEMGIW9qs0)VYVgG1l0C))hrElKa{4e1buPM$ACtnC z2s`K-sHkN1aM)~6tPo{y(2)G?klu$WvU7on8qN|Fk4$9lfN5D`wBw>SQ#iXB(gADI-&en5_Ao(;{v*@oO}bYzx^mtDp^+{Xs$iaT?@x&<`=FJbE_N+oTT0= z-Zf%@Ud8{@+=k`z+G`USFA?lg=*@Uy+6u(I`^L-0pIPv~k$hi5(_3 zxM^)!s?xiy?UaNhSF2mDe+R`q@+69~xXDX+rA{%FD3n_j}-JsQ z&woR{lghM^fQ+78ZbGE{`|Q6y-(m3s_wAcYI^l!W_gz0{ML+&ab6Hmw*2C)^!oU;8 zq^R{IeW1;Mw=)2ye9ysI?PVX9y~5d+nIl#1^E>39_?Mb%K><9?QIk@j3m)E`z+V!F|VsU((iB7jWRx?t2v3D8hMPmgD!(C>W0%}0dW z;!R6DJ3IqgEEwdx@5OOmTNwebl(e?*`XNXCn)AU>=%&_d{qM`Cg9HAy!>WjLdQvzo z-d#~_dplry$N#kL(}=Vj*GAV_6oWIfYOFJUCD;btrse6sJG_p3clBCXB8*k)zxE1d zI&tG)7>&Rf1QUJ~gdQ{qHX{ky9DO7IM^Vdk}$$`^6Cgtw+4ea%l{AAefebI^D{ z(WsC3Tk!Jg)5)_EnJW**{M2uCWP&ZMZO}j2BzvsyjAjf1_{r1#>f^B-g{aAix*bn@ z*z~OX#CT$>rN>_vgG@~nN}57mX69lFgUdjoJu=9%YN0~=Pq(?bM#U1XDN$zva5I?g z*|g)GYWR^l7Pl%4ef_<<5rt*6e_MJ88>0qphe>VMxlu@Z3alRYGjdQl5;fo(4R^3g zY>DmqN6q#V88jwi-gAcRhoM_PWhr)-vQi!`Iy^V~JPht~cm~RZu>b(?lfRMeOYFP9 zTQB`Y&)9eG(1LY;?%@MwWyL{7q1Ojf>PNnHQEf=iJddK-ckR86Upn;2-!C%_586Zk z$-VLe#k<-3#X+hT%|jLz&7;Y&)3T0Q^FJ4>^bnT^hX&?($a`@gEw!U zTSd+5NGb?+}HH9NCrxD}NQeDK=PE=Yx11fmev+i29;r|g>W@SG1lfRCy zZR(l+;yR_{1>D@&yiDv;4HBHZxSzWXGHdSR;XYXZwx5DB%Ba9viBdyQ9SKM6cU)Q zdwQ3D_I^uk7JFe4e&_2851c%izHMN!{xRvY3r@~9pXqFd$8r5mM(sV7bllr|sb~A$ zvwsE5FbBdWXU*k?G?}sI+5|5#Q(xjPkkzHTq%Ws!0KeZT&gf?JPXm69v`c&7t<^Q@ z;p4dkKWPMy_;9_Wlzm&7qk&5J(YwWSfm4bmxSPb(UBOz5K_@BvQ27_Qenuz>;-4w= zlXm&=0glbNTlM>H7j+?iif21mvkQ-YP*%VdsbE6>1sJyCRSpz;arNXtzo!-(C|NZeRi;p({;D+5$Sbp{Aj4 zki64OCJl-3XKfEMKI%8*r}XA2U*>`z@mC+jK0g@D^Y4+qTN=20_lNyODD8tmBUj{P zd3h7U{hHn0D5q`vU`F;bS-M3)6hazqNP2##N+6}){HN-Mfa zEoN7EWkFykHnM89i+{A3;QjeBiSyZX6P>tn@MhRiO<_9wqn*~&S1>{KX%G5+1}S1` z=PPpCT;9;|C$6}!9(|Xac|*0;qiJmv(Z<%<3Eth;(vJVO3#hVdli7@k1^yza_PR-P z#vzLy?7bvdhOXA4IfhfKNK|yR9-kEc(e^8q;@+S$pHUk1m(m14-(|__7a&3L)XL7C}=UK6d9?Nl)Az z*Y|KnCRcXlqsK9W^-HIKtND~XiMeJ{d35TbB5*r~=w5*aKK$Ppd+VsSqORRH1b25U zP$*uCdyxWd(Nf$!I23oMxD|IR?(PtZ7K*!5B)Geu-S<7;7wOY8w{?L@l_tT5{qmd+2upRUGIt6Hl`00S77aT zl9k(w4wGA;*rl(%Rjk8C6hZn_N2=2>e*8{QXZhH1ZU(EoK%9;_GT;+*fEUE&`;h%Get338A zgqLcd+0+)c*cboXZQZ$eoJ%Xgd17|eI*mE>aYM4%m~f}If2_#;airsoe}-K_%+q;( zS7+DBGt&{JF!_t`_I%+c#YZ;tYb~ieb;QQ!sEAqL?dPYmak?euwAu~6%SXp6gZ`Mq zP!Tz*rLdJ5eNt`3{`-T37h0>x3Mmo$E?kJhPhnDXPE$&AfxD|Ps<6l!2mRrJSi&eZ z=n{@iWvFyr|7u6&wlRtCMQqYx&3(_65<%i$onEI{GVSo)wfx5qFJp%ycSk3XpV;ls zR-@c+hukuyH1A{m;j0`WFTbTU#~&sQw7^|%M*I=*FAqslni%b!fqc#Jz}4i;1G^Ds zg&aaEe0J8Lu5S$XQ0%Pnun_Ecv*aEq^kAIssgJf=%L?zIs663#ICgOPFy^Rvxr4-i zoxd9YxRn`mBKPKelqFH%-o{e@B9E2!q`j62o&W62KA@YB@IO)_%2(&J5P`L!$>eEy zP3dgAxGju>BD2s1H;T<9(jjExHb+pS4{4MhBQoRtIR!}mTgUTgHifAl{$Y@#zAfV5Ug*&Z!NW3TR8r6EUxfQ`hNtwr2?la8S8fkOcrka^p~n{m z6=v$C>1_BGLU}~^rW>VB|5kly1=}HO-jn)VMqr+!YqVSw{8Gcyu;+X$P4zyZpGft) zMjKTj?R4wu%w`yewTLUGU)FC&(B;nce44fJw5%d}V>E5n#^~P!jPm^&9-H9pCs5hY zaIWF5r!T2h!yS&)Ue&arRqYi4*0MWRV^-URy7RotqK5-AXrJ*jb&b)?cLz7LRUN`X zi-09M;X3#wLoEk7ly~f?)+Os#Aj*-kEu{f5Y@xX(8^-r{QbZ33SvD7JC47w55f7A8s)Z?yN1!#$Q~Z=+|F=pJqACxi46t;zq~(&V?Nx1W1TRnx+p4{t*@TwDt4;D~;w8fTyQxf3p6ix?TV6M{@*HEZyeHkT1E!t1C# zK7236*B1wCf1Y1Tn40SO{$~BGFeA%__Ywc`E7s8)cis@ObrrA0uR=C#aE(|zcWdZ= zpO#Xmri_<7*x|Kuwl^a>^F7NdI^IAaMA|l&2!g8td~y}zd25$u8B3N{d%qV-yjQ+1 zMIK|;$2!_`aG368uaTjt5Lb{avM4!?e^780INEAYG~tSg*y21>I4v=nb$(6Nv}t#; zE+H*A-g9I0uHemehIP*SMw$@4NiN##T~vR3bqHLXB7iWt#O~$AIR5wo)C~vz%#}{ZZa0=@N z?1dCH8`u=uf(jb4+tC|LPC!tD@Zw8fP2Zu?sZpfGoLxrHiuA@f;HArfg&Ck};oA)) z;rXI&KJWM0PXAB_{`(|UN#`{^5VWMYRXC3VfkZ;YfT#jqoNj8(L{cygYHGeeTkX5K zUE0X9t}PlMansAnCj-CqMkq}|UNm{{+t$TQ#Z;eDP~j7?1^pXlcz zly|1EU7m!j`V-9W_ul9{c9)W2T3O zuAeVoOB0ZtmWD%_+vFwJT}>ABO{cr55`7zp|3!}16XC5qy;#(6r4bPXN!9yI= zrL*@_53G6yh^ri8TnAC?hjOy@{j-FiB{2TKqTMU6NIVcX;L^wfJ0Ut8`4wiioO`-^ zL`GbCJ9WeSDqVBgl7LJsNmcNVWo0%K0m`jv)Q~w+VKuz-8tv}0a|y98^*3lVTXbW) zwWIX_Q^PzH$-sL=u4!oxWPjjaI)cfpYV}vOVO7DTjo8l{>s4(xGJjK=o@&VCnDCw} zlX4JZbO=nJ`e7u#g*`vb~9`-(>$Ol54j;=cB*33;AuRq8-J(q zcnQ+C8RIRyZ|Z4P7XSHuP|`z!w_b9qaJ#!ueIuFouEZZp1rPx^=oSvtBF3kN56JLk zCnW4c38j;r`{~t;g$ChW4z=rGAu=!YfcBTaz1!7|4HO4i&_lr_vb!)_MQgQ`o2EeR zRCl8!0g!0)o1KDiH)Oh?xSyBFj&oL^3%KSVbz-78>4H6n=bfrVlLURhr*H1Hv>tZj zMks8cC!VnM&?Wwz_GaQd+#D9&59Hv{1wjZ3?knfvwkZjx^ z0$<_NRC{+Cr@{9wQtx_+pIBwX?l)!3>|8EITKnw}s)T+13){|3YKN2M;-I9BP1}4~ zOTQrU-82fTrhF{bp4G6YN0jx8`3u>~z`x;IaAbZkZ23!}Ae_xbl^iu-f|3~k(lO<} z7V2UqsCqu3^(20(t_G!a@-zr_X8y6QZWfGQAzr;VHiblPm%$!1Skt}mhpVD{(^PD3 zf)GvCdmus{&_h44ikU=XeG{K9XjSL(k>Jh!=Mjg>FUfI;}&Q&o}{Pk7yHi^4YW-n&ZZ z0EWnrQ+_$03Ro6BpvsE5>OwwE#{3Tr!);?a?iUS2pn)@ENM*Y`9HaE2_v_-^zZ=fT zHApeK(^703E`m89?zcuYpOqOkjGdS;;@0EY>+%vX*^(1L^nNV}1oSR6eYOfm*MHH6 zg`MJ3w=B~o;sUK5y4)t1j<{NNei@iSW*;|N?l?RbqXmC{iF7(MXM~!3XDpJk;(RK zU8EEgS_wBUni33lz?^o~;K$YWF$K<=FO#_1ir0Pgrka{mOCdA;GmslfU>o84xX1_};nl%z}L{X~tAtT^IqMZc`rT`HfS!`Me*ew^~?F^B)Z~uYu2g zlEO!R`=bXhWa@@`2AGy;8QuHqsybHP8*1J(CCU*h^iwAz(uK^-LbL`=@VMA4erS3K zv}AK&GdoGIY0io*n{`GO@d6TP`DrIW9qM&TO|OzO>zJXkWGvP&2P+tNEMxO>%jeo1 znsGO|V^z{`m2%@k8R02{mc-|Jo~tL2GstKSIhYjlzq?^VB>&xd zF3<+5#c&L?j|R_9Y$lBEk9R5*s=iy=2pTwqvaxF} z{gR;-W0U%3WzJ2t^r1%EefCIUt{UnnmqoZ2?k276V${`@)9WVkm&oMjrn$2f~v(@+qVR#<*{o22S} zY#JX{VD4E((k@eS+DB_}R@be(gZ-ZWV~LYUN}r{wO5!LW&Y@{6u8Mht1-~L_-nmD? zkH@xFwAe&!Br6JB_E>KZ@c_vN3a@04jR|jDz*QRQnQX7WLPut=Ha#apc)OwkOd`_CPYc1ESR43zVMk7ZgGaJwE2T*X^2DwT~@kg&|xZ9a9l zzQcyRF(3VZi?%zsv89YSK1tJ?b_@|Ar_8#2@BfgJ4^-846E!k3^-k=)o67L4{Kp2q zaTVumWW4M|+#IMS`-S&e_3h-(6s@1tFB(=Mqm6pR2p@6Wd!f8bFR2ltmjY|k0?;Wy zl{*tMoRlyLijygMOQUk&S%o83(R!yPtAprHU^he7=(#d`|KpUdbJO%zM5TgWr%IG( z;9yKbPR%%fg8m~~7-AzGD*~E!h|7xuexCu;)e$p-(yY(+F``xOui+>96yS<5WZvIG z^arzacAEq6n!PJ)F?##k@lEoGb+U%R(pKd{79`rU)0o}gKGgjl!*ls+rtK{vCk^&s zA2Q9QA#;W$boTwHoFcXY$B)W zH4&IO<|HZ7U(xvi=ujv{9jD7$7Is1_PLqEnu?TS0KunVLFIR@H?#ITWM5!hIU|lG! z)Px7yxSiRmKK+%z*G)7Y(c(>hJ-^(uWp6!O8YqeVfGz3$8(~w~-|mu3s!dk#4DA~e z8lla|)%}t{NM1#YPhF?RdRa~7V`hxEqfjONqB=JQqh5XG{EmwbJv7K8Ku_mWr;ib! zXZSvyUsBVjpI1_o)ipM!9Y~e!*w(YVLCTs-rmbFZRo%IX2{PomBf}BcBzJdQBTdz~ zb}bW?_m3T}g;c&L!!Zg-P(Xr2HMoLn+}UM6|Dv(*&D4k*`HNO5IzH1F&CKNzd=H;X z=ypS;p{H4J`c^lvB)p>g2$>;CAC6spY1O%#Hq1&w?#Cqy%>=}n$Bi6r$1-QNpj0-Y zG?1!Xd&su(@;gPKrk#W58-CDg^b^%c)J%hmL`g8t1`GYIn$v~Ziwtka*!M}xM(83B zg!5<}04^Fo)yg^;2UbI{qSfnL7QyLdT!!`-bl&k6M`@j%Rt2p9dI}FU=Gc(B^kg5M z0hvy{mklVQvl4yxFkX_e=3FRvW(7D=oKIQ*qgnYs6))o?Mt?O$vIl-d!kae>RE%VY zTAvYZk?11DVY~+7C98F>E`Vx7dGBwXk52QW`x1fIS9`9Q$Hs>9#4P~kOWydhva9?D zNxvO*Q3ceKX-VaLer?q_wPi+Es&A@UyG#iVAcuZl_I*(wegEy^reI)UZuh?HePDVN zd+u&)taFFJP@8gXfi7vvsD0Vr1(_j8<6!+;fU>pD`hT`jSwVqj+Er*XWCT2^mcQBy9 zZ}JX4H-@t25|?yt^T2C!;<@sLzUJt_>w)?LMy`wWuHu=Jfgjr`z@P0g6dQ2V`E@Gq zz?{jNeJtS~+f<8NZ*R&H)PxEsO0LAaqq$;`z8tf_(K<782(v_zeC@9_1#0IchYtK# z(B;ZlpKnyQ85)y7m|!9`-WsN+U!V>*J!&wsqjw@R*5fZ-enWk(F|ag2SiZ07y%bZu z9Sa+6z}KW2F4j9%4ZdaO1HbS#0m$i(%Q@!yY&B39Zu2WGMuG9&Yp~8sT57nmelLN$ ze<@F4ZFJh1OZ{O}{L-#->xe1#ed|#mX+ukPB9x(5{SGF7a#5Aa=QFkIKmz-(tP0_v z95}5&?otsMZKfYxa(-dB@Ne#-*G?h?Zysxyga|h;VH9+3(%0Ai1{1UeE+U*{2B+lD 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 literal 31642 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 Date: Thu, 2 Jun 2022 08:45:36 +0100 Subject: [PATCH 02/20] Fix: notification schedule nullpointer (#56) * Removed notification schedule outside the job * Moved flutter plugin out of redux * Replaced classNotificationScheduled for shouldScheduleClass --- .../notifications/notification_scheduler.dart | 37 ++++++++++++------- .../notifications/notification_setup.dart | 4 +- app/lib/main.dart | 16 ++------ app/lib/redux/action_creators.dart | 9 +---- app/lib/redux/actions.dart | 5 --- app/lib/redux/reducers.dart | 9 ----- 6 files changed, 32 insertions(+), 48 deletions(-) diff --git a/app/lib/controller/notifications/notification_scheduler.dart b/app/lib/controller/notifications/notification_scheduler.dart index 73cf399..2e5cab1 100644 --- a/app/lib/controller/notifications/notification_scheduler.dart +++ b/app/lib/controller/notifications/notification_scheduler.dart @@ -8,9 +8,29 @@ import 'package:uni/model/notifications/notification.dart'; import 'package:timezone/timezone.dart' as tz; class NotificationScheduler { - final Store _store; + static var _notificationPlugin = null; - NotificationScheduler(this._store); + static get notificationPlugin { + return _notificationPlugin; + } + + NotificationScheduler() { + if (_notificationPlugin == null) { + throw Exception( + 'Instantiated Notification Scheduler without initializing it'); + } + } + + static init() async { + const AndroidInitializationSettings initializationSettingsAndroid = + AndroidInitializationSettings('mipmap/ic_launcher'); + final InitializationSettings initializationSettings = + InitializationSettings( + android: initializationSettingsAndroid, + ); + _notificationPlugin = FlutterLocalNotificationsPlugin(); + await _notificationPlugin.initialize(initializationSettings); + } static NotificationDetails _buildPlatformChannelSpecifics( Notification notification) { @@ -25,11 +45,8 @@ class NotificationScheduler { Future schedule( Notification notification, tz.TZDateTime scheduledTime) async { - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - this._store.state.content['flutterLocalNotificationsPlugin']; - Logger() - .i('LocalNotifPlugin:' + flutterLocalNotificationsPlugin.toString()); - await flutterLocalNotificationsPlugin.zonedSchedule( + Logger().i("Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); + await _notificationPlugin.zonedSchedule( notification.id, notification.title, notification.body, @@ -39,10 +56,4 @@ class NotificationScheduler { UILocalNotificationDateInterpretation.absoluteTime, androidAllowWhileIdle: true); } - - Future scheduleAll() async { - final List preferences = - await this._store.state.content['userNotificationPreferences']; - Logger().i('Preferences:' + preferences.toString()); - } } diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index 03b6cdd..68cc31b 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -1,3 +1,4 @@ +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:logger/logger.dart'; import 'package:redux/redux.dart'; import 'package:tuple/tuple.dart'; @@ -68,13 +69,14 @@ Future classNotificationSetUp( final List lectures = await AppLecturesDatabase().lectures(); for (Lecture lecture in lectures) { if (shouldScheduleClass(lecture, alreadyScheduled, preferences)) { + Logger().i("Notification Already Scheduled: ${lecture.subject}-${lecture.day}"); continue; } final Notification notification = ClassNotificationFactory().buildNotification(lecture); alreadyScheduled.add(NotificationData( notification.id, lecture.id, NotificationType.classNotif.typeName)); - NotificationScheduler(store).schedule( + NotificationScheduler().schedule( ClassNotificationFactory().buildNotification(lecture), ClassNotificationFactory().calculateTime(lecture, antecedence)); } diff --git a/app/lib/main.dart b/app/lib/main.dart index f1dbd66..453cb12 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -5,10 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_redux/flutter_redux.dart'; +import 'package:logger/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry/sentry.dart'; import 'package:redux/redux.dart'; import 'package:uni/controller/middleware.dart'; +import 'package:uni/controller/notifications/notification_scheduler.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; import 'package:uni/model/app_state.dart'; import 'package:uni/redux/actions.dart'; import 'package:uni/redux/reducers.dart'; @@ -41,17 +44,6 @@ SentryEvent beforeSend(SentryEvent event) { return event.level == SentryLevel.info ? event : null; } -setupNotifications() { - const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('mipmap/ic_launcher'); - final InitializationSettings initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, - ); - store.dispatch(SetNotificationService(FlutterLocalNotificationsPlugin())); - store.state.content['flutterLocalNotificationsPlugin'] - .initialize(initializationSettings); -} - workManagerCallbackDispatcher() { Workmanager().executeTask((taskName, inputData) { switch (taskName) { @@ -75,7 +67,7 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); OnStartUp.onStart(store); tz.initializeTimeZones(); - await setupNotifications(); + await NotificationScheduler.init(); await setupWorkManager(); await SentryFlutter.init( (options) { diff --git a/app/lib/redux/action_creators.dart b/app/lib/redux/action_creators.dart index 62050c5..1af3dca 100644 --- a/app/lib/redux/action_creators.dart +++ b/app/lib/redux/action_creators.dart @@ -60,10 +60,6 @@ ThunkAction reLogin(username, password, faculty, {Completer action}) { await loadRemoteUserInfoToState(store); store.dispatch(SetLoginStatusAction(RequestStatus.successful)); action?.complete(); - - // Notifications - // await loadNotificationData(store); - notificationSetUp(store); // Schedule notifications } else { store.dispatch(SetLoginStatusAction(RequestStatus.failed)); action?.completeError(RequestStatus.failed); @@ -105,10 +101,7 @@ ThunkAction login(username, password, faculties, persistentSession, usernameController.clear(); passwordController.clear(); await acceptTermsAndConditions(); - - // Notifications - // await loadNotificationData(store); - notificationSetUp(store); // Schedule notifications + await notificationSetUp(store); } else { store.dispatch(SetLoginStatusAction(RequestStatus.failed)); } diff --git a/app/lib/redux/actions.dart b/app/lib/redux/actions.dart index bd88e39..a6bf663 100644 --- a/app/lib/redux/actions.dart +++ b/app/lib/redux/actions.dart @@ -169,11 +169,6 @@ class SetUserFaculties { SetUserFaculties(this.faculties); } -class SetNotificationService { - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin; - SetNotificationService(this.flutterLocalNotificationsPlugin); -} - class SetUserNotificationPreferences { List preferences; SetUserNotificationPreferences(this.preferences); diff --git a/app/lib/redux/reducers.dart b/app/lib/redux/reducers.dart index 5598ef6..4c853a9 100644 --- a/app/lib/redux/reducers.dart +++ b/app/lib/redux/reducers.dart @@ -64,8 +64,6 @@ AppState appReducers(AppState state, dynamic action) { return setUserFaculties(state, action); } else if (action is SetRestaurantsAction) { return setRestaurantsAction(state, action); - } else if (action is SetNotificationService) { - return setNotificationService(state, action); } else if (action is SetUserNotificationPreferences) { return setUserNotificationPreferences(state, action); } else if (action is SetNotificationsData) { @@ -225,13 +223,6 @@ AppState setUserFaculties(AppState state, SetUserFaculties action) { return state.cloneAndUpdateValue('userFaculties', action.faculties); } -AppState setNotificationService(AppState state, SetNotificationService action) { - Logger().i('setting notification system service ' + - action.flutterLocalNotificationsPlugin.toString()); - return state.cloneAndUpdateValue('flutterLocalNotificationsPlugin', - action.flutterLocalNotificationsPlugin); -} - AppState setUserNotificationPreferences( AppState state, SetUserNotificationPreferences action) { Logger().i( From 603d73ac027c9534de9627ea6a27e96222e1d5e1 Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Thu, 2 Jun 2022 08:47:28 +0100 Subject: [PATCH 03/20] Test: notification scheduling (#53) * Some corrections and test started * Upgraded using mockito * Upgraded test --- app/lib/controller/logout.dart | 3 + .../notifications/notification_setup.dart | 5 +- app/pubspec.lock | 85 ++++++++++++++++++- app/pubspec.yaml | 2 +- .../notification/lecture_id_uniqueness.dart | 12 +-- .../unit/notification/notification_setup.dart | 29 +++++++ .../notification_setup.mocks.dart | 11 +++ 7 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 app/test/unit/notification/notification_setup.dart create mode 100644 app/test/unit/notification/notification_setup.mocks.dart diff --git a/app/lib/controller/logout.dart b/app/lib/controller/logout.dart index 25d7628..6972d80 100644 --- a/app/lib/controller/logout.dart +++ b/app/lib/controller/logout.dart @@ -11,6 +11,7 @@ import 'package:uni/controller/local_storage/app_bus_stop_database.dart'; import 'package:uni/controller/local_storage/app_courses_database.dart'; import 'package:uni/controller/local_storage/app_exams_database.dart'; import 'package:uni/controller/local_storage/app_last_user_info_update_database.dart'; +import 'package:uni/controller/local_storage/app_lecture_notification_preferences_database.dart'; import 'package:uni/controller/local_storage/app_lectures_database.dart'; import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; import 'package:uni/controller/local_storage/app_refresh_times_database.dart'; @@ -23,6 +24,8 @@ Future logout(BuildContext context) async { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); + AppLectureNotificationPreferencesDatabase() + .deleteLectureNotificationPreferences(); AppNotificationPreferencesDatabase().deletePreferences(); AppLecturesDatabase().deleteLectures(); AppExamsDatabase().deleteExams(); diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index 68cc31b..b8b7ede 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -26,8 +26,7 @@ Future> notificationPreferences() async { NotificationPreference( isActive: true, antecedence: NotificationPreference.DEFAULT_ANTECEDENCE, - notificationType: NotificationType.classNotif.typeName - ) + notificationType: NotificationType.classNotif.typeName) ]; await db.saveNewPreferences(preferences); } @@ -88,7 +87,7 @@ bool shouldScheduleClass( List notificationsData, List preferences) { try { - return NotificationData.listContainsModelId( + return !NotificationData.listContainsModelId( notificationsData, lecture.id) && LectureNotificationPreference.idIsActive(preferences, lecture.id); } catch (e) { diff --git a/app/pubspec.lock b/app/pubspec.lock index 4e7ba64..1f7727b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -64,6 +64,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.6.2" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.6" + build_daemon: + dependency: transitive + description: + name: build_daemon + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.10" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.5" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.10" built_collection: dependency: transitive description: @@ -113,6 +148,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" cli_util: dependency: transitive description: @@ -324,6 +366,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + graphs: + dependency: transitive + description: + name: graphs + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" html: dependency: "direct main" description: @@ -344,7 +393,7 @@ packages: name: http_multi_server url: "https://pub.dartlang.org" source: hosted - version: "3.2.0" + version: "2.2.0" http_parser: dependency: transitive description: @@ -372,7 +421,7 @@ packages: name: io url: "https://pub.dartlang.org" source: hosted - version: "1.0.3" + version: "0.3.5" js: dependency: transitive description: @@ -380,6 +429,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.1" logger: dependency: "direct main" description: @@ -611,6 +667,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.8" query_params: dependency: "direct main" description: @@ -729,7 +792,7 @@ packages: name: shelf_web_socket url: "https://pub.dartlang.org" source: hosted - version: "1.0.1" + version: "0.2.4+1" sky_engine: dependency: transitive description: flutter @@ -791,6 +854,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + stream_transform: + dependency: transitive + description: + name: stream_transform + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" string_scanner: dependency: transitive description: @@ -840,6 +910,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.7.0" + timing: + dependency: transitive + description: + name: timing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+3" toast: dependency: "direct main" description: @@ -944,7 +1021,7 @@ packages: name: web_socket_channel url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "1.2.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8f50dbc..44c0904 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -57,7 +57,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - + build_runner: test: any mockito: ^4.1.1 flutter_launcher_icons: ^0.9.0 diff --git a/app/test/unit/notification/lecture_id_uniqueness.dart b/app/test/unit/notification/lecture_id_uniqueness.dart index f97ee0b..b2979fb 100644 --- a/app/test/unit/notification/lecture_id_uniqueness.dart +++ b/app/test/unit/notification/lecture_id_uniqueness.dart @@ -2,14 +2,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:uni/model/entities/lecture.dart'; void main() { - Lecture lecture1 = Lecture("ESOF", "TP", 1, 2, "B204", "AOR", "3LEIC08", 10, 30, 12, 30); - Lecture lecture2 = Lecture("IA", "TP", 2, 2, "B204", "HLC", "3LEIC08", 8, 30, 10, 30); + final Lecture lecture1 = + Lecture('ESOF', 'TP', 1, 2, 'B204', 'AOR', '3LEIC08', 10, 30, 12, 30); + final Lecture lecture2 = + Lecture('IA', 'TP', 2, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); group('Lecture ID Uniqueness', () { test('Equal Lecture ID', () { - expect(lecture1.id, lecture1.id); + expect(lecture1.id, lecture1.id); }); test('Different Lecture ID', () { - expect(lecture1.id, isNot(equals(lecture2.id))); + expect(lecture1.id, isNot(equals(lecture2.id))); }); }); -} \ No newline at end of file +} diff --git a/app/test/unit/notification/notification_setup.dart b/app/test/unit/notification/notification_setup.dart new file mode 100644 index 0000000..e9a8785 --- /dev/null +++ b/app/test/unit/notification/notification_setup.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; +import 'package:uni/model/entities/lecture.dart'; +import 'package:uni/model/entities/lecture_notification_preference.dart'; +import 'package:uni/model/entities/notification_data.dart'; +import 'package:mockito/mockito.dart'; +import 'package:mockito/annotations.dart'; + +import 'notification_setup.mocks.dart'; + +@GenerateMocks([Lecture]) +void main() { + group('Notification Setup Helpers Test', () { + test('shouldScheduleClass test', () { + final lecture = MockLecture(); + final List preferences = [ + LectureNotificationPreference(1, true), + LectureNotificationPreference(2, false), + ]; + final List data = []; + when(lecture.id).thenReturn(1); + expect(shouldScheduleClass(lecture, data, preferences), isTrue); + when(lecture.id).thenReturn(2); + expect(shouldScheduleClass(lecture, data, preferences), isFalse); + when(lecture.id).thenReturn(3); + expect(shouldScheduleClass(lecture, data, preferences), isFalse); + }); + }); +} diff --git a/app/test/unit/notification/notification_setup.mocks.dart b/app/test/unit/notification/notification_setup.mocks.dart new file mode 100644 index 0000000..fef8978 --- /dev/null +++ b/app/test/unit/notification/notification_setup.mocks.dart @@ -0,0 +1,11 @@ +import 'package:mockito/mockito.dart' as _i1; +import 'package:uni/model/entities/lecture.dart' as _i2; + +/// A class which mocks [Lecture]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockLecture extends _i1.Mock implements _i2.Lecture { + MockLecture() { + _i1.throwOnMissingStub(this); + } +} From 7903c47d7d0a638427b59c755668334b4eaad4ab Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Thu, 2 Jun 2022 08:55:25 +0100 Subject: [PATCH 04/20] Added function that resets everything (#54) --- .../notifications/notification_scheduler.dart | 6 ++++++ .../notifications/notification_setup.dart | 13 +++++++++---- app/lib/model/entities/lecture.dart | 18 +++++------------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/app/lib/controller/notifications/notification_scheduler.dart b/app/lib/controller/notifications/notification_scheduler.dart index 2e5cab1..044b4c3 100644 --- a/app/lib/controller/notifications/notification_scheduler.dart +++ b/app/lib/controller/notifications/notification_scheduler.dart @@ -43,6 +43,12 @@ class NotificationScheduler { return NotificationDetails(android: androidPlatformChannelSpecifics); } + Future unscheduleAll() async { + final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + this._store.state.content['flutterLocalNotificationsPlugin']; + flutterLocalNotificationsPlugin.cancelAll(); + } + Future schedule( Notification notification, tz.TZDateTime scheduledTime) async { Logger().i("Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index b8b7ede..525724c 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -42,15 +42,20 @@ Future> return AppLectureNotificationPreferencesDatabase().preferences(); } +Future resetNotifications(Store store) async { + NotificationScheduler(store).unscheduleAll(); + notificationSetUp(store); +} + Future notificationSetUp(Store store) async { - Logger().i('Getting here'); + // TODO: this function might be useless + // Check if user session is persistent final Tuple2 userPersistentInfo = await AppSharedPreferences.getPersistentUserInfo(); if (userPersistentInfo.item1 == '' || userPersistentInfo.item2 == '') return; final List preferences = await notificationPreferences(); - Logger().i('Preferences:' + preferences.toString()); for (NotificationPreference preference in preferences) { if (preference.notificationType == NotificationType.classNotif.typeName && preference.isActive) { @@ -67,7 +72,7 @@ Future classNotificationSetUp( Logger().i('Notification data:' + alreadyScheduled.toString()); final List lectures = await AppLecturesDatabase().lectures(); for (Lecture lecture in lectures) { - if (shouldScheduleClass(lecture, alreadyScheduled, preferences)) { + if (!shouldScheduleClass(lecture, alreadyScheduled, preferences)) { Logger().i("Notification Already Scheduled: ${lecture.subject}-${lecture.day}"); continue; } @@ -76,7 +81,7 @@ Future classNotificationSetUp( alreadyScheduled.add(NotificationData( notification.id, lecture.id, NotificationType.classNotif.typeName)); NotificationScheduler().schedule( - ClassNotificationFactory().buildNotification(lecture), + notification, ClassNotificationFactory().calculateTime(lecture, antecedence)); } AppNotificationDataDatabase().saveNewNotificationData(alreadyScheduled); diff --git a/app/lib/model/entities/lecture.dart b/app/lib/model/entities/lecture.dart index f6ae49d..eeecc75 100644 --- a/app/lib/model/entities/lecture.dart +++ b/app/lib/model/entities/lecture.dart @@ -6,6 +6,7 @@ import 'package:logger/logger.dart'; /// Stores information about a lecture. class Lecture { + // TODO: maybe could implement an abstract class static var dayName = [ 'Segunda-feira', 'Terça-feira', @@ -31,7 +32,8 @@ class Lecture { int get id { //We assume that there is only one class of a given type in a given day BigInt agregatedId = BigInt.from(0); - List hashBytes = sha256.convert(utf8.encode('$subject-$typeClass-$day')).bytes; + List hashBytes = + sha256.convert(utf8.encode('$subject-$typeClass-$day')).bytes; for (var byte in hashBytes) { agregatedId += BigInt.from(byte); } @@ -112,18 +114,8 @@ class Lecture { final endTimeHours = (startTimeMinutes + (blocks * 30)) ~/ 60 + startTimeHours; final endTimeMinutes = (startTimeMinutes + (blocks * 30)) % 60; - return Lecture( - subject, - typeClass, - day, - blocks, - room, - teacher, - classNumber, - startTimeHours, - startTimeMinutes, - endTimeHours, - endTimeMinutes); + return Lecture(subject, typeClass, day, blocks, room, teacher, classNumber, + startTimeHours, startTimeMinutes, endTimeHours, endTimeMinutes); } /// Clones a lecture from the api. From 22ff9d6e39b61db8ca81ca19ab3c22b5389d07e9 Mon Sep 17 00:00:00 2001 From: marhcouto Date: Thu, 2 Jun 2022 08:59:43 +0100 Subject: [PATCH 05/20] Fix: solved unwanted merge changes --- .../notifications/notification_scheduler.dart | 11 +++++------ .../controller/notifications/notification_setup.dart | 8 ++++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/lib/controller/notifications/notification_scheduler.dart b/app/lib/controller/notifications/notification_scheduler.dart index 044b4c3..ffbd185 100644 --- a/app/lib/controller/notifications/notification_scheduler.dart +++ b/app/lib/controller/notifications/notification_scheduler.dart @@ -23,9 +23,9 @@ class NotificationScheduler { static init() async { const AndroidInitializationSettings initializationSettingsAndroid = - AndroidInitializationSettings('mipmap/ic_launcher'); + AndroidInitializationSettings('mipmap/ic_launcher'); final InitializationSettings initializationSettings = - InitializationSettings( + InitializationSettings( android: initializationSettingsAndroid, ); _notificationPlugin = FlutterLocalNotificationsPlugin(); @@ -44,14 +44,13 @@ class NotificationScheduler { } Future unscheduleAll() async { - final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - this._store.state.content['flutterLocalNotificationsPlugin']; - flutterLocalNotificationsPlugin.cancelAll(); + NotificationScheduler._notificationPlugin.cancelAll(); } Future schedule( Notification notification, tz.TZDateTime scheduledTime) async { - Logger().i("Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); + Logger().i( + "Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); await _notificationPlugin.zonedSchedule( notification.id, notification.title, diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index 525724c..88af3ad 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -43,7 +43,7 @@ Future> } Future resetNotifications(Store store) async { - NotificationScheduler(store).unscheduleAll(); + NotificationScheduler().unscheduleAll(); notificationSetUp(store); } @@ -73,15 +73,15 @@ Future classNotificationSetUp( final List lectures = await AppLecturesDatabase().lectures(); for (Lecture lecture in lectures) { if (!shouldScheduleClass(lecture, alreadyScheduled, preferences)) { - Logger().i("Notification Already Scheduled: ${lecture.subject}-${lecture.day}"); + Logger().i( + 'Notification Already Scheduled: ${lecture.subject}-${lecture.day}'); continue; } final Notification notification = ClassNotificationFactory().buildNotification(lecture); alreadyScheduled.add(NotificationData( notification.id, lecture.id, NotificationType.classNotif.typeName)); - NotificationScheduler().schedule( - notification, + NotificationScheduler().schedule(notification, ClassNotificationFactory().calculateTime(lecture, antecedence)); } AppNotificationDataDatabase().saveNewNotificationData(alreadyScheduled); From afdcb861d1ca7ce58d24ad5dec37ecbc7ffcf4c9 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Thu, 2 Jun 2022 09:06:36 +0100 Subject: [PATCH 06/20] Solved next date calculator issues (#57) * Started investigating issue * Solved bugs with calculate next day * Refactored hours Co-authored-by: marhcouto --- app/lib/utils/time_zone_utils.dart | 45 +++++------- .../notification/next_notification_date.dart | 70 ++++++++++++------- 2 files changed, 64 insertions(+), 51 deletions(-) diff --git a/app/lib/utils/time_zone_utils.dart b/app/lib/utils/time_zone_utils.dart index db1bf20..8e9e7b5 100644 --- a/app/lib/utils/time_zone_utils.dart +++ b/app/lib/utils/time_zone_utils.dart @@ -2,34 +2,27 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:timezone/timezone.dart' as tz; -int calculateDelayBetweenDays({ - @required tz.TZDateTime now, - @required int indexDayOfWeek -}) { - final nowWeekDayStartingZero = now.weekday - 1; - return nowWeekDayStartingZero > indexDayOfWeek ? - (indexDayOfWeek - nowWeekDayStartingZero) % 7: - 7 + indexDayOfWeek - nowWeekDayStartingZero; -} - /// Creates a TZDateTime for the same week day in the next week /// The week starts on monday which has a value of 0 -tz.TZDateTime calculateDayInNextWeek({ - @required tz.TZDateTime now, - @required int indexDayOfWeek, - Duration antecedence = Duration.zero, - int startTimeHours = 1, - int startTimeMinutes = 0} - ) { - final int distance = calculateDelayBetweenDays( - now: now, - indexDayOfWeek: indexDayOfWeek - ); - final tz.TZDateTime date = - tz.TZDateTime(tz.local, now.year, now.month, now.day) - .add(Duration(days: distance)) +tz.TZDateTime calculateNextDay( + {@required tz.TZDateTime now, + @required int indexDayOfWeek, + Duration antecedence = Duration.zero, + int startTimeHours = 1, + int startTimeMinutes = 0, + int addedWeeks = 0}) { + final HOURS_IN_WEEK = 168; + final DAYS_IN_WEEK = 7; + final int distanceInDays = + (indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK; + tz.TZDateTime date = tz.TZDateTime(tz.local, now.year, now.month, now.day) + .add(Duration(days: distanceInDays)) .add(Duration(hours: startTimeHours)) .add(Duration(minutes: startTimeMinutes)) - .subtract(antecedence); + .subtract(antecedence) + .add(Duration(hours: addedWeeks * HOURS_IN_WEEK)); + if (date.isBefore(now)) { + date = date.add(Duration(hours: HOURS_IN_WEEK)); + } return date; -} \ No newline at end of file +} diff --git a/app/test/unit/notification/next_notification_date.dart b/app/test/unit/notification/next_notification_date.dart index 0068cab..d8aaf8e 100644 --- a/app/test/unit/notification/next_notification_date.dart +++ b/app/test/unit/notification/next_notification_date.dart @@ -8,7 +8,7 @@ void main() { group('calculateDayInNextWeek tests', () { test('Less than a week before next date 1', () { final tz.TZDateTime now = tz.TZDateTime.local(2022, 05, 27, 12, 30, 13); - final tz.TZDateTime nextDay = calculateDayInNextWeek( + final tz.TZDateTime nextDay = calculateNextDay ( now: now, indexDayOfWeek: 1, startTimeHours: 14, @@ -22,7 +22,7 @@ void main() { }); test('Less than a week before next date 2', () { final tz.TZDateTime now = tz.TZDateTime.local(2022, 05, 27, 12, 30, 13); - final tz.TZDateTime nextDay = calculateDayInNextWeek( + final tz.TZDateTime nextDay = calculateNextDay ( now: now, indexDayOfWeek: 0, startTimeHours: 14, @@ -34,23 +34,9 @@ void main() { expect(nextDay.minute, 0); expect(nextDay.year, 2022); }); - test('More than a week before next date', () { - final tz.TZDateTime now = tz.TZDateTime.utc(2022, 05, 24, 12, 30, 13); - final tz.TZDateTime nextDay = calculateDayInNextWeek( - now: now, - indexDayOfWeek: 4, - startTimeHours: 14, - startTimeMinutes: 0 - ); - expect(nextDay.weekday, nextDay.weekday); //Expects the same day of week - expect(nextDay.day, 3); - expect(nextDay.hour, 14); - expect(nextDay.minute, 0); - expect(nextDay.year, 2022); - }); test('Next day is in a different year', () { final tz.TZDateTime now = tz.TZDateTime.utc(2022, 12, 31, 12, 30, 13); - final tz.TZDateTime nextDay = calculateDayInNextWeek( + final tz.TZDateTime nextDay = calculateNextDay ( now: now, indexDayOfWeek: 4, startTimeHours: 14, @@ -63,8 +49,8 @@ void main() { expect(nextDay.year, 2023); }); test('Antecedence changes hours', () { - final tz.TZDateTime now = tz.TZDateTime.local(2022, 05, 27, 12, 30, 13); - final tz.TZDateTime nextDay = calculateDayInNextWeek( + final tz.TZDateTime now = tz.TZDateTime.local(2022, 05, 27, 8, 0, 0); + final tz.TZDateTime nextDay = calculateNextDay ( now: now, indexDayOfWeek: 4, startTimeHours: 9, @@ -72,19 +58,53 @@ void main() { antecedence: Duration(minutes: 15) ); expect(nextDay.weekday, nextDay.weekday); //Expects the same day of week - expect(nextDay.day, 3); + expect(nextDay.day, 27); expect(nextDay.hour, 8); expect(nextDay.minute, 45); expect(nextDay.year, 2022); }); - test('Notification is at the same day as now', () { - final tz.TZDateTime now = tz.TZDateTime.utc(2022, 05, 23, 11, 0, 30); - final tz.TZDateTime nextDay = calculateDayInNextWeek( + test('Same day but still can schedule', () { + final tz.TZDateTime now = tz.TZDateTime.local(2022, 6, 1, 8, 0, 0); + final tz.TZDateTime nextDay = calculateNextDay ( now: now, - indexDayOfWeek: 4, + indexDayOfWeek: 2, startTimeHours: 14, startTimeMinutes: 0 ); + expect(nextDay.weekday, 3); //Expects the same day of week + expect(nextDay.day, 1); + expect(nextDay.hour, 14); + expect(nextDay.minute, 0); + expect(nextDay.year, 2022); + }); + test('Same day but still cant schedule', () { + final tz.TZDateTime now = tz.TZDateTime.local(2022, 6, 1, 12, 0, 0); + final tz.TZDateTime nextDay = calculateNextDay ( + now: now, + indexDayOfWeek: 2, + startTimeHours: 8, + startTimeMinutes: 0 + ); + expect(nextDay.weekday, 3); //Expects the same day of week + expect(nextDay.day, 8); + expect(nextDay.hour, 8); + expect(nextDay.minute, 0); + expect(nextDay.year, 2022); + }); + test('Sends to next week', () { + final tz.TZDateTime now = tz.TZDateTime.local(2022, 06, 1, 12, 30, 13); + final tz.TZDateTime nextDay = calculateNextDay ( + now: now, + indexDayOfWeek: 3, + startTimeHours: 14, + startTimeMinutes: 0, + addedWeeks: 1 + ); + expect(nextDay.weekday, 4); //Expects the same day of week + expect(nextDay.day, 9); + expect(nextDay.hour, 14); + expect(nextDay.minute, 0); + expect(nextDay.year, 2022); }); }); -} \ No newline at end of file +} From 5434c6a2f10f958708cae46dbbb4a8c9f7fe0d6c Mon Sep 17 00:00:00 2001 From: marhcouto Date: Thu, 2 Jun 2022 09:38:03 +0100 Subject: [PATCH 07/20] Hotfix: experiment with actions --- app/assets/env/pubspec.yaml | 142 ++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 app/assets/env/pubspec.yaml diff --git a/app/assets/env/pubspec.yaml b/app/assets/env/pubspec.yaml new file mode 100644 index 0000000..44c0904 --- /dev/null +++ b/app/assets/env/pubspec.yaml @@ -0,0 +1,142 @@ +name: uni +description: A FEUP no teu bolso + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# Read more about versioning at semver.org. + +version: 1.1.1+35 + + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: 2.0.1 + +dependencies: + flutter: + sdk: flutter + html: ^0.15.0 + flutter_redux: ^0.7.0 + redux_thunk: ^0.3.0 + http: ^0.13.0 + query_params: ^0.6.1 + tuple: ^1.0.3 + shared_preferences: ^2.0.3 + encrypt: ^5.0.0-beta.1 + path_provider: ^2.0.0 + sqflite: ^2.0.0 + path: ^1.8.0 + cached_network_image: ^3.0.0-nullsafety + flutter_svg: ^0.21.0-nullsafety.0 + synchronized: ^3.0.0 + toast: ^0.1.5 + image: ^3.0.0-nullsafety.0 + connectivity: ^0.4.4 + logger: ^0.9.4 + url_launcher: ^6.0.2 + flutter_markdown: ^0.6.0 + intl: ^0.17.0 + crypto: ^3.0.1 + add_2_calendar: ^1.4.0 + sentry_flutter: ^5.0.0 + email_validator: ^1.0.6 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + material_design_icons_flutter: ^5.0.6595 + flutter_local_notifications: ^5.0.0+4 + flutter_native_timezone: ^2.0.0 + workmanager: ^0.4.1 + meta: ^1.3.0 + + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: + test: any + mockito: ^4.1.1 + flutter_launcher_icons: ^0.9.0 + flutter_native_splash: ^1.0.3 + +flutter_icons: + android: true + ios: true + image_path: "assets/icon/icon.png" + +flutter_native_splash: + image: assets/images/splash_screen.png + color: "ffffff" + fill: true + +# For information on the generic Dart part of this file, see the +# following page: https://www.dartlang.org/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + fonts: + - family: Raleway + fonts: + - asset: assets/fonts/Raleway-Black.ttf + weight: 900 + - asset: assets/fonts/Raleway-Bold.ttf + weight: 700 + - asset: assets/fonts/Raleway-ExtraBold.ttf + weight: 800 + - asset: assets/fonts/Raleway-ExtraLight.ttf + weight: 200 + - asset: assets/fonts/Raleway-Light.ttf + weight: 300 + - asset: assets/fonts/Raleway-Medium.ttf + weight: 500 + - asset: assets/fonts/Raleway-Regular.ttf + weight: 400 + - asset: assets/fonts/Raleway-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Raleway-Thin.ttf + weight: 100 + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + assets: + - assets/ + - assets/images/ + - assets/env/ + - assets/text/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.io/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.io/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.io/custom-fonts/#from-packages From 7627d5b9eac1079c73e2f9eee0bf0a9b7be23b3f Mon Sep 17 00:00:00 2001 From: marhcouto Date: Thu, 2 Jun 2022 09:53:50 +0100 Subject: [PATCH 08/20] Hotfix: deleting extra pubspec.yaml --- app/assets/env/pubspec.yaml | 142 ------------------------------------ 1 file changed, 142 deletions(-) delete mode 100644 app/assets/env/pubspec.yaml diff --git a/app/assets/env/pubspec.yaml b/app/assets/env/pubspec.yaml deleted file mode 100644 index 44c0904..0000000 --- a/app/assets/env/pubspec.yaml +++ /dev/null @@ -1,142 +0,0 @@ -name: uni -description: A FEUP no teu bolso - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# Read more about versioning at semver.org. - -version: 1.1.1+35 - - -environment: - sdk: ">=2.7.0 <3.0.0" - flutter: 2.0.1 - -dependencies: - flutter: - sdk: flutter - html: ^0.15.0 - flutter_redux: ^0.7.0 - redux_thunk: ^0.3.0 - http: ^0.13.0 - query_params: ^0.6.1 - tuple: ^1.0.3 - shared_preferences: ^2.0.3 - encrypt: ^5.0.0-beta.1 - path_provider: ^2.0.0 - sqflite: ^2.0.0 - path: ^1.8.0 - cached_network_image: ^3.0.0-nullsafety - flutter_svg: ^0.21.0-nullsafety.0 - synchronized: ^3.0.0 - toast: ^0.1.5 - image: ^3.0.0-nullsafety.0 - connectivity: ^0.4.4 - logger: ^0.9.4 - url_launcher: ^6.0.2 - flutter_markdown: ^0.6.0 - intl: ^0.17.0 - crypto: ^3.0.1 - add_2_calendar: ^1.4.0 - sentry_flutter: ^5.0.0 - email_validator: ^1.0.6 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - material_design_icons_flutter: ^5.0.6595 - flutter_local_notifications: ^5.0.0+4 - flutter_native_timezone: ^2.0.0 - workmanager: ^0.4.1 - meta: ^1.3.0 - - -dev_dependencies: - flutter_test: - sdk: flutter - build_runner: - test: any - mockito: ^4.1.1 - flutter_launcher_icons: ^0.9.0 - flutter_native_splash: ^1.0.3 - -flutter_icons: - android: true - ios: true - image_path: "assets/icon/icon.png" - -flutter_native_splash: - image: assets/images/splash_screen.png - color: "ffffff" - fill: true - -# For information on the generic Dart part of this file, see the -# following page: https://www.dartlang.org/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - fonts: - - family: Raleway - fonts: - - asset: assets/fonts/Raleway-Black.ttf - weight: 900 - - asset: assets/fonts/Raleway-Bold.ttf - weight: 700 - - asset: assets/fonts/Raleway-ExtraBold.ttf - weight: 800 - - asset: assets/fonts/Raleway-ExtraLight.ttf - weight: 200 - - asset: assets/fonts/Raleway-Light.ttf - weight: 300 - - asset: assets/fonts/Raleway-Medium.ttf - weight: 500 - - asset: assets/fonts/Raleway-Regular.ttf - weight: 400 - - asset: assets/fonts/Raleway-SemiBold.ttf - weight: 600 - - asset: assets/fonts/Raleway-Thin.ttf - weight: 100 - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - assets: - - assets/ - - assets/images/ - - assets/env/ - - assets/text/ - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.io/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.io/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.io/custom-fonts/#from-packages From d8be4e997f90c9495c110f14fd2963c8a5f7ace1 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Sun, 5 Jun 2022 13:55:23 +0100 Subject: [PATCH 09/20] Notification Menu sync with Database (#61) * Started working on view change * Now notification page view is consistent with the database --- ...app_notification_preferences_database.dart | 27 ++++++ app/lib/utils/constants.dart | 21 ++++- .../notification_settings_page_view.dart | 93 ++++++++++++++++--- .../view/Widgets/notification_setting.dart | 91 +++++++++--------- 4 files changed, 172 insertions(+), 60 deletions(-) diff --git a/app/lib/controller/local_storage/app_notification_preferences_database.dart b/app/lib/controller/local_storage/app_notification_preferences_database.dart index 98ff17d..8c86738 100644 --- a/app/lib/controller/local_storage/app_notification_preferences_database.dart +++ b/app/lib/controller/local_storage/app_notification_preferences_database.dart @@ -3,6 +3,7 @@ import 'package:logger/logger.dart'; import 'package:uni/controller/local_storage/app_database.dart'; import 'package:sqflite/sqflite.dart'; import 'package:uni/model/entities/notification_preference.dart'; +import 'package:uni/utils/constants.dart'; /// Manages the app's Notification Preferences database. /// @@ -56,6 +57,32 @@ class AppNotificationPreferencesDatabase extends AppDatabase { } } + Future getPreference(NotificationType type) async { + final Database db = await this.getDatabase(); + + // This list only contains more than one element if primary key is broken + final List> rawPreferences = await db.query( + 'notification_preferences', + where: 'notificationType = ?', + whereArgs: [type.typeName] + ); + + final wantedPreference = rawPreferences[0]; + return NotificationPreference.fromHtml(wantedPreference['isActive'], + wantedPreference['antecedence'], wantedPreference['notificationType']); + } + + Future replacePreference(NotificationPreference preference) async { + final Database db = await this.getDatabase(); + Logger().d("Updating preference: ${preference.toMap()}"); + await db.update( + 'notification_preferences', + preference.toMap(), + where: 'notificationType = ?', + whereArgs: [preference.notificationType] + ); + } + /// Deletes all of the data stored in this database. Future deletePreferences() async { // Get a reference to the database diff --git a/app/lib/utils/constants.dart b/app/lib/utils/constants.dart index 29acb64..1c0ec58 100644 --- a/app/lib/utils/constants.dart +++ b/app/lib/utils/constants.dart @@ -28,6 +28,23 @@ const faculties = [ enum NotificationType { classNotif, tuitionNotif } +extension NotificationWidgetData on NotificationType { + static const notificationAntecedenceMaxValues = { + NotificationType.classNotif: 30.0 + }; + static const notificationAntecedenceGranularities = { + NotificationType.classNotif: 5 + }; + + static const notificationAntecedenceSuffixes = { + NotificationType.classNotif: 'minutos antes da próxima aula.' + }; + + double get antecedenceMaxValue => notificationAntecedenceMaxValues[this]; + int get antecedenceGranularity => notificationAntecedenceGranularities[this]; + String get antecedenceSuffix => notificationAntecedenceSuffixes[this]; +} + extension NotificationTypeData on NotificationType { static const typeNames = { NotificationType.classNotif: 'class notification', @@ -40,8 +57,8 @@ extension NotificationTypeData on NotificationType { }; static const channelNames = { - NotificationType.classNotif: 'Notificações de Aulas', - NotificationType.tuitionNotif: 'Notificações de Pagamento de propinas' + NotificationType.classNotif: 'Notificações sobre Aulas', + NotificationType.tuitionNotif: 'Notificações sobre Pagamento de propinas' }; static const channelDescriptions = { diff --git a/app/lib/view/Pages/notification_settings_page_view.dart b/app/lib/view/Pages/notification_settings_page_view.dart index fa75760..2a12465 100644 --- a/app/lib/view/Pages/notification_settings_page_view.dart +++ b/app/lib/view/Pages/notification_settings_page_view.dart @@ -1,5 +1,7 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; +import 'package:uni/model/entities/notification_preference.dart'; +import 'package:uni/utils/constants.dart'; import 'package:uni/view/Pages/secondary_page_view.dart'; import 'package:uni/view/Widgets/notification_setting.dart'; import 'package:uni/view/Widgets/page_title.dart'; @@ -9,9 +11,67 @@ class NotificationSettingsPageView extends StatefulWidget { State createState() => NotificationSettingsPageViewState(); } -/// Manages the 'Bugs and sugestions' section of the app. class NotificationSettingsPageViewState extends SecondaryPageViewState { - List _isOpen = List.filled(2, false); + var _editedPreferences = false; + final Map notificationSettings = { + NotificationType.classNotif: NotificationPreference( + antecedence: 0, + notificationType: NotificationType.classNotif.typeName, + isActive: false + ) + }; + + NotificationSettingsPageViewState() { + retrieveSettingsFromDatabase(); + } + + retrieveSettingsFromDatabase() async { + final db = AppNotificationPreferencesDatabase(); + for (var key in notificationSettings.keys) { + notificationSettings[key] = await db.getPreference(key); + } + if (mounted) { + setState(() => _editedPreferences = false); + } + } + + getCommitButtons() { + if (_editedPreferences) { + return Padding( + padding: EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ElevatedButton( + child: Text('Descartar'), + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.grey) + ), + onPressed: () { + retrieveSettingsFromDatabase(); + }, + ), + SizedBox(width: 15), + ElevatedButton( + child: Text('Guardar'), + onPressed: savePreferences, + ), + ] + ) + ); + } + return SizedBox.shrink(); + } + + savePreferences() async { + final db = AppNotificationPreferencesDatabase(); + for (var value in notificationSettings.values) { + await db.replacePreference(value); + } + if (mounted) { + setState(() => _editedPreferences = false); + } + } @override Widget getBody(BuildContext context) { @@ -21,19 +81,24 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { name: 'Notificações' ), NotificationSetting( - 'Início de Aulas', - switched: _isOpen[0], - onChanged: (newState) { - setState(() => _isOpen[0] = newState); + notificationType: NotificationType.classNotif, + switched: notificationSettings[NotificationType.classNotif].isActive, + onSwitchChanged: (value) { + setState(() { + _editedPreferences = true; + notificationSettings[NotificationType.classNotif] + .isActive = value; + }); }, - ), - NotificationSetting( - 'Pagamento de propinas', - switched: _isOpen[1], - onChanged: (newState) { - setState(() => _isOpen[1] = newState); + onSliderChanged: (value) { + setState(() => _editedPreferences = true); + notificationSettings[NotificationType.classNotif] + .antecedence = value.toInt(); }, - ) + initialSliderValue: notificationSettings[NotificationType.classNotif] + .antecedence, + ), + getCommitButtons() ], ); } diff --git a/app/lib/view/Widgets/notification_setting.dart b/app/lib/view/Widgets/notification_setting.dart index b7c4583..a565827 100644 --- a/app/lib/view/Widgets/notification_setting.dart +++ b/app/lib/view/Widgets/notification_setting.dart @@ -1,80 +1,83 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:uni/utils/constants.dart'; class NotificationSetting extends StatefulWidget { - String _notificationName; - bool _switched; - Function(bool) _onChanged; + final NotificationType notificationType; + final bool switched; + final int initialSliderValue; + final Function(bool) onSwitchChanged; + final Function(double) onSliderChanged; - NotificationSetting(String notificationName, - {bool switched, Function(bool) onChanged}) { - this._notificationName = notificationName; - this._switched = switched; - this._onChanged = onChanged; - } + NotificationSetting({ + @required this.notificationType, + @required this.switched, + @required this.onSwitchChanged, + @required this.onSliderChanged, + @required this.initialSliderValue + }); @override - State createState() => - _NotificationSettingsState(_notificationName, - switched: _switched, onChanged: _onChanged); + State createState() => _NotificationSettingState( + notificationType: this.notificationType, + switched: this.switched, + onSwitchChanged: this.onSwitchChanged, + onSliderChanged: this.onSliderChanged, + initialSliderValue: this.initialSliderValue + ); } -class _NotificationSettingsState extends State { - String _notificationName; - bool _switched; - Function(bool) _onChanged; - double _timerSliderValue = 0; +class _NotificationSettingState extends State { + final NotificationType notificationType; + final bool switched; + final int initialSliderValue; + final Function(bool) onSwitchChanged; + final Function(double) onSliderChanged; + double sliderValue; - _NotificationSettingsState(String notificationName, - {bool switched, Function(bool) onChanged}) { - this._notificationName = notificationName; - this._switched = switched; - this._onChanged = onChanged; + _NotificationSettingState({ + @required this.notificationType, + @required this.switched, + @required this.onSwitchChanged, + @required this.onSliderChanged, + @required this.initialSliderValue + }) { + sliderValue = initialSliderValue.toDouble(); } - + @override Widget build(BuildContext context) { List columnChildren = [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(_notificationName), + Text(notificationType.channelName), Switch( - value: _switched, - onChanged: _onChanged, + value: switched, + onChanged: onSwitchChanged, ), ], ), ]; - if (_switched) { + if (switched) { columnChildren.add( Slider( - value: _timerSliderValue, + value: sliderValue, min: 0, - max: 100, - divisions: 20, - //label: _timerSliderValue.round().toString(), - onChanged: (double value) { - setState(() { - _timerSliderValue = value.roundToDouble(); - //the round fixes weird glitch when the value hits 55 - }); - }, + max: notificationType.antecedenceMaxValue, + divisions: notificationType.antecedenceGranularity, activeColor: Colors.brown[300], inactiveColor: Colors.deepOrange[400], + onChanged: (value) => setState(() => sliderValue = value), + onChangeEnd: onSliderChanged, ), ); columnChildren.add( SizedBox(height: 30), ); - if (_notificationName == 'Início de Aulas') { - columnChildren - .add(Text("$_timerSliderValue minutos antes da próxima aula.")); - } else { - columnChildren.add(Text( - "$_timerSliderValue dias antes do prazo do próximo pagamento.")); - } + columnChildren + .add(Text('$sliderValue ${notificationType.antecedenceSuffix}')); } return Padding( From eb1ad24372a43708052fc06fc77460541f8edf46 Mon Sep 17 00:00:00 2001 From: frpdoliv Date: Sun, 5 Jun 2022 14:26:58 +0100 Subject: [PATCH 10/20] Solved compile time errors --- .../class_notification_factory.dart | 2 +- .../class_notification_schedule_task.dart | 2 +- app/lib/utils/time_zone_utils.dart | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/lib/model/notifications/class_notification_factory.dart b/app/lib/model/notifications/class_notification_factory.dart index de2ae32..cfba819 100644 --- a/app/lib/model/notifications/class_notification_factory.dart +++ b/app/lib/model/notifications/class_notification_factory.dart @@ -21,7 +21,7 @@ class ClassNotificationFactory extends NotificationFactory { } tz.TZDateTime calculateTime(Lecture notificationModel, int antecedence) { - return tzu.calculateDayInNextWeek( + return tzu.calculateNextDay( now: tz.TZDateTime.now(tz.local), indexDayOfWeek: notificationModel.day + 1, antecedence: Duration(minutes: antecedence), diff --git a/app/lib/tasks/class_notification_schedule_task.dart b/app/lib/tasks/class_notification_schedule_task.dart index 0ecfdd2..19aec6d 100644 --- a/app/lib/tasks/class_notification_schedule_task.dart +++ b/app/lib/tasks/class_notification_schedule_task.dart @@ -17,7 +17,7 @@ class ClassNotificationScheduleTask { frequency: Duration(days: 7), // Delays initial job to sunday initialDelay: Duration( - days: calculateDelayBetweenDays( + days: delayBetweenDays( now: tz.TZDateTime.now(tz.local), indexDayOfWeek: 5) ) diff --git a/app/lib/utils/time_zone_utils.dart b/app/lib/utils/time_zone_utils.dart index 8e9e7b5..54404d7 100644 --- a/app/lib/utils/time_zone_utils.dart +++ b/app/lib/utils/time_zone_utils.dart @@ -2,6 +2,16 @@ import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:timezone/timezone.dart' as tz; +const HOURS_IN_WEEK = 168; +const DAYS_IN_WEEK = 7; + +int delayBetweenDays({ + @required tz.TZDateTime now, + @required int indexDayOfWeek +}) { + return (indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK; +} + /// Creates a TZDateTime for the same week day in the next week /// The week starts on monday which has a value of 0 tz.TZDateTime calculateNextDay( @@ -11,10 +21,10 @@ tz.TZDateTime calculateNextDay( int startTimeHours = 1, int startTimeMinutes = 0, int addedWeeks = 0}) { - final HOURS_IN_WEEK = 168; - final DAYS_IN_WEEK = 7; - final int distanceInDays = - (indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK; + final int distanceInDays = delayBetweenDays( + now: now, + indexDayOfWeek: indexDayOfWeek + ); tz.TZDateTime date = tz.TZDateTime(tz.local, now.year, now.month, now.day) .add(Duration(days: distanceInDays)) .add(Duration(hours: startTimeHours)) From f03a56102a86f14d053f8a3cb2c00ce0b61124fa Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Sun, 5 Jun 2022 17:31:04 +0100 Subject: [PATCH 11/20] Added flutter test action (#63) --- .github/workflows/tests.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..21a7f5f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: FLutter_Tests + +on: + push: + branches: [ dev, main ] + pull_request: + branches: [ dev, main ] + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout Code from PR/Commit + uses: actions/checkout@v2 + + - name: Install and set Flutter version + uses: subosito/flutter-action@v2.0.1 + with: + flutter-version: '2.0.1' + + - name: Restore packages + run: | + cd app/ + flutter pub get + + - name: Run tests + run: | + cd app/ + flutter test --coverage From 9b330b0859f6861f0c11cfa5d6c882f604eb5235 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Sun, 5 Jun 2022 17:37:24 +0100 Subject: [PATCH 12/20] Done weekly schedule of notificaitons (#62) --- .../notifications/notification_scheduler.dart | 18 +++++----- .../notifications/notification_setup.dart | 2 +- app/lib/main.dart | 28 ++------------- .../class_notification_schedule_task.dart | 34 ------------------- app/lib/utils/time_zone_utils.dart | 2 +- 5 files changed, 15 insertions(+), 69 deletions(-) delete mode 100644 app/lib/tasks/class_notification_schedule_task.dart diff --git a/app/lib/controller/notifications/notification_scheduler.dart b/app/lib/controller/notifications/notification_scheduler.dart index ffbd185..206cf62 100644 --- a/app/lib/controller/notifications/notification_scheduler.dart +++ b/app/lib/controller/notifications/notification_scheduler.dart @@ -52,13 +52,15 @@ class NotificationScheduler { Logger().i( "Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); await _notificationPlugin.zonedSchedule( - notification.id, - notification.title, - notification.body, - scheduledTime, - _buildPlatformChannelSpecifics(notification), - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - androidAllowWhileIdle: true); + notification.id, + notification.title, + notification.body, + scheduledTime, + _buildPlatformChannelSpecifics(notification), + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + androidAllowWhileIdle: true, + matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime + ); } } diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index 88af3ad..acd8e07 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -96,7 +96,7 @@ bool shouldScheduleClass( notificationsData, lecture.id) && LectureNotificationPreference.idIsActive(preferences, lecture.id); } catch (e) { - Logger().e('Error: ' + e.cause); + Logger().e('Error: ${e.cause}/${lecture.subject}-${lecture.typeClass}-${lecture.day}'); } return false; } diff --git a/app/lib/main.dart b/app/lib/main.dart index 453cb12..afd10ac 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,19 +3,16 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_native_timezone/flutter_native_timezone.dart'; import 'package:flutter_redux/flutter_redux.dart'; -import 'package:logger/logger.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry/sentry.dart'; import 'package:redux/redux.dart'; import 'package:uni/controller/middleware.dart'; import 'package:uni/controller/notifications/notification_scheduler.dart'; -import 'package:uni/controller/notifications/notification_setup.dart'; import 'package:uni/model/app_state.dart'; import 'package:uni/redux/actions.dart'; import 'package:uni/redux/reducers.dart'; -import 'package:uni/tasks/class_notification_schedule_task.dart'; import 'package:uni/utils/constants.dart' as Constants; import 'package:uni/view/Pages/about_page_view.dart'; import 'package:uni/view/Pages/bug_report_page_view.dart'; @@ -29,7 +26,7 @@ import 'package:uni/view/Widgets/page_transition.dart'; import 'package:uni/view/navigation_service.dart'; import 'package:uni/view/theme.dart'; import 'package:timezone/data/latest_all.dart' as tz; -import 'package:workmanager/workmanager.dart'; +import 'package:timezone/timezone.dart' as tz; import 'controller/on_start_up.dart'; import 'model/schedule_page_model.dart'; @@ -44,31 +41,12 @@ SentryEvent beforeSend(SentryEvent event) { return event.level == SentryLevel.info ? event : null; } -workManagerCallbackDispatcher() { - Workmanager().executeTask((taskName, inputData) { - switch (taskName) { - case ClassNotificationScheduleTask.taskId: - ClassNotificationScheduleTask.scheduleClassNotifications(store); - break; - } - return Future.value(true); - }); -} - -setupWorkManager() async { - await Workmanager().initialize( - workManagerCallbackDispatcher, - isInDebugMode: kDebugMode ? true : false - ); - await ClassNotificationScheduleTask.createClassNotificationSchedulingJob(); -} - Future main() async { WidgetsFlutterBinding.ensureInitialized(); OnStartUp.onStart(store); tz.initializeTimeZones(); + tz.setLocalLocation(tz.getLocation(await FlutterNativeTimezone.getLocalTimezone())); await NotificationScheduler.init(); - await setupWorkManager(); await SentryFlutter.init( (options) { options.dsn = diff --git a/app/lib/tasks/class_notification_schedule_task.dart b/app/lib/tasks/class_notification_schedule_task.dart deleted file mode 100644 index 19aec6d..0000000 --- a/app/lib/tasks/class_notification_schedule_task.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:uni/controller/local_storage/app_shared_preferences.dart'; -import 'package:workmanager/workmanager.dart'; -import 'package:redux/redux.dart'; -import 'package:uni/model/app_state.dart'; -import 'package:uni/controller/notifications/notification_setup.dart'; -import 'package:uni/utils/time_zone_utils.dart'; -import 'package:timezone/timezone.dart' as tz; - -class ClassNotificationScheduleTask { - static const String taskId = 'ClassNotificationScheduleTask'; - - static Future createClassNotificationSchedulingJob() async { - if (!await AppSharedPreferences.classNotificationJobCreated()) { - Workmanager().registerPeriodicTask( - taskId, - taskId, - frequency: Duration(days: 7), - // Delays initial job to sunday - initialDelay: Duration( - days: delayBetweenDays( - now: tz.TZDateTime.now(tz.local), - indexDayOfWeek: 5) - ) - ); - // Cancels the task in case mobile data is deleted - await Workmanager().cancelByUniqueName(taskId); - await AppSharedPreferences.setClassNotificationJobCreated(true); - } - } - - static void scheduleClassNotifications(Store store) async { - await notificationSetUp(store); - } -} diff --git a/app/lib/utils/time_zone_utils.dart b/app/lib/utils/time_zone_utils.dart index 54404d7..8588bc1 100644 --- a/app/lib/utils/time_zone_utils.dart +++ b/app/lib/utils/time_zone_utils.dart @@ -9,7 +9,7 @@ int delayBetweenDays({ @required tz.TZDateTime now, @required int indexDayOfWeek }) { - return (indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK; + return ((indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK) - 1; } /// Creates a TZDateTime for the same week day in the next week From c32094e60b14d82e58793b85cd1b3439fcddce97 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Mon, 6 Jun 2022 11:27:44 +0100 Subject: [PATCH 13/20] Feature: Notification menu now updates scheduled notificaitons (#64) * Notificaiton menu now updates scheduled notificaitons * Test that always fails * Minor Changes * Update tests.yml * Update tests.yml * Update tests.yml * Update tests.yml * Fixed test files name * Removed bad test * Fixed one test * Minor Changes Co-authored-by: Marcelo Couto --- .github/workflows/tests.yml | 4 +++- .../app_notification_data_database.dart | 1 + .../notifications/notification_setup.dart | 14 ++++++-------- .../notifications/class_notification_factory.dart | 2 +- app/lib/redux/action_creators.dart | 2 +- app/lib/utils/time_zone_utils.dart | 2 +- .../Pages/notification_settings_page_view.dart | 2 ++ ...ueness.dart => lecture_id_uniqueness_test.dart} | 0 ..._date.dart => next_notification_date_test.dart} | 0 ...ion_setup.dart => notification_setup_test.dart} | 3 +++ 10 files changed, 18 insertions(+), 12 deletions(-) rename app/test/unit/notification/{lecture_id_uniqueness.dart => lecture_id_uniqueness_test.dart} (100%) rename app/test/unit/notification/{next_notification_date.dart => next_notification_date_test.dart} (100%) rename app/test/unit/notification/{notification_setup.dart => notification_setup_test.dart} (89%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21a7f5f..e3bf824 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,9 @@ jobs: steps: - name: Checkout Code from PR/Commit - uses: actions/checkout@v2 + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} - name: Install and set Flutter version uses: subosito/flutter-action@v2.0.1 diff --git a/app/lib/controller/local_storage/app_notification_data_database.dart b/app/lib/controller/local_storage/app_notification_data_database.dart index a149190..911d87d 100644 --- a/app/lib/controller/local_storage/app_notification_data_database.dart +++ b/app/lib/controller/local_storage/app_notification_data_database.dart @@ -5,6 +5,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:uni/model/entities/notification_data.dart'; import 'package:uni/model/entities/notification_preference.dart'; import 'package:uni/model/notifications/notification.dart'; +import 'package:uni/utils/constants.dart'; /// Manages the app's Notifications Data database. /// diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index acd8e07..44346b2 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -42,14 +42,13 @@ Future> return AppLectureNotificationPreferencesDatabase().preferences(); } -Future resetNotifications(Store store) async { +Future resetNotifications() async { NotificationScheduler().unscheduleAll(); - notificationSetUp(store); + await AppNotificationDataDatabase().deleteNotificationsData(); + await notificationSetUp(); } -Future notificationSetUp(Store store) async { - // TODO: this function might be useless - // Check if user session is persistent +Future notificationSetUp() async { final Tuple2 userPersistentInfo = await AppSharedPreferences.getPersistentUserInfo(); if (userPersistentInfo.item1 == '' || userPersistentInfo.item2 == '') return; @@ -59,13 +58,12 @@ Future notificationSetUp(Store store) async { for (NotificationPreference preference in preferences) { if (preference.notificationType == NotificationType.classNotif.typeName && preference.isActive) { - classNotificationSetUp(store, preference.antecedence); + classNotificationSetUp(preference.antecedence); } } } -Future classNotificationSetUp( - Store store, int antecedence) async { +Future classNotificationSetUp(int antecedence) async { final preferences = await lectureNotificationPreferences(); Logger().i('Lecture preferences:' + preferences.toString()); final alreadyScheduled = await notificationsData(); diff --git a/app/lib/model/notifications/class_notification_factory.dart b/app/lib/model/notifications/class_notification_factory.dart index cfba819..a93d3d6 100644 --- a/app/lib/model/notifications/class_notification_factory.dart +++ b/app/lib/model/notifications/class_notification_factory.dart @@ -23,7 +23,7 @@ class ClassNotificationFactory extends NotificationFactory { tz.TZDateTime calculateTime(Lecture notificationModel, int antecedence) { return tzu.calculateNextDay( now: tz.TZDateTime.now(tz.local), - indexDayOfWeek: notificationModel.day + 1, + indexDayOfWeek: notificationModel.day, antecedence: Duration(minutes: antecedence), startTimeHours: notificationModel.startTimeHours, startTimeMinutes: notificationModel.startTimeMinutes diff --git a/app/lib/redux/action_creators.dart b/app/lib/redux/action_creators.dart index 1af3dca..1397627 100644 --- a/app/lib/redux/action_creators.dart +++ b/app/lib/redux/action_creators.dart @@ -101,7 +101,7 @@ ThunkAction login(username, password, faculties, persistentSession, usernameController.clear(); passwordController.clear(); await acceptTermsAndConditions(); - await notificationSetUp(store); + await notificationSetUp(); } else { store.dispatch(SetLoginStatusAction(RequestStatus.failed)); } diff --git a/app/lib/utils/time_zone_utils.dart b/app/lib/utils/time_zone_utils.dart index 8588bc1..1a6c2a8 100644 --- a/app/lib/utils/time_zone_utils.dart +++ b/app/lib/utils/time_zone_utils.dart @@ -9,7 +9,7 @@ int delayBetweenDays({ @required tz.TZDateTime now, @required int indexDayOfWeek }) { - return ((indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK) - 1; + return ((indexDayOfWeek - (now.weekday - 1)) % DAYS_IN_WEEK); } /// Creates a TZDateTime for the same week day in the next week diff --git a/app/lib/view/Pages/notification_settings_page_view.dart b/app/lib/view/Pages/notification_settings_page_view.dart index 2a12465..cfd8246 100644 --- a/app/lib/view/Pages/notification_settings_page_view.dart +++ b/app/lib/view/Pages/notification_settings_page_view.dart @@ -5,6 +5,7 @@ import 'package:uni/utils/constants.dart'; import 'package:uni/view/Pages/secondary_page_view.dart'; import 'package:uni/view/Widgets/notification_setting.dart'; import 'package:uni/view/Widgets/page_title.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; class NotificationSettingsPageView extends StatefulWidget { @override @@ -71,6 +72,7 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { if (mounted) { setState(() => _editedPreferences = false); } + await resetNotifications(); } @override diff --git a/app/test/unit/notification/lecture_id_uniqueness.dart b/app/test/unit/notification/lecture_id_uniqueness_test.dart similarity index 100% rename from app/test/unit/notification/lecture_id_uniqueness.dart rename to app/test/unit/notification/lecture_id_uniqueness_test.dart diff --git a/app/test/unit/notification/next_notification_date.dart b/app/test/unit/notification/next_notification_date_test.dart similarity index 100% rename from app/test/unit/notification/next_notification_date.dart rename to app/test/unit/notification/next_notification_date_test.dart diff --git a/app/test/unit/notification/notification_setup.dart b/app/test/unit/notification/notification_setup_test.dart similarity index 89% rename from app/test/unit/notification/notification_setup.dart rename to app/test/unit/notification/notification_setup_test.dart index e9a8785..160d8c8 100644 --- a/app/test/unit/notification/notification_setup.dart +++ b/app/test/unit/notification/notification_setup_test.dart @@ -13,6 +13,9 @@ void main() { group('Notification Setup Helpers Test', () { test('shouldScheduleClass test', () { final lecture = MockLecture(); + when(lecture.subject).thenReturn('a'); + when(lecture.typeClass).thenReturn('a'); + when(lecture.day).thenReturn(1); final List preferences = [ LectureNotificationPreference(1, true), LectureNotificationPreference(2, false), From 99fe9922dc1438af9f36dad9383777699d33f515 Mon Sep 17 00:00:00 2001 From: Afonso Monteiro <40891307+H0wl3r2001@users.noreply.github.com> Date: Mon, 6 Jun 2022 11:53:42 +0100 Subject: [PATCH 14/20] WI: Created a stateful button to the schedule page (#60) * Added a floating action button. Cannot make it change icons. Don't understand the reason * some refactoring * some corrections, but have the same problem * solved the problem by making a stateful button widget * added some comments for clarification * Removed prints Co-authored-by: marhcouto --- app/lib/view/Pages/schedule_page_view.dart | 39 +++++++++++++++++----- app/lib/view/Widgets/button_stateful.dart | 31 +++++++++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 app/lib/view/Widgets/button_stateful.dart diff --git a/app/lib/view/Pages/schedule_page_view.dart b/app/lib/view/Pages/schedule_page_view.dart index 6e5679d..3d5e9f7 100644 --- a/app/lib/view/Pages/schedule_page_view.dart +++ b/app/lib/view/Pages/schedule_page_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:uni/view/Widgets/page_title.dart'; import 'package:uni/view/Widgets/request_dependent_widget_builder.dart'; import 'package:uni/view/Widgets/schedule_slot.dart'; +import 'package:uni/view/Widgets/button_stateful.dart'; /// Manages the 'schedule' sections of the app class SchedulePageView extends StatelessWidget { @@ -20,6 +21,7 @@ class SchedulePageView extends StatelessWidget { final RequestStatus scheduleStatus; final TabController tabController; final ScrollController scrollViewController; + int fabIconNumber = 0; @override Widget build(BuildContext context) { @@ -72,15 +74,25 @@ class SchedulePageView extends StatelessWidget { final List scheduleContent = []; for (int i = 0; i < lectures.length; i++) { final Lecture lecture = lectures[i]; - scheduleContent.add(ScheduleSlot( - subject: lecture.subject, - typeClass: lecture.typeClass, - rooms: lecture.room, - begin: lecture.startTime, - end: lecture.endTime, - teacher: lecture.teacher, - classNumber: lecture.classNumber, - )); + + IconData ic = Icons.alarm_add_rounded; + + Stack stk = Stack( + children: [ + ScheduleSlot( + subject: lecture.subject, + typeClass: lecture.typeClass, + rooms: lecture.room, + begin: lecture.startTime, + end: lecture.endTime, + teacher: lecture.teacher, + classNumber: lecture.classNumber, + ), + ScheduleButton() + ], + ); + + scheduleContent.add(stk); } return scheduleContent; } @@ -111,4 +123,13 @@ class SchedulePageView extends StatelessWidget { index: day, ); } + + IconData setIcon(IconData ic) { + if (ic == Icons.alarm_add_rounded) { + ic = Icons.alarm_off_rounded; + } else { + ic = Icons.alarm_add_rounded; + } + return ic; + } } diff --git a/app/lib/view/Widgets/button_stateful.dart b/app/lib/view/Widgets/button_stateful.dart new file mode 100644 index 0000000..45ddcdb --- /dev/null +++ b/app/lib/view/Widgets/button_stateful.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +class ScheduleButton extends StatefulWidget { + @override + ScheduleButtonState createState() => new ScheduleButtonState(); +} + +class ScheduleButtonState extends State { + int fabIconNumber = 0; + List icons = [Icons.alarm_off_rounded, Icons.alarm_add_rounded]; + IconData ic = Icons.alarm_add_rounded; + + void onPressed() { + //add another function here if needed + setState(() { + fabIconNumber = fabIconNumber % 2 == 0 ? 0 : 1; + ic = icons[fabIconNumber]; + fabIconNumber++; + }); + } + + @override + Widget build(BuildContext context) { + return new Align( + alignment: new Alignment(0.65, 0), + child: new Transform.scale( + scale: 0.8, + child: new FloatingActionButton( + onPressed: onPressed, child: Icon(ic), heroTag: null))); + } +} From 728478bd371b637be42328a3bb88e91d5d1ec494 Mon Sep 17 00:00:00 2001 From: Afonso Monteiro <40891307+H0wl3r2001@users.noreply.github.com> Date: Mon, 6 Jun 2022 17:38:56 +0100 Subject: [PATCH 15/20] WI: added button that connects the settings page to the schedule and Vice-versa (#66) * added button that connects the notification settings page to the schedule and Vice-versa * minor changes * More changes * done some corrections --- .../notification_settings_page_view.dart | 53 +++++++++++-------- app/lib/view/Pages/schedule_page_view.dart | 15 +++++- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/app/lib/view/Pages/notification_settings_page_view.dart b/app/lib/view/Pages/notification_settings_page_view.dart index cfd8246..3ee51d2 100644 --- a/app/lib/view/Pages/notification_settings_page_view.dart +++ b/app/lib/view/Pages/notification_settings_page_view.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; import 'package:uni/model/entities/notification_preference.dart'; +import 'package:uni/model/schedule_page_model.dart'; import 'package:uni/utils/constants.dart'; +import 'package:uni/view/Pages/schedule_page_view.dart'; import 'package:uni/view/Pages/secondary_page_view.dart'; import 'package:uni/view/Widgets/notification_setting.dart'; import 'package:uni/view/Widgets/page_title.dart'; @@ -16,10 +18,9 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { var _editedPreferences = false; final Map notificationSettings = { NotificationType.classNotif: NotificationPreference( - antecedence: 0, - notificationType: NotificationType.classNotif.typeName, - isActive: false - ) + antecedence: 0, + notificationType: NotificationType.classNotif.typeName, + isActive: false) }; NotificationSettingsPageViewState() { @@ -39,15 +40,13 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { getCommitButtons() { if (_editedPreferences) { return Padding( - padding: EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ + padding: EdgeInsets.all(10), + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ ElevatedButton( child: Text('Descartar'), style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.grey) - ), + backgroundColor: + MaterialStateProperty.all(Colors.grey)), onPressed: () { retrieveSettingsFromDatabase(); }, @@ -57,9 +56,7 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { child: Text('Guardar'), onPressed: savePreferences, ), - ] - ) - ); + ])); } return SizedBox.shrink(); } @@ -79,28 +76,38 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { Widget getBody(BuildContext context) { return Column( children: [ - PageTitle( - name: 'Notificações' - ), + PageTitle(name: 'Notificações'), NotificationSetting( notificationType: NotificationType.classNotif, switched: notificationSettings[NotificationType.classNotif].isActive, onSwitchChanged: (value) { setState(() { _editedPreferences = true; - notificationSettings[NotificationType.classNotif] - .isActive = value; + notificationSettings[NotificationType.classNotif].isActive = + value; }); }, onSliderChanged: (value) { setState(() => _editedPreferences = true); - notificationSettings[NotificationType.classNotif] - .antecedence = value.toInt(); + notificationSettings[NotificationType.classNotif].antecedence = + value.toInt(); }, - initialSliderValue: notificationSettings[NotificationType.classNotif] - .antecedence, + initialSliderValue: + notificationSettings[NotificationType.classNotif].antecedence, ), - getCommitButtons() + getCommitButtons(), + Align( + alignment: Alignment.center, + child: ElevatedButton( + child: const Text('Ver Horário'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SchedulePage())); + }, + ), + ) ], ); } diff --git a/app/lib/view/Pages/schedule_page_view.dart b/app/lib/view/Pages/schedule_page_view.dart index 3d5e9f7..b3e69eb 100644 --- a/app/lib/view/Pages/schedule_page_view.dart +++ b/app/lib/view/Pages/schedule_page_view.dart @@ -1,6 +1,7 @@ import 'package:uni/model/app_state.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:flutter/material.dart'; +import 'package:uni/view/Pages/notification_settings_page_view.dart'; import 'package:uni/view/Widgets/page_title.dart'; import 'package:uni/view/Widgets/request_dependent_widget_builder.dart'; import 'package:uni/view/Widgets/schedule_slot.dart'; @@ -44,7 +45,19 @@ class SchedulePageView extends StatelessWidget { child: TabBarView( controller: tabController, children: createSchedule(context), - )) + )), + Align( + alignment: Alignment.center, + child: ElevatedButton( + child: const Text('Ver menu notificações'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NotificationSettingsPageView())); + }, + ), + ) ]); } From 147bbc96630c030966b3e1c91cc7312e5c3f5ce3 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Mon, 6 Jun 2022 19:23:39 +0100 Subject: [PATCH 16/20] Improved parameters used to generate lecture id's (#67) --- app/lib/model/entities/lecture.dart | 20 ++++++------- .../lecture_id_uniqueness_test.dart | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/app/lib/model/entities/lecture.dart b/app/lib/model/entities/lecture.dart index eeecc75..f016c82 100644 --- a/app/lib/model/entities/lecture.dart +++ b/app/lib/model/entities/lecture.dart @@ -33,7 +33,7 @@ class Lecture { //We assume that there is only one class of a given type in a given day BigInt agregatedId = BigInt.from(0); List hashBytes = - sha256.convert(utf8.encode('$subject-$typeClass-$day')).bytes; + sha256.convert(utf8.encode('$subject-$typeClass-$day-$startTime-$room')).bytes; for (var byte in hashBytes) { agregatedId += BigInt.from(byte); } @@ -181,13 +181,13 @@ class Lecture { @override bool operator ==(o) => o is Lecture && - this.subject == o.subject && - this.startTime == o.startTime && - this.endTime == o.endTime && - this.typeClass == o.typeClass && - this.room == o.room && - this.teacher == o.teacher && - this.day == o.day && - this.blocks == o.blocks && - this.startTimeSeconds == o.startTimeSeconds; + this.subject == o.subject && + this.startTime == o.startTime && + this.endTime == o.endTime && + this.typeClass == o.typeClass && + this.room == o.room && + this.teacher == o.teacher && + this.day == o.day && + this.blocks == o.blocks && + this.startTimeSeconds == o.startTimeSeconds; } diff --git a/app/test/unit/notification/lecture_id_uniqueness_test.dart b/app/test/unit/notification/lecture_id_uniqueness_test.dart index b2979fb..df428e5 100644 --- a/app/test/unit/notification/lecture_id_uniqueness_test.dart +++ b/app/test/unit/notification/lecture_id_uniqueness_test.dart @@ -3,9 +3,9 @@ import 'package:uni/model/entities/lecture.dart'; void main() { final Lecture lecture1 = - Lecture('ESOF', 'TP', 1, 2, 'B204', 'AOR', '3LEIC08', 10, 30, 12, 30); + Lecture('ESOF', 'TP', 1, 2, 'B204', 'AOR', '3LEIC08', 10, 30, 12, 30); final Lecture lecture2 = - Lecture('IA', 'TP', 2, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); + Lecture('IA', 'TP', 2, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); group('Lecture ID Uniqueness', () { test('Equal Lecture ID', () { expect(lecture1.id, lecture1.id); @@ -13,5 +13,30 @@ void main() { test('Different Lecture ID', () { expect(lecture1.id, isNot(equals(lecture2.id))); }); + test('Different Subject changes ID', () { + final Lecture lecture2WithDiferentSubject = + Lecture('COMP', 'TP', 2, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); + expect(lecture2.id, isNot(equals(lecture2WithDiferentSubject))); + }); + test('Different TypeClass changes ID', () { + final Lecture lecture2TypeClassChangesId = + Lecture('IA', 'T', 2, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); + expect(lecture2.id, isNot(equals(lecture2TypeClassChangesId))); + }); + test('Different Day changes ID', () { + final Lecture lecture2DayChangesId = + Lecture('IA', 'TP', 3, 2, 'B204', 'HLC', '3LEIC08', 8, 30, 10, 30); + expect(lecture2.id, isNot(equals(lecture2DayChangesId))); + }); + test('Different Start Time changes ID', () { + final Lecture lecture2StartTimeChangesId = + Lecture('IA', 'TP', 2, 2, 'B204', 'HLC', '3LEIC08', 9, 30, 10, 30); + expect(lecture2.id, isNot(equals(lecture2StartTimeChangesId))); + }); + test('Different Room changes ID', () { + final Lecture lecture2RoomChangesId = + Lecture('IA', 'TP', 2, 2, 'B205', 'HLC', '3LEIC08', 8, 30, 10, 30); + expect(lecture2.id, isNot(equals(lecture2RoomChangesId))); + }); }); } From 8415c2d9680c2df2a0fa8c3167fad4f49428da23 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Mon, 6 Jun 2022 23:32:39 +0100 Subject: [PATCH 17/20] Fix: Notification schedule granularity is 5 min now (#69) --- app/lib/utils/constants.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/utils/constants.dart b/app/lib/utils/constants.dart index 1c0ec58..6a986ec 100644 --- a/app/lib/utils/constants.dart +++ b/app/lib/utils/constants.dart @@ -33,7 +33,7 @@ extension NotificationWidgetData on NotificationType { NotificationType.classNotif: 30.0 }; static const notificationAntecedenceGranularities = { - NotificationType.classNotif: 5 + NotificationType.classNotif: 6 }; static const notificationAntecedenceSuffixes = { From db4b3137352684f7e11638c936fb97b361512ad6 Mon Sep 17 00:00:00 2001 From: Francisco Oliveira <71940096+frpdoliv@users.noreply.github.com> Date: Mon, 6 Jun 2022 23:37:19 +0100 Subject: [PATCH 18/20] WI: Changes backend when changing preferences in schedule (#68) * Changed the way icons are switched * Now updating notification preferences in scheduler changes notification preferences in backend --- ...ure_notification_preferences_database.dart | 19 ++++++ app/lib/view/Pages/schedule_page_view.dart | 4 +- app/lib/view/Widgets/button_stateful.dart | 62 +++++++++++++++---- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart b/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart index f7aa090..5187e0a 100644 --- a/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart +++ b/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart @@ -40,6 +40,25 @@ class AppLectureNotificationPreferencesDatabase extends AppDatabase { this.saveNewLectureNotificationPreferences(preferences); } + Future setNotificationPreference(int lectureId, bool activationValue) async { + final Database db = await this.getDatabase(); + + await db.update('lectureNotificationPreferences', { + 'isActive': activationValue + }, where: 'id = ?', whereArgs: [lectureId]); + } + + Future getNotificationPreference(int lectureId) async { + final Database db = await this.getDatabase(); + var preference = await db.query( + 'lectureNotificationPreferences', + columns: ['isActive'], + where: 'id = ?', + whereArgs: [lectureId] + ); + return preference[0]['isActive'] == 1 ? true : false; + } + /// Returns a list containing all of the lectures stored in this database. Future> preferences() async { // Get a reference to the database diff --git a/app/lib/view/Pages/schedule_page_view.dart b/app/lib/view/Pages/schedule_page_view.dart index b3e69eb..a6c97ce 100644 --- a/app/lib/view/Pages/schedule_page_view.dart +++ b/app/lib/view/Pages/schedule_page_view.dart @@ -101,7 +101,9 @@ class SchedulePageView extends StatelessWidget { teacher: lecture.teacher, classNumber: lecture.classNumber, ), - ScheduleButton() + ScheduleButton( + lectureId: lecture.id + ) ], ); diff --git a/app/lib/view/Widgets/button_stateful.dart b/app/lib/view/Widgets/button_stateful.dart index 45ddcdb..c31eef0 100644 --- a/app/lib/view/Widgets/button_stateful.dart +++ b/app/lib/view/Widgets/button_stateful.dart @@ -1,31 +1,67 @@ import 'package:flutter/material.dart'; +import 'package:uni/controller/local_storage/app_lecture_notification_preferences_database.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; class ScheduleButton extends StatefulWidget { + final int lectureId; + + ScheduleButton({ + @required this.lectureId + }); + @override - ScheduleButtonState createState() => new ScheduleButtonState(); + ScheduleButtonState createState() => ScheduleButtonState( + lectureId: this.lectureId + ); } class ScheduleButtonState extends State { - int fabIconNumber = 0; - List icons = [Icons.alarm_off_rounded, Icons.alarm_add_rounded]; - IconData ic = Icons.alarm_add_rounded; + final int lectureId; + bool notificationActivated = false; + + ScheduleButtonState({ + @required this.lectureId + }) { + retrieveActivationStatus(); + } + + void retrieveActivationStatus() async { + notificationActivated = await AppLectureNotificationPreferencesDatabase() + .getNotificationPreference( + lectureId + ); + if (mounted) { + setState(() {}); + } + } void onPressed() { - //add another function here if needed setState(() { - fabIconNumber = fabIconNumber % 2 == 0 ? 0 : 1; - ic = icons[fabIconNumber]; - fabIconNumber++; + notificationActivated = !notificationActivated; }); + AppLectureNotificationPreferencesDatabase().setNotificationPreference( + lectureId, + notificationActivated + ); + resetNotifications(); } @override Widget build(BuildContext context) { - return new Align( - alignment: new Alignment(0.65, 0), - child: new Transform.scale( + return Align( + alignment: Alignment(0.65, 0), + child: Transform.scale( scale: 0.8, - child: new FloatingActionButton( - onPressed: onPressed, child: Icon(ic), heroTag: null))); + child: FloatingActionButton( + onPressed: onPressed, + child: Icon( + notificationActivated ? + Icons.alarm_off_rounded : + Icons.alarm_add_rounded + ), + heroTag: null + ) + ) + ); } } From e3bc2af0e88717caa23a37e6dd3607473b7b14fb Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Tue, 7 Jun 2022 08:57:56 +0100 Subject: [PATCH 19/20] Notifications menu is now unavailable for non permanent sessions (#73) * Notifications menu is now unavailable for non permanent sessions * All fixed --- ...ure_notification_preferences_database.dart | 23 +++--- .../app_notification_data_database.dart | 4 - ...app_notification_preferences_database.dart | 13 ++-- app/lib/controller/logout.dart | 6 +- .../notifications/notification_scheduler.dart | 23 +++--- .../notifications/notification_setup.dart | 34 ++++++--- app/lib/redux/action_creators.dart | 6 +- .../notification_settings_page_view.dart | 74 ++++++++++++++++++- app/lib/view/Pages/schedule_page_view.dart | 10 +-- app/lib/view/Widgets/button_stateful.dart | 67 ----------------- .../Widgets/schedule_button_stateful.dart | 74 +++++++++++++++++++ 11 files changed, 203 insertions(+), 131 deletions(-) delete mode 100644 app/lib/view/Widgets/button_stateful.dart create mode 100644 app/lib/view/Widgets/schedule_button_stateful.dart diff --git a/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart b/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart index 5187e0a..6135fda 100644 --- a/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart +++ b/app/lib/controller/local_storage/app_lecture_notification_preferences_database.dart @@ -26,6 +26,7 @@ class AppLectureNotificationPreferencesDatabase extends AppDatabase { /// Replaces all of the data in this database with [preferences]. saveNewLectureNotificationPreferences( List preferences) async { + Logger().i('Saving new lecture preferences: ${preferences}'); await deleteLectureNotificationPreferences(); await _insertLectureNotificationPreferences(preferences); } @@ -37,25 +38,23 @@ class AppLectureNotificationPreferencesDatabase extends AppDatabase { for (Lecture l in lectures) { preferences.add(LectureNotificationPreference(l.id, true)); } - this.saveNewLectureNotificationPreferences(preferences); + await this.saveNewLectureNotificationPreferences(preferences); } - Future setNotificationPreference(int lectureId, bool activationValue) async { + Future setNotificationPreference( + int lectureId, bool activationValue) async { final Database db = await this.getDatabase(); - - await db.update('lectureNotificationPreferences', { - 'isActive': activationValue - }, where: 'id = ?', whereArgs: [lectureId]); + Logger() + .i('Updating lecture preference: ${lectureId} - ${activationValue}'); + await db.update( + 'lectureNotificationPreferences', {'isActive': activationValue}, + where: 'id = ?', whereArgs: [lectureId]); } Future getNotificationPreference(int lectureId) async { final Database db = await this.getDatabase(); - var preference = await db.query( - 'lectureNotificationPreferences', - columns: ['isActive'], - where: 'id = ?', - whereArgs: [lectureId] - ); + var preference = await db.query('lectureNotificationPreferences', + columns: ['isActive'], where: 'id = ?', whereArgs: [lectureId]); return preference[0]['isActive'] == 1 ? true : false; } diff --git a/app/lib/controller/local_storage/app_notification_data_database.dart b/app/lib/controller/local_storage/app_notification_data_database.dart index 911d87d..438fbdd 100644 --- a/app/lib/controller/local_storage/app_notification_data_database.dart +++ b/app/lib/controller/local_storage/app_notification_data_database.dart @@ -1,11 +1,7 @@ import 'dart:async'; -import 'package:logger/logger.dart'; import 'package:uni/controller/local_storage/app_database.dart'; import 'package:sqflite/sqflite.dart'; import 'package:uni/model/entities/notification_data.dart'; -import 'package:uni/model/entities/notification_preference.dart'; -import 'package:uni/model/notifications/notification.dart'; -import 'package:uni/utils/constants.dart'; /// Manages the app's Notifications Data database. /// diff --git a/app/lib/controller/local_storage/app_notification_preferences_database.dart b/app/lib/controller/local_storage/app_notification_preferences_database.dart index 8c86738..0d20dff 100644 --- a/app/lib/controller/local_storage/app_notification_preferences_database.dart +++ b/app/lib/controller/local_storage/app_notification_preferences_database.dart @@ -23,6 +23,7 @@ class AppNotificationPreferencesDatabase extends AppDatabase { /// Replaces all of the data in this database with [preferences]. saveNewPreferences(List preferences) async { + Logger().i('Saving new preferences: ${preferences}'); await deletePreferences(); await _insertPreferences(preferences); } @@ -64,8 +65,7 @@ class AppNotificationPreferencesDatabase extends AppDatabase { final List> rawPreferences = await db.query( 'notification_preferences', where: 'notificationType = ?', - whereArgs: [type.typeName] - ); + whereArgs: [type.typeName]); final wantedPreference = rawPreferences[0]; return NotificationPreference.fromHtml(wantedPreference['isActive'], @@ -74,13 +74,10 @@ class AppNotificationPreferencesDatabase extends AppDatabase { Future replacePreference(NotificationPreference preference) async { final Database db = await this.getDatabase(); - Logger().d("Updating preference: ${preference.toMap()}"); - await db.update( - 'notification_preferences', - preference.toMap(), + Logger().i('Updating preference: ${preference.toMap()}'); + await db.update('notification_preferences', preference.toMap(), where: 'notificationType = ?', - whereArgs: [preference.notificationType] - ); + whereArgs: [preference.notificationType]); } /// Deletes all of the data stored in this database. diff --git a/app/lib/controller/logout.dart b/app/lib/controller/logout.dart index 6972d80..38d0b04 100644 --- a/app/lib/controller/logout.dart +++ b/app/lib/controller/logout.dart @@ -16,6 +16,7 @@ import 'package:uni/controller/local_storage/app_lectures_database.dart'; import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; import 'package:uni/controller/local_storage/app_refresh_times_database.dart'; import 'package:uni/controller/local_storage/app_user_database.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; import 'package:uni/model/app_state.dart'; import 'package:uni/redux/action_creators.dart'; import 'package:uni/view/Pages/general_page_view.dart'; @@ -24,9 +25,10 @@ Future logout(BuildContext context) async { final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - AppLectureNotificationPreferencesDatabase() + await AppLectureNotificationPreferencesDatabase() .deleteLectureNotificationPreferences(); - AppNotificationPreferencesDatabase().deletePreferences(); + await AppNotificationPreferencesDatabase().deletePreferences(); + await deleteNotifications(); AppLecturesDatabase().deleteLectures(); AppExamsDatabase().deleteExams(); AppCoursesDatabase().deleteCourses(); diff --git a/app/lib/controller/notifications/notification_scheduler.dart b/app/lib/controller/notifications/notification_scheduler.dart index 206cf62..0f9e2fa 100644 --- a/app/lib/controller/notifications/notification_scheduler.dart +++ b/app/lib/controller/notifications/notification_scheduler.dart @@ -1,8 +1,5 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:logger/logger.dart'; -import 'package:redux/redux.dart'; -import 'package:uni/model/app_state.dart'; -import 'package:uni/model/entities/notification_preference.dart'; import 'package:uni/utils/constants.dart'; import 'package:uni/model/notifications/notification.dart'; import 'package:timezone/timezone.dart' as tz; @@ -44,6 +41,7 @@ class NotificationScheduler { } Future unscheduleAll() async { + Logger().i('Unscheduling all notifications'); NotificationScheduler._notificationPlugin.cancelAll(); } @@ -52,15 +50,14 @@ class NotificationScheduler { Logger().i( "Scheduled Notification '${notification.toString()}' to '${scheduledTime.toString()}' "); await _notificationPlugin.zonedSchedule( - notification.id, - notification.title, - notification.body, - scheduledTime, - _buildPlatformChannelSpecifics(notification), - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - androidAllowWhileIdle: true, - matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime - ); + notification.id, + notification.title, + notification.body, + scheduledTime, + _buildPlatformChannelSpecifics(notification), + uiLocalNotificationDateInterpretation: + UILocalNotificationDateInterpretation.absoluteTime, + androidAllowWhileIdle: true, + matchDateTimeComponents: DateTimeComponents.dayOfWeekAndTime); } } diff --git a/app/lib/controller/notifications/notification_setup.dart b/app/lib/controller/notifications/notification_setup.dart index 44346b2..f1dd2d5 100644 --- a/app/lib/controller/notifications/notification_setup.dart +++ b/app/lib/controller/notifications/notification_setup.dart @@ -1,6 +1,4 @@ -import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:logger/logger.dart'; -import 'package:redux/redux.dart'; import 'package:tuple/tuple.dart'; import 'package:uni/controller/local_storage/app_lecture_notification_preferences_database.dart'; import 'package:uni/controller/local_storage/app_lectures_database.dart'; @@ -8,7 +6,6 @@ import 'package:uni/controller/local_storage/app_notification_data_database.dart import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; import 'package:uni/controller/local_storage/app_shared_preferences.dart'; import 'package:uni/controller/notifications/notification_scheduler.dart'; -import 'package:uni/model/app_state.dart'; import 'package:uni/model/entities/lecture.dart'; import 'package:uni/model/entities/lecture_notification_preference.dart'; import 'package:uni/model/entities/notification_data.dart'; @@ -34,17 +31,37 @@ Future> notificationPreferences() async { } Future> notificationsData() async { - return AppNotificationDataDatabase().notificationsData(); + return await AppNotificationDataDatabase().notificationsData(); } Future> lectureNotificationPreferences() async { - return AppLectureNotificationPreferencesDatabase().preferences(); + final AppLecturesDatabase lecturesDb = AppLecturesDatabase(); + final AppLectureNotificationPreferencesDatabase + lecturesNotificationPreferencesDb = + AppLectureNotificationPreferencesDatabase(); + List lectures = await lecturesDb.lectures(); + final List lectureNotificationPreferences = + await lecturesNotificationPreferencesDb.preferences(); + // While lectures have not been saved in database from remote + while (lectures.isEmpty) { + await Future.delayed(const Duration(milliseconds: 100)); + lectures = await lecturesDb.lectures(); + } + if (lectures.length > lectureNotificationPreferences.length) { + await lecturesNotificationPreferencesDb + .saveNewPreferencesThroughLectures(lectures); + } + return await AppLectureNotificationPreferencesDatabase().preferences(); } -Future resetNotifications() async { +Future deleteNotifications() async { NotificationScheduler().unscheduleAll(); await AppNotificationDataDatabase().deleteNotificationsData(); +} + +Future resetNotifications() async { + await deleteNotifications(); await notificationSetUp(); } @@ -65,9 +82,7 @@ Future notificationSetUp() async { Future classNotificationSetUp(int antecedence) async { final preferences = await lectureNotificationPreferences(); - Logger().i('Lecture preferences:' + preferences.toString()); final alreadyScheduled = await notificationsData(); - Logger().i('Notification data:' + alreadyScheduled.toString()); final List lectures = await AppLecturesDatabase().lectures(); for (Lecture lecture in lectures) { if (!shouldScheduleClass(lecture, alreadyScheduled, preferences)) { @@ -94,7 +109,8 @@ bool shouldScheduleClass( notificationsData, lecture.id) && LectureNotificationPreference.idIsActive(preferences, lecture.id); } catch (e) { - Logger().e('Error: ${e.cause}/${lecture.subject}-${lecture.typeClass}-${lecture.day}'); + Logger().e( + 'Error: ${e.cause}/${lecture.subject}-${lecture.typeClass}-${lecture.day}'); } return false; } diff --git a/app/lib/redux/action_creators.dart b/app/lib/redux/action_creators.dart index 1397627..d39f1c6 100644 --- a/app/lib/redux/action_creators.dart +++ b/app/lib/redux/action_creators.dart @@ -59,6 +59,7 @@ ThunkAction reLogin(username, password, faculty, {Completer action}) { if (session.authenticated) { await loadRemoteUserInfoToState(store); store.dispatch(SetLoginStatusAction(RequestStatus.successful)); + await notificationSetUp(); action?.complete(); } else { store.dispatch(SetLoginStatusAction(RequestStatus.failed)); @@ -279,12 +280,7 @@ ThunkAction getUserSchedule( // Updates local database according to the information fetched -- Lectures if (userPersistentInfo.item1 != '' && userPersistentInfo.item2 != '') { final AppLecturesDatabase lecturesDb = AppLecturesDatabase(); - final AppLectureNotificationPreferencesDatabase - lecturesNotificationPreferencesDb = - AppLectureNotificationPreferencesDatabase(); lecturesDb.saveNewLectures(lectures); - lecturesNotificationPreferencesDb - .saveNewPreferencesThroughLectures(lectures); } store.dispatch(SetScheduleAction(lectures)); diff --git a/app/lib/view/Pages/notification_settings_page_view.dart b/app/lib/view/Pages/notification_settings_page_view.dart index 3ee51d2..d0be90b 100644 --- a/app/lib/view/Pages/notification_settings_page_view.dart +++ b/app/lib/view/Pages/notification_settings_page_view.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; import 'package:uni/controller/local_storage/app_notification_preferences_database.dart'; +import 'package:uni/controller/local_storage/app_shared_preferences.dart'; import 'package:uni/model/entities/notification_preference.dart'; import 'package:uni/model/schedule_page_model.dart'; import 'package:uni/utils/constants.dart'; -import 'package:uni/view/Pages/schedule_page_view.dart'; import 'package:uni/view/Pages/secondary_page_view.dart'; import 'package:uni/view/Widgets/notification_setting.dart'; import 'package:uni/view/Widgets/page_title.dart'; @@ -15,7 +16,8 @@ class NotificationSettingsPageView extends StatefulWidget { } class NotificationSettingsPageViewState extends SecondaryPageViewState { - var _editedPreferences = false; + bool _editedPreferences = false; + bool _permanentSession = false; final Map notificationSettings = { NotificationType.classNotif: NotificationPreference( antecedence: 0, @@ -24,10 +26,12 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { }; NotificationSettingsPageViewState() { - retrieveSettingsFromDatabase(); + retrievePermanentSession().then((value) => { + if (_permanentSession) {retrieveSettingsFromDatabase()} + }); } - retrieveSettingsFromDatabase() async { + Future retrieveSettingsFromDatabase() async { final db = AppNotificationPreferencesDatabase(); for (var key in notificationSettings.keys) { notificationSettings[key] = await db.getPreference(key); @@ -37,6 +41,18 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { } } + Future retrievePermanentSession() async { + final Tuple2 userPersistentInfo = + await AppSharedPreferences.getPersistentUserInfo(); + if (mounted) { + if (userPersistentInfo.item1 == '' || userPersistentInfo.item2 == '') { + setState(() => _permanentSession = false); + } else { + setState(() => _permanentSession = true); + } + } + } + getCommitButtons() { if (_editedPreferences) { return Padding( @@ -74,6 +90,56 @@ class NotificationSettingsPageViewState extends SecondaryPageViewState { @override Widget getBody(BuildContext context) { + final Column body = Column( + children: [ + PageTitle(name: 'Notificações'), + NotificationSetting( + notificationType: NotificationType.classNotif, + switched: notificationSettings[NotificationType.classNotif].isActive, + onSwitchChanged: (value) { + setState(() { + _editedPreferences = true; + notificationSettings[NotificationType.classNotif].isActive = + value; + }); + }, + onSliderChanged: (value) { + setState(() => _editedPreferences = true); + notificationSettings[NotificationType.classNotif].antecedence = + value.toInt(); + }, + initialSliderValue: + notificationSettings[NotificationType.classNotif].antecedence, + ), + getCommitButtons(), + Align( + alignment: Alignment.center, + child: ElevatedButton( + child: const Text('Ver Horário'), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SchedulePage())); + }, + ), + ) + ], + ); + final Column noBody = Column(children: [ + PageTitle(name: 'Notificações'), + Align( + alignment: Alignment.center, + child: const Text( + 'You need to be in a permanent session to access this feature', + textAlign: TextAlign.center), + ) + ]); + if (_permanentSession) { + return body; + } else { + return noBody; + } return Column( children: [ PageTitle(name: 'Notificações'), diff --git a/app/lib/view/Pages/schedule_page_view.dart b/app/lib/view/Pages/schedule_page_view.dart index a6c97ce..b4057b5 100644 --- a/app/lib/view/Pages/schedule_page_view.dart +++ b/app/lib/view/Pages/schedule_page_view.dart @@ -5,7 +5,7 @@ import 'package:uni/view/Pages/notification_settings_page_view.dart'; import 'package:uni/view/Widgets/page_title.dart'; import 'package:uni/view/Widgets/request_dependent_widget_builder.dart'; import 'package:uni/view/Widgets/schedule_slot.dart'; -import 'package:uni/view/Widgets/button_stateful.dart'; +import 'package:uni/view/Widgets/schedule_button_stateful.dart'; /// Manages the 'schedule' sections of the app class SchedulePageView extends StatelessWidget { @@ -88,9 +88,7 @@ class SchedulePageView extends StatelessWidget { for (int i = 0; i < lectures.length; i++) { final Lecture lecture = lectures[i]; - IconData ic = Icons.alarm_add_rounded; - - Stack stk = Stack( + final Stack stk = Stack( children: [ ScheduleSlot( subject: lecture.subject, @@ -101,9 +99,7 @@ class SchedulePageView extends StatelessWidget { teacher: lecture.teacher, classNumber: lecture.classNumber, ), - ScheduleButton( - lectureId: lecture.id - ) + ScheduleButton(lectureId: lecture.id) ], ); diff --git a/app/lib/view/Widgets/button_stateful.dart b/app/lib/view/Widgets/button_stateful.dart deleted file mode 100644 index c31eef0..0000000 --- a/app/lib/view/Widgets/button_stateful.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:uni/controller/local_storage/app_lecture_notification_preferences_database.dart'; -import 'package:uni/controller/notifications/notification_setup.dart'; - -class ScheduleButton extends StatefulWidget { - final int lectureId; - - ScheduleButton({ - @required this.lectureId - }); - - @override - ScheduleButtonState createState() => ScheduleButtonState( - lectureId: this.lectureId - ); -} - -class ScheduleButtonState extends State { - final int lectureId; - bool notificationActivated = false; - - ScheduleButtonState({ - @required this.lectureId - }) { - retrieveActivationStatus(); - } - - void retrieveActivationStatus() async { - notificationActivated = await AppLectureNotificationPreferencesDatabase() - .getNotificationPreference( - lectureId - ); - if (mounted) { - setState(() {}); - } - } - - void onPressed() { - setState(() { - notificationActivated = !notificationActivated; - }); - AppLectureNotificationPreferencesDatabase().setNotificationPreference( - lectureId, - notificationActivated - ); - resetNotifications(); - } - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment(0.65, 0), - child: Transform.scale( - scale: 0.8, - child: FloatingActionButton( - onPressed: onPressed, - child: Icon( - notificationActivated ? - Icons.alarm_off_rounded : - Icons.alarm_add_rounded - ), - heroTag: null - ) - ) - ); - } -} diff --git a/app/lib/view/Widgets/schedule_button_stateful.dart b/app/lib/view/Widgets/schedule_button_stateful.dart new file mode 100644 index 0000000..c095ab6 --- /dev/null +++ b/app/lib/view/Widgets/schedule_button_stateful.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; +import 'package:uni/controller/local_storage/app_lecture_notification_preferences_database.dart'; +import 'package:uni/controller/local_storage/app_shared_preferences.dart'; +import 'package:uni/controller/notifications/notification_setup.dart'; + +class ScheduleButton extends StatefulWidget { + final int lectureId; + + ScheduleButton({@required this.lectureId}); + + @override + ScheduleButtonState createState() => + ScheduleButtonState(lectureId: this.lectureId); +} + +class ScheduleButtonState extends State { + final int lectureId; + bool _notificationActivated = false; + bool _permanentSession = false; + + ScheduleButtonState({@required this.lectureId}) { + retrievePermanentSession().then((value) => { + if (_permanentSession) {retrieveActivationStatus()} + }); + } + + Future retrievePermanentSession() async { + final Tuple2 userPersistentInfo = + await AppSharedPreferences.getPersistentUserInfo(); + if (mounted) { + if (userPersistentInfo.item1 == '' || userPersistentInfo.item2 == '') { + setState(() => _permanentSession = false); + } else { + setState(() => _permanentSession = true); + } + } + } + + Future retrieveActivationStatus() async { + final bool notificationActivated = + await AppLectureNotificationPreferencesDatabase() + .getNotificationPreference(lectureId); + if (mounted) { + setState(() { + _notificationActivated = notificationActivated; + }); + } + } + + void onPressed() { + if (!_permanentSession) return; + setState(() { + _notificationActivated = !_notificationActivated; + }); + AppLectureNotificationPreferencesDatabase() + .setNotificationPreference(lectureId, _notificationActivated); + resetNotifications(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment(0.65, 0), + child: Transform.scale( + scale: 0.8, + child: FloatingActionButton( + onPressed: onPressed, + child: Icon(_notificationActivated + ? Icons.alarm_add_rounded + : Icons.alarm_off_rounded), + heroTag: null))); + } +} From 82c89a0f597b84c93ed095222cb8b97fc0057758 Mon Sep 17 00:00:00 2001 From: Marcelo Couto Date: Tue, 7 Jun 2022 11:23:28 +0100 Subject: [PATCH 20/20] Fix: bug duplicate scheduling in relogin #77 --- app/lib/redux/action_creators.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/redux/action_creators.dart b/app/lib/redux/action_creators.dart index d39f1c6..0c475bd 100644 --- a/app/lib/redux/action_creators.dart +++ b/app/lib/redux/action_creators.dart @@ -59,7 +59,7 @@ ThunkAction reLogin(username, password, faculty, {Completer action}) { if (session.authenticated) { await loadRemoteUserInfoToState(store); store.dispatch(SetLoginStatusAction(RequestStatus.successful)); - await notificationSetUp(); + await resetNotifications(); action?.complete(); } else { store.dispatch(SetLoginStatusAction(RequestStatus.failed));