From 13dfd482c1dc658a6a4b92544556d06349e20910 Mon Sep 17 00:00:00 2001 From: Rosario Fernandes Date: Wed, 14 Oct 2026 16:07:14 +0100 Subject: [PATCH 1/2] feat(friendlymeals-flutter): implement missing features --- .../flutter/android/app/build.gradle.kts | 8 +- .../android/app/src/main/AndroidManifest.xml | 20 +- .../example/friendlymeals}/MainActivity.kt | 2 +- .../flutter/android/gradle.properties | 6 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../flutter/android/settings.gradle.kts | 4 +- .../assets/images/ic_launcher_no_bg.png | Bin 0 -> 180243 bytes .../flutter/lib/data/app_repositories.dart | 48 + .../data/datasource/ai_remote_datasource.dart | 235 +++++ .../datasource/auth_remote_datasource.dart | 16 + .../database_remote_datasource.dart | 447 +++++++++ .../datasource/live_ai_remote_datasource.dart | 77 ++ .../datasource/remote_config_service.dart | 83 ++ .../datasource/storage_remote_datasource.dart | 68 ++ .../flutter/lib/data/model/grocery_item.dart | 45 + .../flutter/lib/data/model/like.dart | 23 + .../flutter/lib/data/model/recipe.dart | 99 ++ .../flutter/lib/data/model/review.dart | 27 + .../flutter/lib/data/model/user.dart | 13 + .../lib/data/repository/ai_repository.dart | 59 ++ .../lib/data/repository/auth_repository.dart | 15 + .../data/repository/database_repository.dart | 86 ++ .../data/repository/live_ai_repository.dart | 14 + .../data/repository/storage_repository.dart | 12 + .../flutter/lib/data/schema/meal_schema.dart | 28 + .../lib/data/schema/recipe_schema.dart | 53 + .../flutter/lib/data/schema/store_schema.dart | 50 + .../flutter/lib/main.dart | 217 +++- .../flutter/lib/recipe_generator_screen.dart | 125 --- .../lib/ui/generate/generate_screen.dart | 370 +++++++ .../ui/grocery_list/grocery_list_screen.dart | 847 ++++++++++++++++ .../lib/ui/live/live_assistant_screen.dart | 482 +++++++++ .../flutter/lib/ui/recipe/recipe_screen.dart | 859 ++++++++++++++++ .../lib/ui/recipe_list/filter_options.dart | 45 + .../lib/ui/recipe_list/filter_screen.dart | 356 +++++++ .../recipe_list/recipe_list_controller.dart | 115 +++ .../ui/recipe_list/recipe_list_screen.dart | 206 ++++ .../lib/ui/scan_meal/scan_meal_screen.dart | 329 +++++++ .../lib/ui/shared/audio_component.dart | 122 +++ .../flutter/lib/ui/shared/bottom_nav_bar.dart | 49 + .../lib/ui/shared/camera_component.dart | 158 +++ .../lib/ui/shared/loading_indicator.dart | 26 + .../flutter/lib/ui/shared/rating_button.dart | 56 ++ .../flutter/lib/ui/theme/app_theme.dart | 50 + .../flutter/pubspec.lock | 928 +++++++++++++++++- .../flutter/pubspec.yaml | 91 +- .../flutter/test/widget_test.dart | 122 ++- 47 files changed, 6813 insertions(+), 280 deletions(-) rename firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/{example/myapp => google/firebase/example/friendlymeals}/MainActivity.kt (65%) create mode 100644 firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/app_repositories.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/model/like.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/model/review.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/model/user.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart delete mode 100644 firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart create mode 100644 firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart diff --git a/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts b/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts index 36b46fe0..931ca920 100644 --- a/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts +++ b/firebase-ai-friendly-meals/flutter/android/app/build.gradle.kts @@ -9,8 +9,9 @@ plugins { } android { - namespace = "com.example.myapp" + namespace = "com.google.firebase.example.friendlymeals" compileSdk = flutter.compileSdkVersion + ndkVersion = "28.2.13676358" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -22,11 +23,10 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.myapp" + applicationId = "com.google.firebase.example.friendlymeals" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 23 + minSdk = 26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName diff --git a/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml b/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml index de8dcecb..ae175960 100644 --- a/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml +++ b/firebase-ai-friendly-meals/flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,12 @@ + + + + + + + + + + + + + + + + + + diff --git a/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt b/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt similarity index 65% rename from firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt rename to firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt index 6aec4e99..abb92c4d 100644 --- a/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt +++ b/firebase-ai-friendly-meals/flutter/android/app/src/main/kotlin/com/google/firebase/example/friendlymeals/MainActivity.kt @@ -1,4 +1,4 @@ -package com.example.myapp +package com.google.firebase.example.friendlymeals import io.flutter.embedding.android.FlutterActivity diff --git a/firebase-ai-friendly-meals/flutter/android/gradle.properties b/firebase-ai-friendly-meals/flutter/android/gradle.properties index d2572314..f0e71c02 100644 --- a/firebase-ai-friendly-meals/flutter/android/gradle.properties +++ b/firebase-ai-friendly-meals/flutter/android/gradle.properties @@ -1,5 +1,9 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.java.home=/Users/rosariopf/Library/Java/JavaVirtualMachines/corretto-17.0.18/Contents/Home android.useAndroidX=true android.enableJetifier=true -gradle.user.home=/home/user/.gradle org.gradle.caching=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties b/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties index ac3b4792..02767eb1 100644 --- a/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties +++ b/firebase-ai-friendly-meals/flutter/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip diff --git a/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts b/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts index bd7522f7..ea334df4 100644 --- a/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts +++ b/firebase-ai-friendly-meals/flutter/android/settings.gradle.kts @@ -18,11 +18,11 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.11.1" apply false // START: FlutterFire Configuration id("com.google.gms.google-services") version("4.3.15") apply false // END: FlutterFire Configuration - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false } include(":app") diff --git a/firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png b/firebase-ai-friendly-meals/flutter/assets/images/ic_launcher_no_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..7f3569ccfcc2ee59d2bcbfab1dcb2e8c20b14e87 GIT binary patch literal 180243 zcmY&<1yEc|(>5-_-Q9vq5`w!2cXtggVPSE14;ma2AUFhfcXxM}#a;g8%6q@Rrl_LM zPS5l_{d7;Co;hKP@{%a;2;V_KK%huVi77)sKwiE5A;5v349%{ELqMQINQ()px$(^vJkHnR|EE4=Xe^+!nAQ>XtE6Eiqg=CFl_4zSWPMmqO$!+o@5 z4SHmN+fk!b{9mg$z<7`7HKkz0?ThL6PlH_F!~}*pgbDqxwNv>EN$K>geB z@cc$(_wn#jtFHei>L(Wn?)by9N=EGqjLbic6xlC+oD4aYqeU$HpJs?fp@{iFn^vMc z^PeodXg2i~yy))cujJBXgzx_}z7mA*fiD%ETAb?t)B0?m94wv}@Yr)wWApE*>s4>^ zLw18hkpD{Ch{MQ;tc6zt9s5*k_5TrfK%@i<({R!Ml=*ilG*DTrP&7F{t)YoKtSnGw zNB{nShWMsBrj6Op|J3|~1b)U^djGzrp&sG;KbqQTz0FjmqJvuU&l1<2MBdb~svWA- z*M%9J{TmO`H;sI-J6M}oFy8;u-0IQaBR+44RM`}Wt#48){-fy`0oXLpA39acll}*W z4eHx8#FwgHwJ04NG2ZL2g2$M@0wdIVP*6eqpT!^)F@g8kZy4&@zzp8JO->5=HZ_b8 zOVhs-zsIPb2a9@FVz4Z|4OIW{da%Jy_1hJ_6aSmo?rq}bOO3AvU>N_Idj8|v#8$t4 ztN!_)W!F32wC<>+cmCV_EPum^Mg_)KFw!td?!Phlw87#pmJ|jzI)9nwuTk5E-@xGb zapqM2uMu>xz<}57HYepV{Ds(GY8p7bX+9t)U8MLQP4{BJ?8_1~H;ek$ToDmM3lE|~ zU`vERXlwoaPfg`FvV5_Xkn#GLreIBV{#=7++V4*4`g`V{`{*BnD>&U z;C^JE_J^YX56cDK;JvG0()m~RFF-wmXTONNBCmhf`d6L?>IW=%L7#_Y6M_F())Nkf z6b{_6H2ME@q=AY-c*Bj7Eji=OmVY;0T^JU3k+%9S*1ydEw>9Sp-)!+|w~|==A3WO4 zqT9{n_ml6Ble^xm`_|?;-g=q+sv?&9x7SW27&AP_o1tI-XD#$^Ysrsgd8GeG5WJSq zH^4DETI%KguX`>>fW>c(m3se$Q3i&S8LYGYVo_6l_vm!x27N584WJqqOB{ny^iAz*cM z>|}-@_5O1@7CEqR+sD11)4%^Cz~!5Jjv7JsPbP%>vF!SvmpD8dmPj6jd(yX#_f4;0L8Gpn1TKy z02<8)y~gXWVD%+ApOOlj+D3gG9F*5sGG(Q zY?b8hkKbkfvVa{z^Ye+ly{XlnkHXk=7H^%$;_Po+0v@Sx30D1%$wkLy=L_|89uGQo z9lD93q_`in%35ffo;!!awO2mc2VojM7k7euFHT)$#2{!YLsG5=S>}H{r#=M%X67aV zaGVO9IF9D&WD}>;>vnpCN%}$O=z4Fr?0l%Asmtwm|8wiRiKbSTWN}@j@T5pOAns0{ z|K?<*e*>RJTS|2e_aY0Tm-Dbnk=Yx$a7Bd1Mk(%VcmXkEs)Y2xnmvSJ zPgk9K^^bwT9m$D9CUQsqcmR>(tYjKWTm#IgnbZa3RHMT$qMOa2a=s?Fr#S)l8?rL@ zI|>G!WmzzNTT{)8r)$8}FpMBSs}}f8Ip(?anYX|Z_~J?|=*%n6O4zfM*r9v&+F*(`|=^&w=Hw2h!y^g zC1;itNAd$#BKim(=G|p;O<)W{9KCee*N&R_x;XnWagZZ`YG}bWiD_;31{1 z967{Lfp=Q0np*tV@gDMPFZ`0x%c|~wdav(qbdr4B-blF7Q*BfFmw&H2i>wbamT{|> zf0Rc9dI2{O53h-ZQ>yJGF5nny*xlICfqOEm`oxileeI~^($Nr z4!3Blo|_44o2X%a z@X(XWLhK+&d{sSH##5k|X|;aIzSHkJ@PYNh9-5ZR=Hpz4shwIJcRF@y+8) zel-!>yfNlyVEgIt+jrfR$7Bn3f>(wdv=ompO%#c%YSHZ0;l zWitNR`0-$B?R|`H(@56N&gnp}vu$^q)~%p|){7#+r;MiEvgh9`yri#~z{3zY+qJ_w zW#A#??-_DseYQy6eH8U_Eq&fc15`3^(V9Y4Z(B+j(zM^;@ceOkQ2XzYjYr~F?!9=M zdAG~@e7;temhnn;3M;8cnoHsz+yM7kb5$nF0L0!mD!(n~JUH3J^2ES*<60@eT_rq1m-Aq$ur>z@W z4DpP1=t8Qr>LJCZi?7jQ##dMM)(w#0*+XTiuPAr$SY-R2%zjowB1hdnJKB0K#o6*+ zt8s<(rlU_~)$%I`cwhvtpxEA3R>-G9RffW8%FH~ph7*D8Zr&;HSNFNcBdrb3bB?MW z^m<}iK!ltYclsMihH(76E|!H)&jvOe_T#DjLa}qZ?TpCf_kTi5`1?=iX(#?TBYie1 zuUgdh*r)+5e)4WG25?6%>7oWO^=bZ<4pgY2huU1Hd|7FqJE#xxrwpW86EX{enJGeudK$m(~^E zYCW6u+Qwyjm`uvNUMvn?zk*NapvrsHV)!#fSYio zo(qrSx4cRH$HbTgpV`8j1>{27>(Pp5fTdq$757=5ZuH+lX<*J>Uhv(HEBYXoTRi{{ znSSY9Bm{WBevL}5x(4UoUPmKV+_`VU%#lL=8>i$5bS*D|Bo7L0jsDGv?bWicUbj!n zEg_fU-aGIvib<{@$}3C8-QS4|I%bylpXs1i<{$%lxgy}r{@p1B+x zWXj6Pw1WBP9`;C+ZDr2_$F>=iqS#LHkIu24yA zMdBY4&s_@n3_Hq%q8w!vUi>dxy*Zco?d@ z))|vG_8pS>>C?raFB!72=IK}f4ToKaq*9{U#rvwb?B-fxF#YWt=$YaF(g+8G?d0BT=Jz97 z@2|L9`fkE*=|S5o->y&n%8k3!cEbJ4x5NS7y68>uO`&E44jAhPH5Q!6o$=#HVzsjx z;AGocU}(Yy4u&%|I@6;;a-U??b15ek&1?Vq(eA#RDANVF@e3~_&1YzimE~omZfxwe z=XPZ5b>}vN`x5W<{_3FZ?q-T^^(v}B{&`0ugXeme^}b(H-{*u*aDb=o>Ap6`=c!lX zeoq-;hg|{4h>M*{+x<4C2@ORKTJarbjUKKsH_h z#;kT|oGHVXnZ&tm=IZ!(Ek@$RknTUCLF|liVzSXCFxqB60TYYHZ z_ox!}08ra!rfEJ86`juz74)jxr({?@-wfX^+JEpoId*&ewq)Mu!}odN@scDVLsa8f z=GFB!^0D}t7p{DXJ?^#hL)PR&bd#Oeii*I)#9I1fP-j;WNZrnwnUt?=)?tlUEd%GA zjkcX!9!Z2QX6h1mvYy5$1lF&PhyUtP6d#dX1T%yrqi(OkYpdB5#-n>BI{cg@@IcCqR9RP*(6#o1c0 z+S%@8hxgZ9tEFA8`?r5k0o+3^gwiL>>xE^*- zzI+MkYZg@3GkD#fuDt(226&vP6TGc8@wtshBjdliwr|@BE`JR8Z19>pN80#8U+z9N zW^n!RL;u5VaF>0dGQeFbV3j)2u3;xw(SH^&-FHFfoB<>sT$(Ij4DtWS^duN$9yo`0>q4Bb8-tv&Bd zn2mR_6^OL?qd?Q^+&+_(xAr*1y2U#vnqI^>jqUg4%ptxDPE-tk`Tv;~2AAiZ!`XmB>%s4yskYa=>L{$wB+EZu7iw)= z?DHg_P3QO`(L;8ZxoDGdzUcaIlZN%0<^7ARVTXe%yXH%*8JYSot!(vsl8Y-b zK1&NdF?TxusEN?$ zkC(#33(581+UwEE=ci3%wA(E`>83!AIqDQ>0aV85@I)*qUae3n)+iQ8pS zv|hJv0uugVk8)g4MMq1&YjX1)a792YEM4d1;Rt{MJo~=kP@jY{)x_B`{so5&3LG>9 zVa5l+C59h3kSm!lcYSNk&+RzYYc*cxcfkjh9b;;hFS1t(b0Lhk;|coMa|Xenaw+|X=T+X_wxnRNg6Hwp zUAJvkchS9xroJHaP%+&waPJSvE@KnJRsd_(N|?b?E86GT{O7@2uT4N!nILT!ZHvo9 z;=qN^sNCz3@`1GAsRI!|q%^^bdUBuuVTvD{vD7~O>>Q6}?Sm8+#RNRx=UarRpc>p7 zZIqb`?-F(u!H#!37D$wawd*N^6mKP#Wi&`o`RBUT2f9ZPH?S)PG|%bdHaEO^RI5-f zXs}r$F?k>ZFoTt@Mv(+9-99)xvq*DM5hT=9*IP1E(eGhqCj+;Y#RbvW#6nX{Gs#8# z8G@xqvsDIwX#3|Dg`5eR80j;e zXE>XWxfMD;lrO_t)i_?vn$#c7s&G9N!4mUU`QJ~G{P{sJ76GrRuhX+I^Py^0ke{|@ zH=!8oNU+lOcAcbavFNkc{^;7vuOFbN1paVg_E@>IZ&PtU7+fDK?dAPvUUB4+Ek10h z^Wv!=m7=Cu%fYCl69_Z^7OHRE6hT>$CyV%UBsGbi_`o#jvfUBz!BF%4pLmUcLHO4x zpF2>79E5Purl?RmUm%ZJ;$I0@pzm#+gk%iq-C@JQ@wcYhdyP@K-Z=@)?=KH4Qalm; z`^=AS2-^fq0~E0kensRJpSbXpe!KKjbiNBcx?r*_|@u3F7%Z@%v02rU|OkHQX#V?NYy>m+{Tl4s^;`d zsJWcZ5H$_O{Ri+y1-D@S3ac*#8F7W6F4hBm=#R&DB&L!xnPzA| z%|P)Ss3wbG^|&#q04%n<{IJZ@>y6U^b)I7tR8}pDw51wWWahMdH{NkUve?yda6MsC z55D}xXi*okHDn;bJ@?RZkdaZRz_%Oy`q)b4bM~`t*-4ZndHyHQis$XAhH5WQc^T(q zUyqdzPYbE`5%twIegJ8B2C4}ZK4Pp#ngf}G@KoRAB!e<$V+{o{K&XV~z**p1sF9*$ zZy;G}YRY}eYI4;ljP@1&>mv#H=&aijNEQ;R2lWC}4|bsu>c_U$_=7=#TTCl)IGrvA#?ip5b2 zT{woL&!zPv^AULq*oM-|gQW38^rN(b*H^*AYWRtHSDeG8GBLJFva%|Y$_z0m;K?ZY z-hJ~p;emQp4)no+{BLT zjfzG-u{M+XYbH(ogY~OU23!r?%0<2i^<<0ddu7wSYr04UG;bH6$5US-V`tW5_kyT_ zlj-#@L=YN$%TSKzO_s_iFbo0)hZ8HX;C*{hsT1k1PPiTlg%PR;;a4W zqLmvK6Xknce0E#?rm+!T+J)#f zFBfUAF9P{S{E}{_wxY3q#z=>`+Jxo`TmSRP?y*sXiDTEJ`~asrl{^PNvk^UYdOd)L z2@huGoI6tPWH;9cud|XQ))KiAue)*Ql!SOIGgk%w9XkC0nvP|5NP%t(606(8mg7mQ z9=@dShBn$z(C5{Tg&Nc(ZN$cB^k;9J6?I>Em3p(2imGXFnn9?po-|>{oVTP`bsgrZ z!^L@*Hq7!WGnDZ(wKNAO=$7w3rFQv})VQW7lvt18^tStep3ky$`x0lz)My*q46M2h ztUL2F>meVyNO_)7)o0ZwwvrzAK?BS+Z0Lwaa)E5WO}tuSsw0l5p98IM@JA!IPJ<*- z>tXzGDkPL^uG7HA)an|hYuc899@6e>Hg4&YWc-8XB6rm3 zp;i4VVF}UXIf?(Dcgd)6H1x>Q6f@OMyb+yLd$T~>L-OBP9!5t?!Wx8O(->5g3&Ek% z!(o>W`s-2c`vUwBtHN~yrTCHEm-_E8D2O~n%avj+qA7JDU@C-Rh0QRp&%G>VNyW^Z zJ}dqZ8D!p8#Y3buR-U;3ZLnIqOaTHh4~3nqr4$_W=1Vt zOSg9+kwra?KwX_bcC52tDfX&wLO(|82{CExWJ5tlQgaC&Mlr|RX4 zqxYBr3$yo~h9K||WzAFLlB}#bXnxbE*~R0v^z-Y(R)zObOv6JFk(}*Li06DGhw$h9 z9{xYP)8(Ips%IqYlB4g+{;dTpNhX=G!J$)>A(z&KVuj2kaz(^wz=NWCo0>ds2YcT^@13a}mSSbz&|gjHY|G295uw_ysT z_|D0V9UHc7S%-^`HGrU(rUm&WIqFp)AZf9QzYB4tBsDM|iOlt1P86SrBwyfBM^Mc*Y4v8n5e` zc9Bm(U+2`wu`)!(Vo}pzD3;cSwVW!73V1{?%JJMCW`+qwvMwfZSt3CW(WBNXKJA>H zVu%{O{cH|wTRL1lr>#DV%ksH|_dewYJ=6)#=jhkG^6>+heY(kt(sjN2PM7A^o@j2b zl6|UpCLXsk;D;3=M@;8}qJ&eV;h*cHP!XFK)I~r_AXGj)r9AFShi>&sK)1J7X+$0% ztNSU{EWf*F5#3iv3>_AdNxBVFqA<{CvM}cPUJYvrB;;=E4l|rYX2jk8R<&xc1iy8% zWOCm?`|&;^;b_$_qU_2or%TS-c8(*}Q3v*`TJEFRpvQeQw(|-(bA=K$khFauC#=@U z<#dNoq@?rfWobtbHe>21IrHDN&tP`v_8qt2MEEEl78zOljmAPA?#m>7;H{ry37{O` zEuLFynmY()$Cnx{HVsbNi|m)HUc~uHOi(E(_(KAdX2@1*v!V~S@dnD#jwrc-q*|KW zu{T8#S$jm;QostQ#LtDml9~w}yZAyKO>O(F?TdH2$?D@o`4BPCe?~gSbEm2`wPY*Y zeu-KcoBa%vF9_(Upl>myIsR!;(0P9PK}qW=>#@A7)#{>sJq8fP+I*20;d3uX#_h4c zh{jB^=zhDHGZ4et+`=ySI{U!@%qzR zy~FW|U`xvCOJBfKrl2)S%+u9$*>Qw&lQPM+_2g-p(@-F&6^0*9w=O5igLB0wMzi5kQ2mdVH$NYklV6AY|5FX}t>c`|6; z#1Z6a4=^wr#TE8dX%m-FM$H2sj*7NF^7hh|7d@Yem0niU@;A|)jCG;SkuSss9$_FN zcBt=*5t*8rkJTd)N-4rJe8A7*4qcqqhq<=hZgAZGB@-4q)ifkcPCr&P&@VQFL+PNX zTqlnnf5j}0C>8`e>Lp>8^G&T=bcq~=iY~``!FVXMdz*e#@$(_~)m7#QaxUDrv}PzH zGorE0BBV!30}8XXROf#8-cwJg0h9S=Xz8R1i_Q%#4N&)G`9g?wZ*ie(TEF|vu&7IB zVtJk*e)bC4`tet+2x}4-;~3T7XUmZN4Qd&v#A!!*c0J|e#Rk2HhdhlfxNy?&jnNE@Q8RiT&2+nSzi*w~#M-^qYqm;&Ep@h@a)#|voV8A2rijGUc zAOeNeQzidEP|^&cTV)(#PUjMc`5A<}nUho<^qHlzYB_DrM_|+m@27r|3DC*hsP8y) z?TK~mp}J2|KN%Kkw+i2kyC3XW!M>SYzXDX&KJc$TZlsmSnm<>^U(aqNKq8Z@w@X$~ zGqbs@QZm<$G4}7HP%Q=<+Te8-c?o^PbXV;0U1p;;>ZO*y_8*`lw}$F}kD9b?Bh4~P z@II~>hFVmS52^UQ67NG|X5i+hd@i&?w_Y_T%$V@n-> z4C{4bD-9;Oob2}Ybi*y(`%1l6PV1;ELM7j+#BU6y!exHzVc`R%wYUvNJ05c&GOT}L&JIBjaJ=@7qH|Nns!9I}F1~cXkCn#kcZ>4MF%sK_FPj1A*^09KT^u)Cw zM0i>9xH10DJjM9uhW`#U!dzLgEX^1)(fOsV*PU77zy=cC-ZGN8>!5Q1V0~a~sjAO` z*Q>nfs#p0>m(k*<79%neyW%=t(M+#`tJ)@Tj+?g<$(~)8VL>W}Oi2jkAB#!;g;`ze zbQ+O`XSDVpmDd*ubIiS_LXnS{{dXYb@5FasCsMM~=3naI0iZT%PQtD{m&6qTV}umR zl7gzdx3|p@SV)jPiSzT-BTecneb%tO0vwXmgY|M7Y3`kcT0?1x7!sy?QuP;|y%T(8 zg8-NhK^pq0%h0FJRE{E!Qq7WwPM8HMG8QzLa3+zliXY3^aiA&Y5SGmI&W1;NDoDkd ztP$Sj;*x*%{fwIi`h&dFsd&kO$tgB3FEyt6vvY~Cv(PVF8#>aL5uu&ymj}bTq1LJ! zG;{=cU92$b57bKM-{A#1 zO`^DclF>}7&CEOtH}lr;kC3?z$o|>y^B8~ejbSW6m_)(#el@Wx;e3f(7+5fP9<{$4 zP~+6{X2F6PHscs|N&y!gp&$u8ggwUq?HMnE%bP2l`@>&yGu_kUZ=nJU;~hj}`z-f@ zj3Y{|=~NJkn-nwVB^OXs3u05zxvhjPVM}o0LxMK=89Ng8o1K9nNURrv%@yu4@{ZIv zXh>seXtd--qM8!2N~lD)HkwPDDf8skd)P^_?d=k{w7D+MS&|A-wAvDk=H(~8x*oXKf!|%lsy_*p zCsRtp62K=r=fradP+vCRTGjx2w4MehwJS$js-}gV&3tFS4quU~FZ2=uAhk0%5t~({ zs#iAM-H7B)VgBfN{Ym3zL5I^cAi=#}v?gej3(@C=*NvhIU+1RnjAC*gM`sfUCO>2f zG{|sd^>uniL_`s5kwOqB(77dr`kIBYh-|7OicB-q1Qmsmv|R_h9^kQtydol1z854y zNQsgnc)OsbyNKZCQy;z|_(GS(@}iqL3`%yv5I7OKh|Fxr(L!67mH@}?^_=(!3hp?3 zc0U;t*56wm8~%vYsD|vo9&W_$4!2lJ7tDynDu}w<5tBG4C|1BJ1QbR`T zpT&-^&Y|*xlBXh}v4-@SNO??^eDSqCuD98s@uTj22gq^lBO|Y$QEGN0M=|@2%aHrG zkNdaq;A6sw3||2NRB!DbP?i_kynTHBx&yZcR7*NEi2so@ROD11=9}eO zhI;4a1x@@t!TwDf08(Vv&ty(Kwg%U|ERnY=W?6V{>e78g7q)^gKXvarn!tVNa+Y}em6k#TI0gR3SO$8sZy6{A2 z!LK^{+9893#u0&Q66F~VU-*SNWaAS0y)x0WkY`P)?a>rQI>aMTV2l)n4Zrpq2h8k1 z%99!d0ry6(@wow1vWfv4@_{tgOTI`H#_J7(n3bWq5K-FShh=47yVM@hUd{nNw*WLH zeFHA2A0@EW16Ry5%oz2@=OzKey zZ^KUP&{B4&ZTQnq)86EA)Zje~xq_%d5v75{RPn;yTmvxFhUNK;sBlmi<`;RUM>Sr( z12gDJQ&mE!G?WzV1Ous=&IGa5cB5=TiO4dOy=X>yP+vgY**1Y`q>TcdB>`zyiZ%S; zw7%?fjPMcl<|>CSr|&r`u!x5e3Ih@gGQOe0V9awyr^FADsb(;4R}}x0TsB(kcx9qJ z(=ov7+xlS`!lN}#q#1prT}KlG95kkfb!hb$W+uh7QhcCPu@bcc(CYujuw!rP4^MF&K6{xL*7ui?spt99YWeCXAUCF_WdX zf=h8Qv3C`MjnaYW=7{HnTWN~^*t zH5mEKFbh0gG^z>iDWe$MjCDr!o}?5B>>tM0%k+CE6s1%W zAq$aGh%WjRbj`FUE(`0$@@|x+WOIL|fK9MXn>f*JqYR?2py@pi+RS_ZJ%8$`kU|*J z8Fc(&SUaH2ZuH3%{0rz%FR^Lf>VAP3f$vy5Eu^KVs^gy=CEh)@hwO<1gy*ewQLS)s zp1a+p=4(n2+g?&F^gI8Ef>!cS8F|u1?BfQu{{!m$qHD_GM|9P=2nvkKMrDdHN$HhX zU9mth7@5Iz7>v3KH4&^l!tTPjZAK2E_v|8bG>EPkyLy;TSc#q_ae1cJ2p4LvwIVo7Ool9f2j<-mRc{oJJA z__r3ocZG-t%fpE_!-JPiu7=6D4iPMZriYKWQHLL@xk}*M{Ur&ugq*raVM1q3fYexW z#jux0)|~q=HKgEk25&&%`3t#3mc@p2Jhm+nCv=7VO`ew@v$OpAz%YZM<@apq`cdv} zcT|)bVW=4dCK*bN zT!q*Kbf>WkK9B1LBOQ^+Nc7nCgQxg(mH2ODv|8e;S(1zV(z*Q6`Od^vkMHAe_(OjH zOzOR{yFLwWmg2K``d_dkk9iXZ+{fhj?B)esje5ciOwwUYUCL|Ni`G8SZ(G-Om9D|A z@%fj0LwhLwu>Vbi?YNZU3r~N8rr-8BY&Zo$M69ACXveUEB6A=biNJ#R`@T2~B?GQv zJdF%}UpTo~ki~xW5^bfID{}QUy2^5VR`y&*0d(kjzx#4FQf{P|b14j#1O&gMxd=jb z1+yq(n2r!KBa6}3cT@xsTot0B5WNT`nT{%v>}Ti*hzm}_KaFKkViBS<>Y2JPjNvWz zqp}1Vke~aE8=t>7;=?eNFFmbW@+VuSzb|y#M&>Wc;pDu++FHUb=ekRIy;U3Yy5W`K z=ri*(#GE=IJlz&tMjx1~JvM8@YE;>@B z2$n*Ef<6E3w-ig%>q@=C=&_EEv=HokU&i9@BE{l5CYG>`_jr;yHA`s zI44&rAFS!dcWwaI2d*x-f<5(v8CSnRB0kra6z30)@sISTBMI2ooeBw8W8+j=lB@hk zYx;p876^uCoPNVZ{YS!Fgm!R`nabV|0z5?-FHb&D1ovam(IB5l!u zLIpU#&W@Qx;=khrytAMXLjG|jwOx$QIc8Yll)k|)rPD|5#Xly1_<;)rD=UYoy?2Ou zTW#JYBf^iOZcznzL%_fXC|53cN8``B7~LI%i?oRkMKL7`93doR;Sh;;lv3c1g%L5v z%cpLJ8AaPTfQ2Tw+N|h6b~{8qM6K+34jm_%@}fA7?txu5bXIqg&>61JHfrk5R)RDq zl-Y;FqPX5T><;)?f@*CWZdL?aYoh411KS>f+1i9p54NQklp z^u16}=-QzNtEpNi*syjFe#N_8{t(jy;ts&-!yVTJ3AbECUcwhuhlF|svC#(BKte%> z-z0xv&Eta6?Uwu4`?zEQcs|d}(0E$CZM%AWd|!?UNon{#X1w5d*GW=H`bp&r$bHzu z-fIf*+ST@adfR-xyXG+)W#3lm*s$faaTi1C_1xNEbz6;P@{ETzclyW59?>dm^*$T^ zhuJbId_N7gB$RVLF9j@|FNvt;3M!3D(WwxNNvNLCZ@5d6^jjv@b>a%U(O7EX7(Na} z*+r4b)V}O*zhjN|klA!;h3k-tawC3&l;8^eMK(*NM4aRc6=VjaKhlT2XY4Lkr{kk-_R-Jxb_A)Gl9(zW)jurV+T4EWHR+Sy1zq&_=O-U(Ue=a@>S6`@vfe~65 z=_Eddh=LWDP~kj)5$LZylZy+PWR;}PIV0STxn-g@EUS<$1JZK?HM1~r82_x{Pxr-F z2A3jj$68BJj7o#zd~*RTt$3pCWbaZTg1VT9yUwb&50Y+la1WUM7C5UN2g#It`(0^vrH3 z+rLtg-Jh%^m<+Jm{vd2E(LR9@UKN|vfsChsg2(Tho4KE0qo&$%>uw&gg|6j{SiD1> zKjh@H8mB5HPlz=wQx(UUOvZ~365^L0z`_^wjldfREeGg7!r-A^vMn%(tkITUDbXxw z)EkjdiVE3l_rya)IiusS*_ zPw=ZJw~LBgc~GNZ@0r9p86Jy;>WL_6 z^HD$5FXO_j+Mr~=nm-O;SvVfiopzG|NV*2K@fy&$IuWd(;#K!}44FM4#suxPkh zBc@!Sza=%LFu^;-$Zf(DY~k40VFi?Hv>(wdr${=59t4ic*uh*D^F9CO?(M&i~}3w6$jFCqn7RN z{duD7##lm5x#zMlJ|K#y_`N-~JTToEUg+EOMfCMm@`aWm8+91kJir_R3#RbX@03h} zzU^*&rlSB;doJ8Aj=UQfHYVcO{w@fXLa5ro7e@3`9XGu8rci3zajTzPO%>rLfU&JD zvKD9n8Gl&M4lZy7=A2D5$zQ{lt%N-Ccg59N4VuG5uAU~NELI@08mL`+nn6ugLAL3{ znRl(t1-Hyj5kpDpoi(8*2L?=yL;jdC)6wiBk~sAWFCCL!j7Rr1)t&M<*}|2~uQ` z@05!w9y$J7tm~-p{N@d?HTd^Vr{AhuyRA7hRtkF3E4NrT3q)an#WIdn(ibS`GG=mW z`4#oO3qR5}Q6VU%X1Dl+*gwpdYMN=DiFlNmrHUE8JcO(z0D}o{$QdGtOReG9EvZE6+g@;6ikn1HQH|HEDz}u37~qoo24vv*>9!Bm-{3 z(k~1#NZxNuX(|E4IIDBgjWya)NZAm17!v4qMMTf%gm@~QByk^6DqBgnri;v$oo!mV z$ON5%u*G~-_5hRkdJ7MVutq2yw@XO!q)j$TXEX&>Nu)63Bw+DNa82(aO8kA~5;z$4 zmCt)!$GnUKo|l=y8;2h}`0k95cIuTC!goXi1gOpGJ$YHxw$b}}Q+G1QS#iMwiUE^e zUd!9$e6n(R8Lm48#95yc(CVXw!js6ulI%C|=C1e=m-^Mwyz^Dav{6Ocr-9r#A&#e6 z-1PT0>&C2&JO`u`&>9iB==b0qS1;`?Md3(X31L2T#4tj0IE2j0;Y0}a9vgz;ercMg zP&hus0nfvUyVPpXFRecHgG3Q&%=S<*W-8p(kYiq7IszUXZItrBE&(CE?#m^jUI?SY zTSCJ5O;Y*!Xg{OXU|;vl>vrv6$BjwxEs^-kULi3>Q)qGGX0gDv2gfHT+1tG(m?fjl zhL`{r4rCp4Oix4DMxxjT;>>g0Fi$HsAp@uE-g9x%LVWTHDNTu2QvMBN2Fmj)o+>8M zr0ruyp+v^XP$W79D9|lmV+%54F@HLDjrx=Jb7s?+;mZ|6+EIY%NGnjOcw(9MN1ZTRN}Va(VYt zzm!{;-FYzhP#+|03l!{J<^K>JQkMXOUmzRy>j?*BFvDUfzSZsfIW%owB@G2o@qt8YIX0MgQ<={<1>~SC6n)XugdL|#Qfs_Tqx z)-DcDRguKwbiMAjB|aVzVtL)TPb-Q#A>_UU?=PGt@+yqSN^Mj`QSjnd>NuAJbq?9z zl>USGc^7?ThXr?U?z~6`)T<{czdj%4&csq9eMWx-dv$eGI7?S(0JD(-i)%RSf%*d~ zgD6C;>>N$puF2e1&+8n& z>!#p}%amMVY3SHSS4cpS_Un+vuZvd|_7goV0Aj$+yNeEVNNEo0D1yK4t&5Iz|h8A5M4Ij^q@3>O&POJR2!Wc!;Cp3G@JJ{nEZ(RyTH9MNyc@;xX-xw zG%o-(n5OjIP&ixf{WyAXpf!v>1bn+nc#Ng23h@-bc!#FP zfb#oQq^XRg{0e5HcC>_?&i#AEEe6kDAGuf?-!H%CmH8-6PKlm_ukwdN9X&9zi-L{x zQ!ff2R98fIp9HDpY$wPpou{F@^z!>yQQx9=BrN9bq&D6c>Z7UQDTBf_?`yvxA z?<<#c>+4-0yyGT$dR~QcNM;mK*m}bUqsX76zY`2aZ=)1(a4HG0-80{Z`+k7ViKG0t z7JwLNQ%vZn6qnm%3%ot@FLrG`31am$QpaX#1ApWV19?e)VCEFoju+W3_;Q&=<~!Ef zwi=pC%+9BmZeAUxZx8W>15eDMA?P+~355DwKgN8=!f`+qH(S%}K~xS`BD8#dq^hvB^OZcQos6%4AiDZJMlwmS6-K&D@n}kx~H@`jH%UZA| zVJo8};h{Ifx`);{b9plw9SSbyQtY1tvjP&-_pLO!EK<`CqBEPqlT_&W+<)!RZuti+ z#(iuVQ9_vF>rt9dsZ|V}ZECBC*v#Xy6zgAQaLBA9U97wLc@3_lW3vSF^}$T^DENwf zy(o%hK>xBY%uI0d~Tg@vz^P3))krN@o#d7Z({Q~5(F$fx(VphVeQ21bAi%F|Q8 z#5=X&0K@GH4js-6S#=1^MgX$qINSili9i@# zK~5>Mb>UU3kGtEAh5mEM+!!B_%KZ+g&OzqN$l7mqLI<_P6MK~;RbdU$M}ip`+HGRF z!%we0rdnF#&`l?gV%6wZSP309uu?HY-`zldEzy9rzGKCQ0Uf=JofA#C|m=YDEq_ip$tP!YzS{_?T_jQ26HTi0J}w zsHzs&@nSX1*xYGHg$axoALW)*l_-PFczRR}aBvwy?>j^=j0F0a41Gu&Eyu+}Z_4Tgo#k@r^P8-ik#KyK=vlI4{QL7L8rT-7bKsvv{rzBQt z6&ay<_Ye}P3M2vj)9%R2+9@Zbh5hL@FkK8o(o|_Ln z>88VP+{^n>r$x`@*Jek+Z1w?^`aik|JXr71u=m8N>G3C7>q*XSB!Ij!mt0&0}s~crJSyY z^R7?4{>owR%=IQ)eoC0PB=0pLc?#WTw-6GlF9e1*mZIF0@Jlzwr816MV-B`H3z#~o zA%KufK$2C$P!F4q;;25cwVt&kVcT8q04$)Vf1VbvaqE3Rz8M0X4@<8e+y)o%glP_Is_i{G88h&N5ERa_pK;4GJApA3g(63{{v2lY129&(+m7ge#d^Wh~!5v-gcx?S1^i z@*_C*D~Apr8vE0K_}%CK{d@oLXO6Dk`Nos$NB;86@{vDy^FRImbD3{c*HSh=0_8+* z{yDtlMsVcFkxH7iUotr}KH*Ic69TPn8_o7Qx7^zR$-wh`(Nc3Yh%_irVi#IsqrZyg zxjG#irqSTh!_4v_#;5Dm?R#gx=|i9T(69W|um9AhBlUakx#8-ayW77qR#|?5^iKrY z(mcY!kbLR^8gWCSu|Wi}A#!@LDZRGLRC)dzLZWd*jcEk+Nr3qYs|+b#K(foBTlI#d zXX)()CPH8>i6a5B7UK4CB%Kq$U;);3P@(5-=%MPLN@esQGZK5wG;IsSzra1EN|^x8 zz*C&(LW6Ua!*S|Trp9!!K6$6;7Sna;eN z_c^`j3}7E}9|4I@sE~>$ak9wykLr>;M@{k~EuZk?dCueKhU-ybH|Na)fz)OF8`KEXO?sL}KpZbkne&*16eEgMtEPh2ZKKc0N z!LgT|?jQf9f4uomul)kMDXU*12#lV%OGKGcjje$|AE)QW8ub_CE}@yqkO?wuf}rl?GZV?rR&D2HS#QZE-Bn0&X6{J=qaipYDN61D$Fb)m`P|dk+Zs$| z96@yw(7%5sedf&fB!oh|Cjy2-{K#>_+lXuua@*bw*O2vAfOsAH=V}qtl*|o@MJ($u z1XzK!^}}XKP)ujvK)@u!9g_^6da-p9l|>K)uv{>xj%nstn`Nj=PU6ovcaF_vtf_5* z?K@S7_ZNONPbD{_RL63F04fe30N~sfqN$89@j*V?Aw|2<=L&{3IDR(53Jz8r47C(~ zL)$Mxfae_Ct%y;x879LNdnYWOuxkR(+CPJ5@0r9G%{B0>sT!U+QNc5&#_+`PIu1r5 zW~pKDr=L)N%nbDy`49=R096V2Nv` zQc1_hr{87gcAh?0!Tli!_?8qVd(EtMWo#|xVXpd-5iFk8UknHy^6~?R#!`@j~n9AN7*OpGou9V{_k+Qtqtu`a`TY2UuP0qq#Of zn&h=^r}>KR+4<`pLRV##OAG-%N|%^Mr8=7)fixXHb!ujEHqY~7ENX-YgF)eKYg~lJ zjYdxiT}9K1g8UMZ!Wc4I7&s9jLD2XO2gvB@&+XcVox5hDc6a$}-}}CQe)W(4+|NCg z^+n5nzH+h4A35@x%7Go(e;tqd-y{8Ily%zh!yJ}dv_WX$Y1m;=j8|yrY0O7)lAe$T z97$6>b0u_CCXsUsKGk%3=^_?-WiTWK3lTwNfDJNF+lxrs%P=X)oQB+r650qR(hafI zxM33lb`1x>F)61`O6KZGG6mKcgzUpmr~q;jcgh;e zA{NEPxyHu5HxQ=ZOR@qH85KJh>J8_VI(m(s7fm~Vb1riKG!eN^dm^KI5k5!4N*63L zcn4bzKy|>A42(BIY$u`b+10>--E~~OcN_<2B3w;^zjpf=p0r~MSImrKJOn%!O>x>s zn#RP>KzA;T380sQM^4eW(XVsjIR}cd3imyrqQ?iC?VQs9)`oGdR$Gz+pd#sW{?dP_ z=*@4x@rj>2eaEjYt(|y7lC8hMnQmo}uA|*sgZ>*3!(oCfB^G&zJPWY4wuW}MwX@Z4 z{`aE`AK(5tcUBg>BoVmqdAlSPES1_^29-+cgocw->+a1ONKIUwP#({K7$8 zVwe7T+IW5PCDY^ae+k{%bkbT9*YA)VJt9lPuf#$j&yh%#xE#6NQ^-0bJqSH+Q$tjr zV|12W+9%t!Df4jTMo#F@9qa?iL!WF2hYL*840rvzD>{l$nRLqKk|Tqt-7v{Rxi z>_%kn(zT(6Q9TD z@ZrPN_k85|w|?Tz5B*6uS^I%pdVAW#Rns4?qr)SfPG=q6K@Y<;LCWXFyO8f#CvB9$ z000mGNkl8ncic`Zw8DeZQ!oDl!uzk;T*crCJ z?B75B@!$TbU;Tw=vCt*);aKG89jniun}~lA$=V(_X!6Lli-6ng0S!CdJAPcDt0QT; zbKV*0rAYI%`i`DBY!$Xf;ta-+a7#P)6_QX`U?e z1xb?B`|)7^_5S)%hGu>i`OvWs?fl>!?|bF(Q}_IOtGoWx%*8=x*uiRZ1^Po&wug-U ziU_nHJ^R;pQI7`VL>DCkr0HOW#Q!Zv{E^CM>8{Magc10hADT;e45ez95CW~m|GG@$tBEB5Zdk0V>THf}FUI_$yF zpprWvCLtmT4ZTq8IdvUF6%@M>AUcNU;x?>vtPOGky<&+t_5gxW9mC^?z{d!Ypo%_h z(n8Xj$FO;d=jMwrS(mL0S)QvU0E)Te7eBn?y%KkxF`}4zhK@RBY(0hb89XRTrrbWy zrH)=)*`pNojd`C?j6MH7l~8bQ&eCQw8K{4nMjYMjFDXPa9X$7hwQrXENM z@xXd?OpadQF{J(^T1b*%I;dh_V+wmYH}w=qc!bg(r0De%WQ5al(G{Qy;j^m(&H^X! z-u|dS06_p>NS>!$q^9sx)LIMbU*Xr&UVhuP0X~Q8ruQD2`^Ue3%PVic`&0j8wX-st ziI>IIHLS07L1!Epoqf--S^c*j_yBs&dzKH8%Mdnja9m{Ol4Fqe&HTdY=iPDm-yZil zbXyj=L=iBTs9vR7n-GCpZn+_p&_8u-Vyu!Q2dx^M8W37K_0TiNG$RnXSGS_5wVL*I@9^ z2eR(z-*Cyo3zN=S$%cK6xFU*SG}9xz%nngdsaJ9*%*B(HVg!(>btX&Wlsx z_KdS<@eaa80+5G0RgPuz)qNTr>s} za4}T9II0Tu;R1o68)C8^=o4{NcAxl#9xLEBDW28KqAc_!T4RHMI zRJ;LrZ*&qghxa1FG(bdPN#bG*RL7j{cf0*yt-1DsSMq zvHv_sx?|053oEPZxaZgzEU&cC>r)%<@(3b6g2DwyR*2_fjjIzFo52J?n#LH!U8W9a zJN@R@+_Xji{|TQ3&MlE`w!|pPL0l3DbUM?OdcAQFlG!*OAXS2((dzQ-xF6G7WLy+_k8U)rdg~PaK7woNzH?5F?~#9%z_#UUqYWp#uG}9&DYh1^~TOoik$s=omxj2mrOc-!3fXpx<3c$yk^UglFsDNP!V-P3lo}=?8 z9?udF`nlznTf&?F?fu_;{Nz2~x9Uq;O zoC~&$&k=;rS^$U$^j%GoA%;m`*4nFI)Qrwdp}aOF0{U2OO2td1FFgdh4wKbN@Hi4L zpDRZN9g=iNgAtEB1Mg`-EDVj7h%oM9O~`V>d`H8UGY+N;^b(OGEeqb-KShl2Dx4F4k*iih*iod+$qAxiXO96%B`fkOxqiSLuh2PBq00^da*uhSdn zS$X>ud~X3}$TRb-O)q-@T>v;@q-WTUJvj&*`&ZfM!@w0$or|0#uE!yxB#X=$p1D?l za2#VK^Kg78TxA-W9Y-QH#1b)|#N-2b#y#7H8O;^w-RIO>7b?Y66}LXNYl88KtwcpJ}hoNhcA&)y$396!8ys{B`ZXzo#C-ikO7vDV&E+CBBpFh1P3P@XmDSbj`&laCr|Iw{Mlu+3>j!%dtPe@t$KNxx>lmmTfTc(>@~1XR633 zWE3Lz#`S(fL>@SOl^Wph6GZYX;Zun#N-%s@UmMR60$#_6m~W8aixWw-&I{Z(*H8*-r<^&OOLF zbQlYL5A>aI$l<&YcYwR@^w1M-oiD%mjyrFEHdX$#&*}BWYj3;rQ$M`8vb3u|NHH9G zWGU;X9(g9510W^I7k?_s-2*lb!*OWHM~?54AqJM?Q}Iw8V!HFOE%%;$?RB}@wFpP%>DwS}Xt;DMmWA3_2~mZb((}%Cy@utGo&8Y z5%4laTcFo!d;&{S6OvszXd&*mNi0JIRya1pvZIeKY`hVOFuf34j9Fyk{=T6x=05qO zR`8%{cz{dfzHMp|5z&AMd03$sKn$NxlDsacR2YSd#-Sw7av;Wr#~o3YV46E8S5MaQ zlxhRit7NU$u zI)=V`8n=AUgwF$C9p)Mc0)XWm-$}>2)aBrtg)EP&y-a|u0mR~RZaF5p#=3PaGCI8sBJXDmx{VLd}bjtefKAp*-3 z;`NcG(aZ9}>yHOr5?F?r*>Q}GlaS~MY5(3DS?hGJ{@_PG{(s;6w!>e2=+L3)f+io9 zsQ$w-S!84b1jf=>b3EQbNl%Z?IW&c&=v#?`_7qm67@3Gb zuZ-nAiQdygPlru(>4gv1Pas)8PBK4+ymJzgEYVNyqGn=}P6EYR$UYui5U@4Jh8^4U z9wb`8&{$jUerPCcADXXag;*NlycnK~TMT^_zOo(B)D@^qk<1%A5OZ%J$qiEHp*jo6 zO-T@}rEP_D`9kU&a8cosHnL$0S=@$j+ugDc5rK$`hw3N&#RM?~a7i;Kv) zzJz6nJ9Xqxb+}NETm>AjriloIr7C=$Do%)}fhkh79^hFQ92+MQgqAsuSb7A+GN310 zPh#v~1JByK4KFyj9WOet1J9nDz}41c$FPf;xQ~f!hzfUD93@U;^x|Rh%o>ipf)E51 zR*5#S0QC~FFnso@4*J8MU2m_v=-BNac>&)Qp{lt4`s>YYw}0%V$LCMHc)+$}>XBqA zRdk#iUx;DBkZo${`HrN+7>?WSI_-|f2`aw$fnER+>cH_69M%GI=eS_=QIe)t7769o zrb9sIebXtu)fCph{XBN;SXKW~mbbtC#>U~phsNG|%UkO=9l5DOBI4V=IY#V}l1eeH z000mGNkl-qLIYJ8A*W_@R1ErScJ`f@dhG(@# z0@8+s(1_ESBpHFizS$qUVR=DLphZ}RhCInF@?ZkN#2(aVuY`>4Cc)Q{NsZ+!t2kK6 z5l{@J7z+t6VwOb;A9s)p)<$H`Wq@G;VJ`*(k$VNykc|Q7h(y)Vu|Jp;WiR-;e>oAk zAXlR!5s~}#`vB>%#3Y14NKFH9%|h)2x~Ll6cxYIRh0$U`!%#BEd2moqeMenz&0G!7 zJ+K`wzG^?d^1uPSaQiMir8b5G)M1LGTIXVl4+nbvF53MbK=KX(_Q!Pa;u-;-4}@pj zvkn82Yl>kSkFTsR|M%a&@iz;PdG6Y|`W<)Q`AyxpH{BU@I5#=6g3vQ~?@%mc38-*f zDx6lNiAOwSTY8?JWGH?s0fXY=90(T?-V1LH^!;>RrG4{I|U4h9`aS_BYQSzV+~!{@T@#-pEH4ksEHfA+*M92Th`i zh?*ydM7@Y4DRdK2!%;+Od>oCCFGT3kX0$#r&(SlrZZvoz0-h0@7AmRC+_l|@<&8_!on1Jn)G>0@`#zxZa#Ctw@+mT=R#9b%9?bWY-b#2tnL;qIK zd~7Uy*|u5yY=E4}t42-i4+a--p*#My8fe7dmUXZ#3r4eK#@z>kQQ z!1=aupG&o|99c+mv`CFX!u;F;`fL?=QCI5>^^`(5IZ)9uIWqKmiwLq+>e|CyqaNab@MHahgb%J5ve0bmyrt#q(M5E~DZeR=AiG%j%Zx z8=kSJ=(qbk3W>P@aO?#I9^mCTM=Byh{c>2#I)ZJ74<8PgU4Awh0%nuxyQS1!J$2&6 zCl~JctuxDaeEVYa$Tu#x?)lE4EWdn^uY7IaExmk@Eq_Obl^@Kp`ZK9r{jJol-k41 z{ob(XN5rF6t5DS2$^a6l*4G~grKy)Jk$v~vbLv(9{GNaR?$^BLH5JW!DB}I~CK;NS zO*VSJHanH?>aEWUxXmAs9s@F*cuymm!AlMy22=!wiw)zcHAC%F^ot}9AHdM~7PrJk zC>j?L=y*W%4^aHaJOiJWLxLzD(BJCTCU`+_!W*3nnqzX_^ zJI`}ul&}&_uC6cr?^{3i&R_l1(OX~L8MLRB%m-dg^=}uz{D(slIRCyHVfN&xQtJV5gJhR0ZX==@8)KxaoA>G78TxpL%95 zUEeiuYiJG6qB%H=&S1Ha^)~N&XR+R$M`yTzwazJ?3C~yh>H1`DJ9|6vndiI8%C}%_ z@h7|Ck>5MM{^2*@diT5k>Mieo&Ck5`=0Evg@^q{kM3mWSf$Js4#A_cbIlFHEPa6VfZ76+@ona5*1I=2?0#0t*vZIJK<&78x1V zoW@pp!P8hdl4$N6jZ}^>w3wL~!+0$M=)F{FoUAcu)W_jXi1qcswv+SAKl`sAx$}R# z_Ryg*e4dx*5y;YseP4gYF8512CR+#lD@O#WV^;wz$0D?rp68`v9K?7MW(Ftp%tXVS zqi`e!cIqudQ-}jGFah;9aJ1$E&GV!XAmo0wC^6D#=PcoAq{UVMikB)=LO5tW4^P6d zG|VbO((v@yz(s`%9Ad_E>Xh+5W*&?HFzlm>TxJo}51`I7^PtWR7F(wtV7h~2os>Rn zYICR_LXH^q!^M+$gn19Pe+sU72lDm_RC4Yhn2d9n7IQ3yV_`4gxKxCpXwM={rRLOE zwNMccU2ovR%afp(43#32g~R|CO73%bO{aLqK@p=PKi?SDgIxmFAWjnu(jg@DyW9P>@9XrJ zug$qwNY&e?e%98I<(B#pXM*A=UN{^h#6(m}E_w}ggRI-jxaFTA%{5YXw|R0?k;WplVV^(oSPh&UVY%&g0oz0VhUh?Ms+ zJ-=SRiB4|??QRpz&N9}T%UEqLV0rC~EUlh2?RX{XrK=N(ZC#NC-4_kb%1`(6(|@rN zpLjz%y#0GWdhDML(y(3hc@D#nro`nL$3t^bjAwm)6)P(%^bQ8_Sq@KYqn?LT0;ZLw zUd41;l5!A`*m#$wIkJNA4MbD~BEq^JYKk`Mvk5o|%R2Uza~B}xokwG=g0V&whQ`s6 z)U|CjpfSA@*ns%v+m9YQ{*xbi_rLvT9uw5r<>&l4ZI-?!iaNh)ee=q}`f17f>oAT3 zVb4Ma5t>SnG=+U@qoeUT_<}q#6a)e-k8_u!F?KYrQrwFOM5KsoUByXn)bQRbhT|r& zI15h!N2+C1$T27goh3vQ7;2zou99irH1?ns*Y>>+(U{zeD!189Z5H0sGfzESfxCrs zAg_clTAqs)Z9C$wLx>@t+*PElQ%IX9pnpmix-NMkrsN!hr*O`ScBF_Kj!Q(C&wFAf z6xD&9^5Y*=z^f?7@WDR!&)546aemZxQP)r>iii}qT1Md5cMedWLMJXTmEVPc8-X|@ohB;qGsKKT|<@*LT9ywpel%A{-Uz!Nk)`s*v6RbD8 zXtr0;87v0<{LEY`r@kVwcfacPlmGnIHy!?|*Sz_CzxI-wZ#$%0@)u>!X+$yvQMW%F zVwh7OlKF{K$FaJ$%rm?`$P8c^h)5Bd3k)k415i;TIb{7rT?(*@wJ;JeW@u<=dTDpn zXsM82poY-pQjogVaXd?Gj8!o)Sw}!`-4ig&#e$IO#DeNs?xUTwds9wt|&-KO+gT?9HW$l*;|2c~P6kwOeZ6~#iyLu8aLB3kSLMRPdB2h4L!6=4bLDg;49*e8v) z_b@?-Xkr?6Y#ON4NgI~rVIU@gHC0$wfyo(W5h8BQhkPD`lw>trK-|6?o#qjAcy6B3 z<239?e>}zjB2vix<9c5Zi4l!{enN6O2Vw!H;xiI$W;7;^=`+E}D2f*MoF6hSs1QF= z2rjs^ykaNBgO3Y5-$SxCK%&)RBBMGYqPMK?M^6su z6CB_<`ct><#A6xGad)OdLhj~$EcDh96H}NF0Wl8{>Ei=^Pdq*Pl#7AB>vH+jM}4A# z*=^%!R4QP1Jnj(iv4f|r&}UysU7r_YEOi{=F~Y~w>y8V_xTwy!qr@t(He@+l^d&MH zo0iu$^4!&eJefdwZ8`+Zrqg%}DI9paY)vILqAyp-kp)nLrv^zyUp!Ak=4k|&2~8^m zoHiP0PorY3WtQi*+7P{A8|}_2THR$dduPpB_v~1luRbw$t3S|<7v6Ac=~J(M>jz%@ z^0$Bd#s(`rtREVb)EZe^TW$I?3ukfa%xRoFd6Hy4gc=If(-`W_UJax$B;GrQf`A@N zNCI~BN(O@-$C5y3WjD%;2t-CkN)67v0$5WaE+nEXp~n&*P@8M&?suzD2kxl z*N)rLYJ}M-J(l6NW5-Va=s(~5?jQWk-+aRq9^~@=kX>F_ercn&`l~aO@j=9^lJ?uE zngGH%5E0f?6RyH2KSDpWm?!3Yolu5)FCx%!K{BX#q`2@Bk@L4AQjGb0!UYkp6Mw<| zgGWt)y@&uL94+VhJZ0WNsAE{;21R8Yc}OxhHRL1}P&gwb76a=j+!n5I3{^M;S&Xc+ zg5K(J44U^K;gOL35+A~Ow19{QSWZI`oiDDynDRg$PklrLti+EzsC@2YMRAFIdRs*X zzf?O89~*s(oGa@`IO(&pA=b^X0+zE(uzV(nA56eZON0f`zrYyw zarNW`p0;x*b^@pj`Vh{2FX=-6lr85XU0BB$3-GuJFn|cy0FO(ExeQ@KhjE_Ebr`W1zIn`B#1A|_k^W_M=+7Dg$^WEg4H-nB%7sJ{g2!x2R zn~@atJQpUclxHp)I#&|9g0jhkPtGROcnhg(Bs>Xk*8{0SKqElIqAwcwLVLb&c_zp+ zpx7dFD2y||Af6!5*fIrThLDI7Ch&TGi5zi~qSJ}#wRf=I>Y>%|+J4sA*-lr!ZK-?L zADmwJs8~`=E1aoM*8gqR7qfsumYbr_94W5B($T{M|C!wBU^~!yh^bkGt^Uvg@=|? z@gy}JqllI@C^B&GB_h0Wg%k@BI1=qhShz?xesnL)E5w8S;{2t!7ztpEK3{Cl8)5+7 zTjDehTOUVa>qxwXlL%m0$1e4VB;W$5W^#JxeUQPCwiYm0KUMq*ogXYAa%}|MmEaT> zdI?=z74`ttPx~$e4qp67m_ro-9a|y1V;(-=i|+@A&Sr?6;&)CiY(_+wFYH%{dk)X< zjeL<&o?-X!)Q!)gSJA;cO+--_=P$>YE3h>X#`&S%j$?LYPp@q_uqpP(=h3l@_81a& z%X9DA&%>da4RK4oi*B35bJZx-ak%bh}-|!=%s)ocoOZq-hEzb%CM6Iu=vpYaPe2 zlk~$p&n8h`n+^fa=%&+o%P1U)TQE{NW4WpiEVnjo01{M9h z(?9H^&_$>Vh0ParH8i{@ZrZ=)qC{*3jyK$mMS&5^ge0j7v8>r4taN!^&SR72DU$g~ z;X+;*?>$e>~zmn$A1>3CwjvFN}Bmp?c67bM*$TIh9d%nua5PvHx0wCn}9=c6K0 z;3>j!3fTMktouYn1Qx>oKqxr|dbGNTf%6byIz{_XOf-BT5zo5bLD-A%$pbH6%&$Is z0>X?;awriCz400A@RU6}@%Slv*}X0-7rbewiC(u2J?G|n0>bCjST3yiJPGH;3g?oG zfjH?Q^#jxz5ymEK2)GlK6LVcmhzP9ZXvB`@n;qwDOuh2~&+WlW^BEK$HdmB0T;zH7 z;XMb!91YKj@tHK3^m%;BqL%^!d|ECAH7-vb^4et6n&=K|sy;9w^yM8*h${dQp;6!q z=V&Z842!72;Tx$T-%3=%Dk2(X%X4_-hKqhn!ZL-poN*c$;fo8BS${ad>iP=ST8p+f zXzfXS^MBp5_>s5##e0AHNB-rQxdQ%35+;r1x zDmNeg`Iqe8mHhTtrT?U?yJ~u^CiDkz6$>ho!sRh6xALVJ5649BN}OSyfsjlsb>0vW z>giQ5(wQEdAfztTgXvV7>7Qm)M`<+7poq^2BLN_eP@x+75jeI(VRfNs+J&Ocdw%II zkA}t=hzJzhq8wL5hV`s-u*5#(xGLb$1=>Keh`fC_hV8pB=pI2nJOjzw#4V@x76BrN zHsZB^2WtfUaFDJLy~nxBc@NLHr;;k1J!-BgOw?USeL4hse4ublOdH`#o;4vqwIcv8or&c8k* zO5Pb4Pc;(y*mQ%MWzgLx)+lsBaT(67K!0b`?+=R5+7Q-SrgIJmIlE=L=QBJi%jttB zhMv!f>J7HWPYva@=@2lRPU9`4@Tb1?Q+;vvR#7sh0Z^l{LEJcJv_*tOWJY4a7gJ!l z$Em*xwZ$=U1)(yRcsZYOd?~_ENa_*8uo$ZvZcoDH@Yybj zk;H3RPiWjMjdicRiahB}oIQEhcYNaC|MtHP){g$r75nXPOpe7*Mb?ylXANwKBo=V@ zAmJEtMAZmcJOF6C4ZSJDP~+6(+{ZD@C=Q000}kRr5#9?!18|PK_iT?Dywf;?;-m2m z(S(h-hI;=w3yrka$qO9-5h+BmKSNKr?IhUBRDAtK+Wek~wgLfSro&e~mwJ12l- ziO*IS0g1`LQ~v+}pF4hw2#ggJ&m@Th=PnT``Vtw1s>l6BWRxfJz~Q;3ia|!F3)i|@H|>T_m@-TAkG(ghTopg1_Ox5r-{B- zhUvPK%Zf4h$6xlhT*01DfRf=5HO!xv9i>~K;U}0-leI0IFxWu z4fR|%hcSYLXKreM4T;IpK#HhAQA6Xn(od1|KQ%UnFQD-?NM7TMU!^e$`0_hsNerj# zRs0EA24+C$b_8M>HSj6RB@rAw`2MgX{bXgLm#=>v)!^#G<0(tkr5Plk?0krE%*+M4*Po zn1FE$H9mlWkVrHxqDCr*h#)4pDt4azC3%Voo>hA9p?UzqaZ@<{K#3f@m`BS7Bnk9P zJRhuJyf&PD>Qi_A%rl<0^Ov^IxNFnlf^DtL6R!-`DPAGea}q9?oJ+t&>g0gjLHiOo z)eM%43*olB3M2(5s7N!`Lv^IOFf^hp2Jt8^t*;RcRlv4XL`0MCBbu-CuTqpbS61&w zd80-}1R?^(R&mfUrx&iF{$`;~63N<&5N@MOwnh(q9q6AyvT_uirB9)?dIysJDTrG| zUm#iFt~>o^z>hJrv0LILMiMMh;Yxri}Fwo~Vxk!cYXMoVu5uk!B7sPo6(Zzhlpk5P9&(t}- z45|J@LrnMVGv-*5EXN>Dp^F121~#lfY{12?L!P8?TruQv&IM_TEOSWH9LDhd;<)s^ zvRsU_$qePS=@2lRPU9`6aHYEK&NT6-9gRuAEowDHVMs&CmtKvpQzoQw0q=#grN#=p zcRq*adxyf-Xx!0QdlHppLB5~RwiGT_g#ir?EJF(s%Vg~%K+KnUzy&}*Zkm3&da#!- z|7>f}{Mom>{k8kpmayRG{Mof@R~%Jpw*^6%f~1|%nBAu`)6U;t7-yo0P+tR~jyh(o zo70=nd&7ZIg?8S%fs+N-7#xoPy94B;dF^Asl&NLX5UnNzmN76nZ<6 zNfG27T(P%?ul?$$$ya~HlNYKdJ@WQ(xZwf`zJ*Zw22iH0hMjj47TvK8h!7<$x(&#L~=a9d<> z#aIn5dE(=7?c^B7lL4v}n+?%xx6tc$xfoR<2cI{KobRlsJ|e<&pjNeL)I-?77j26O zthF#ECbg)$j$?x`yPaA|%g3h>xZV>x-7vbX4K&t2^^x<-f+2h1dSmEx+?z z_ARXTIekPVAN2dTB(ZA=6%B%bwowhvh1CtyK=BkgFG5{M2FVnLNWF$6$(Yaj)Dsj( zCRL9j%krX(8YHhsaJY1Xe(jU-*vE|n*)q48TZnso#&ebf&7eTU5;+QYBQtn7as18z&Vwgyd!*Dig=6T* z5RNgGob#EH&>eS7a+11w_nscUW8AC$7l@D~IL+7)s{ViGS~^?k9w z^f1ebb3XZit6rXDkqKE-S>X;)Dw$_RhXNpOcK9x-OEY0*A-VOyW6vcHt>I zX0emxT<4a&^g0-HnrOAwk+O~41P~LE*oBX?gEb(TTTD;XP^(p7tWY;Try@{7)~)uK zM{33x=GUO1>Z(GuiZY}3$T9ASiiM|+Dn|Q)&4OyZtz}EuTnO;d*jx&4MQ!Vqu@6}Z z20;+=1*d3E*fLa}}}3DGowY4mfarRfxaN z%}7S$XA|c?s=vzlg!@JApC>|n&X-X=^Wsiqls+o&p=7S4u8TjzMMmJe2z?6yI*6o) zkREx6A(U_|^&|oum0??!B5-Wau}M$jDi$=1GvgtDR40BsK2|}aQA2QU;hg6=hQlFxy&lVXRI61)y4`-Ro{mY|avVa& zow=OvioTDdGzSqW+TsEr9dATEO)upON8pic!90?km3=)h0@bj7OJu^udbNfM&(Z^9 zi;Z282*UuL23yN%`_SH4*!h%a?0x3Nur@}Ptg^3F96V6PH@^H?c-c#zjC}{kkay?N zZ!Q6O0u~0i=m<#ARy+*xH0-Sq>M!imuulpVBDGprVpNsVP9 zIF8YI*5MrK)S|c?r)(siM4?h7U--rTKoQSGr(X>9!@j8i*d(yAz|eGx&hseG0pg(d zBTYT50mTu+A&U%((Atz~IfUbc_Tj*b1wmooU`Vy8E0g&Vy~ZiHHUiPx11vWHXBsd*RqzeJ>g%I#PbdIE-BjW;@QKTez&0{sq zPpt%8Rp?zQn+bu@len2Q-h#SLOwS$(Z1C}b#(wky>a-XbV<997(A=na8V3}YKx8EK z+8D$d^!h_?(RI-84Ozm_Fh}reANc~~oOth9H;+zhh3!x&=aK2((X(Yk(h?z%tnay%4jNxt zY3QY6Q6VSjkKmC~ht!1(Ysg%K(2gPV6NG=9!h5MBW0_1B9c)tD5E^C;A4t5vcfKAd zU(<-U2G|ITn~kq?ZJ*E6acCaX$wMGNz>rvAVt~gG#X^E*I7M0tY!twTq9nhKIE18b zU|fJN^ithaE^O!i_ZQ}A{QmjpapJfx%s*d_IEubI974_oVOo%R4wn&MrgMs6h3^xe zA43rl7zIeqjUA&)MEE8O&SSz@vCYmTeqh<0bq*GM9@(hkXv6|0WQsxR9dgx?dEj#- zk|4AWe%QrWGQ`#Nwx2#XhbPlx-%XM>oi!eTtRn5T;gT3u45EPTv5l0H86c{LuvYj? zr(CqgB<7S_nZnZ!g9;Rz4bgW;<0viz$9GTDR5V>h-$ULfnMl7VR(@UL2;5gaJ`(p$ zDDNJ51fG9&cP+5>cZ24_(nHl3%SZqb`)SJX^+zGJ7CG&WDM)>7y~8ax z8Y_B=K%FmiwGcrCg7AX&Xx-=`$@QN+Ha1%_SQk_GszMEU3doVV!uaL&rZba2tw%dS~&AIT<`5WoU!Ban7D5%$~ zvJoGR66|**fWd}n-3=+83AW6kc?3rsu7rfX~DzE{69MtwZIQ7XhLYKrCyK zIJ`;Of67W6Fi8mK~=TXaY$N=*S(FX4)BBMHl0ghu;AumI> z*8}3L&x@kVM+54?dp=A#x5c$wLF!y+3UPEUMa=Ul-9f5wXKZXZz(H&Aj9qiMZpREJ zxquAmoo6KUwA+PnVQZ+1H-dz)XC(c?LuZ{dP0{ani^XrizM?3CWx5iyj_E=Xkz)Qe zPZ!iq$I5m^gdV%&uuU&AlO>@Olg;+g=VP&v0Sf@A&ja3U)r26 z{(T2)Af7KHEeY)t9KUoMZEUQL@u~5mXuI7(zdNAu5}K+IQY;JtfCM7KdswCk4H4kz zDO9Qv!Z0lAdbW!qKY%dw?8~{F-e-({)~WWB_Sg6O3%~xSfA_1`;4@rPbG1|T>g0Pv zTlE`#diJILX$WzJK>>U5j>ezXZ(}Uf3dR^XFNl*I{T_|pFfR;E4i;n`@DdS-2rS#O z&KK=v-m#wH*epFE1HB9!^0bRAS%dLwxbi>)-|%%`gs=XZ>#$=-4#W%SuFV5^Qg~SL zAfZ55Jq2i|T4HDBl#c&j&z-1KI zepJq-iN8;tkpL<}w_RXC_@Qlsm$vP|^2FNfy#S5{^!ecJL;R>NDx>Nm0+G?PIZ6RU z1fTsn*9P^`vZ|-b1|}lHd1o12J#kyv={9@f)Io-L!;vzdWxz}8IG#_djN&ZE0TZf1 z7e2x)r60D(=SoEQ%>tSPO#?2}SXGL72Cl>|e5RFPtr!Hx79y5)q5v*1@O-{ZHbjN8 zw;JNwnMpipb_)8>#MZfu?t4AtgCPue&Wv|}5m+0dQEedPoMlPMh3}y7&<*=iLJtDA z?N}CoXA!_|94HS8Lm9Lslz4n+yTY;=`(O+1Nuw;K2B{ zQHpuL5sbZ~B4bGd#6vixxQ| z3=$%z#ww4=)h$A~f+swtf$#aw=izI=>S~PDj>F_9Fla6#;$p|pWAnK~g=A|@m2Fqx z@{pKQR~ki+1Wq#Oq9OL4=i>olmK#PpkdGfgG~aJGh=23Cnt82SehBkUgz1*vkLHB=F%tKk+AP)WeMk#GSf@M`ZV z0s$|!4d|;r5)omdehjMx#4lnK8fQOw#ySq#zKAe15Td8zgNJ{=U>Yf z!smn4L%P2h?33g&nwF3lG5PM1eRZOEXHfX}wcEuYs4DmFq!?>!?KD~U(6AJ$sF zw;UJW-HeMEJzlWXk6IP_GYW4kJl{WLAp)TjL1G8nMUu~d&C@?TM0ssG1k9$>c#A50 z-F4sIuLhHcqM+W^E$1i-0Lh?+6TF0mfR+Z}N?SuuLj;N~UfcwZtXSjAKRYvxTCIXE zx9CotI(2Tv93Oj*=sO8fY14Wo5uuQXX>^~!+^wVCY zm$qKo^{cOY-MfDJi?6@_`i;uixJ1P7oxAGaB459|QJsY9Sa>~_=1x3y8HtDm*e-1y zjZX&kh=tZs%j@`3dQv$hqml6}$99V@oDQIf0ZMAR-I&nB&fsZ_vO=^>ob=#bmxQ&3 zXFhEgzW2Lcj4%1(E76Er@bNO*t7jnG>hx@1GGfDajbb3eb}c19P9rD8Oz5#^+|EyE z{G2pk>$9lob~*{XHg$kRy$ilFMcX)rgk(LG2m@3|`gII_jmv>LSO_K@L(Vx&Y20;; zf?O-A(*OVv07*naRL^{AnT--6qtYUyP>KEic_6+UX&QJ0aA0U&)uYmi&xJDo4(H*zgf}uhrJ@BP0%suyE6&_4Kzw#e3&H zv|c@`3_}xqNaT7~6ezzo6#{%iH<7+uSXChjPEKrlw+$Pgs#eAjMkG7vJ7u6RtoMRK z=zor2VETFE0haWzVSvf0DNN7Ku&l@Y{5;z2c44G)?~u`R3W5MCg)tO6YKq?l52%4- zeB+}pzlz>#s0JgUComWc!eMXl{9(WMi#wl~KJ9@>{fi#|4XYF5yAIduV=m&BqEK&P z|3V|{Y1BL|iU*AzLq~E;hcqlavlmDGNS<0w&pTsoGwPYA_HStfH+0G2;YDZ!LmI;h zJjVqi#DO_Jg>aWad>8dvAJ2Q;LGfXA;@r5XX7wxlqeJ;UE?-{Ti(cqjI zhAIsB5xF-{5igw}H;5iu$N9&sVv}rlT5y?49GJ4z@92d-So)iI3(@Qw|6 zE$yXZemZ#GLIp54JrJpnjcxmv(1fkf z1_%rd2Mv~b1D@mX9Duf`dTCmgIpp+)0pVC8XqhZcxy?R65|j8?SH~tIH0d59f(%0A z2jpx&%>`+mu`chE4&uQgLes*tpS2JF-+%r}e8r2OfLgVUyt@ef``)aVqT;LEGPP_4 z(47GYNuA>9Pv>l%#tjlykz^5uX%!~ihWg|I)TXY0Y3uuk^8hDa^L&=WIn)+KbNK< zr{&z;=N_mo4^~?9E|do=_h9K4*46lh<@iJaM$RXR2n=V_a3I2G%v3@Hgc z<2kc;4k7}bZ*5ORuyJWUcvfbD$S7UQs4(e1p9|A8exYm>)U=KG1BJrAEVs#3`0&GW zE*&LyDWAC*o@5_z0lljd;;Gx`@T}dtaaFwvySk3S!V3H#!|K8sI_&}U7wbU~kpd0JUd14UB=Pm=|{r~(TWCc_I~UZ5aaCC4xfF*!Yj z9lN%p*sb|ypHj)0iO5V zz4-QTc^;ns+9iII=sj{p}g>^#|V`NNBh9F3I-@~xKfpeHCpzoH}^L(GYqHfq2#c}bgztwPDJe%PFwPb)@p~q9U&*0hn_TuWP zX-KP&Gk2Z9iK8d5w%TGh0U|CwEf?)MpJf&8hZ)*3JfCCFcO4tzjb_1Ercfe1cQ=uT z3qPfe$S7g{1Adx4JkOF0HdHL(Z4qS^=#Pk|ZXH7|hMhVdu`B==b|LJ%6Uy ze%JCrr2^p=wf?+am{Niw7-F$kLdl`mUZ zzUTMf_K_Q(#A@e1S59R27*uYIq?U&!g0L@7lGY|S+D>WNw7raHnK=!vV;PX@j1Dvh z7atD8VT@RR>7Ih)6tD?;!Ui$7=o79ASPa@mO2ZfTS5dJ&Jo_03@Pj|_4S2zGu0kbf zA#2Yg=T>{x?*UwlLn2@z+HeA`(8raJcmtg}!(c<@SThmk?EM4<16cM@b_A|ys1R?@&C~|MnqAxyNp5&eK zd^znW$mp4SJ|{}%fs#3kpC@X@wT?bJDtVqmL!Uc77vwf=%byz{%j!qVYp7Dh#TD<1 z##J=^Lb-pM-m7Rox|q$x>s}CYpYhpF`2_0I%od8Vv2U)34Y$JeR~WMu;HtS9T)TT0 zrc4D3cb~%2=@s;PF)ZJ&T5SxL&!VhbFD{co(V=mjMt`DcJI0B zufOW6{zt53A8Db^;{B0I)1R1L9~?Wl*1hMt^}&%ZJ-zzTZ@hc{gFkS`$@l#9Cyu`R zSMNCa{@;85EpPd&m45h7tNrMmoow-@n?Lr?H@^3y@A%1g|NB3E?LXiA);-tzPycPt zBR!r+qd)U#bb49#`8f{yiSoo`a%}pas$pYstj-sdgw5BH@1&e-!SJQ0(2(RL9!*r^ z;Awb7Mg~q-=OTh?HNt@_4r0fyolw%(Gxe3VCUP1sEw7^WM=!m`&WSh0i?4TDMn(J3 zdY~t>k*;ZJHpDRL+4bJ?GfpoY{h#l>wjc=A1l+*%ycK*xzkOs^}?EYi#)O+7lD9{Qaw zvSA-CZDYET+q~69l%(1fMI(HS)cjb-i&*FSnm+=0Mi(*5CGyi!qzig z?>u$N;gJys7leo=W>A~n4qqL|kn<5+K^Ay==ovTXQxtz&Ok5ldqvh7SWxa@PRWXDl zOGvw?FzDQaWO#<_g%10t`^YljP6TLdsRAJGzJk%^Ijlb}cd3~n!9o6Ti&u1+T0Ip@BX{x`D1AIO> z+kD124jJ-U?80$zSPgP)pBTsX`UHZZ@R`cta*HsmAW{$A1V!Kaj(M+oY5z=Od!A#@ zDV{oLTqO)@s8t$#H|xGupZ>^Ww?Fj=OJCFvagalY4%tKWu>bb(8>as8pa1N@+uw8J zi{9~`Kl$(XoSy%U<@sZ;Z?;eU<$CwnU#~Qe|5-m-x*@~*ujjV+%BU9qXi$&8Z^z#H zH|)P^_C-7QO+0^Y&-j;Z-!uNL+jow=a(u$v;B5TbFM9tIUZ8zk)PY=rjh~zCOHi}R zSf!b{J^yNb^o|AzgwzT#d@+W*-dcIZUGum9+~JS@?G(;k-}06J^OV8ZTg26JYpUGV=7^}Hwg;Lh=t#Un znq|-(0|()M7G4a7UsvF2;AEt*SKZiV?hJ|AB8a9C)n{O; zQ^@HRrDEZTtyt!h&YktF=qP|;dzR-06`qmT=v7r+hr9PH$a<$p>Zc*;It?fFIM=5j zgyLLOevmKKXG1oMhzLYRvXS;+p~$`EL`HEDDT;55xw!cpzC`4FzanzJhU$0z=6(?o zxlbJtIbT9V&gW^O$mst5alL;q5yEdM8Vcu*8!K@1$Q?;N8>Y~E%Yg=bYDZJW!b;-K z@%>@^f`$$I=Tm)NClR?I7w6N#OGJu$MvKpd7>L|AuPAW8FD;)z5c65cxj+OLjz&~V zp{4@83he@e0iV@w(n6ee;a!R<7jjck1(TH;pJ{>Re};l{m%dvn`hI9XTx-%=2Qljgo16{5ZhBkvtjXERW2R=K-P7Bv(-X5{V`h6b+P!-^ znBG<~J9bWDYNmqeY09<;({quGZwso`albQ{?#p`d>MvJzt z#&BT(&aCi@MqROZV97bRISbwQvr%?q9yabbLM<&Ktm_NW`;9ye^(;i3& z8-&cTQCB>`C#EPWBKK)S;*i>|!T3&@EAtG!= zioU@*ny)&mfR~6M;G`OjdjiAfV?%FkkI4|W0QS!;G`?aet+UT_0r8hwXFAEw_Y+Z~60W@A%*!J!4`|>xZYODzCrd z%I$C3dtmN&uetW%|9DXP-3aufEv(!@hkXilv*7k;w2de^sYoMT03d{BJlLk2bWRiOd)&;@{=pKJY`R!-ZRwUH0^T{5h;p^ z+#8CF;yPEFDKfg(_=RFdU7t_dD1ScTvqsEkE%87FMDEo``}|Cz&!FM+t0E#Ky-{5Q z;hX>g|1%^r^~h5m%jERRed;+E0?WA)6##}~)qda^;XPPi?=KW3c`XNCn$Dp3To~7c z``=%fwvi_ynhcRqsOkEQZ;0N5qp?&+sQ;*6!!ZfPFh>fVm3YBRgwH2+7d}Iw099+C zo_ff1%jeiwunsV4mlgQYxQH;u58q=?0r9X*)AFGadglQGWApKP{VfM34m>Q|?H~B~ zo44Qef!}`7-R-ykcx!m-P;J7!^~%TZ{MBoou*pA&=a_ZmB zzOVdTHR=z4{LE6bKJm-e)ce9aX4-5XqnBKmlqk3x*By(aVpCJKII+Q1l zG=iGUi(wzwABH{Kp$5g)<0JMZfuT_+y?}6M?h?}JL2ZJF3xtLU0=(z0JB7(RaOnz< z_s=2g97BKo5YqMuh8G~ZxZrhy;ZRwYwJ^|A0)T{a=cROE^AV9vpE{0wWg+!G3Pc3P zuui%n+sh+7B9Dv<37enLKKY}@OxQ)utxyBA9b4(lqyEj}BWE-zK&Czd!v1P}<*I)> zw7TsO*0mj0ENd{MAqsQ~{RVI&SASChDUkIGWIZbVVF47Lc@PYu_CfooP`bC~F~<%Z zX`OQ+O)s&7{0P7n`62)PwmO7iVKm!jdMLwCcuALd`snf-UTxR1{t)0t+tI~q!TDHv z2T%G97tRrBRT#Ffh8?V1p{)dC&l7=|Jc&q~Fh8vtYFubHxsq%Vkrl+;#m7e2XV%Lr zAI~yuk}qpvInVM+uhPUwz7COqG4+;Dh+YJ)gvV4}OwH{a&0r zdlcMAV6C%^UfM=c4$Ls?w`#Q&gQw8*l+N zhi(|7hR323Rux!9NaRQT3QOTvMWVm@rAlRbdKx#}a6>TaOB9laA3lup=NDPGgSG(} zh!_}SLEVGuAw3W=3@X5OeLzqWL4guNekGq}r%lWk(1sQ_?t(=Ba$SbagP^KP^r#G&jaZ$x__NZ7s}F0MzgpN z;FJh%l~%c$O=(b9vE8p>%~wIg$|tQ?{Ti?C<)CRguNBp}Y8;6`M1e>+-ok>yDd;&@ z=s^2t7$H1Tw;~Kj@Wp6;#@1lGo?yHY)3^h1Y!O)jGm#w5C(c)R2W>+kH0p)`AI~(6 z)4Z1QMLrd^sPQNJn$JAUPd<$M9=ZodPCtaD&Uv&4t4NC;R2(GXuv$>E3iN5f*V=0|-sf@h%t;(S zc>)g{d=U2@d;s^}e;+=1-@UQsG4F@5=yf_%cZg7Fq9}ehw zYQ5U^r&f=jK)t<9?#OPs{@K59VE-*2nwi|`ECdC2r*H+$E<@Zos<2)RsH_H1x>3*o zIvNfAA-R&P_mnH*vGFGM?%so$saY&7uj1&j6IkH!J4FJe_Y}4q)GGv)gi6O21R?@` zC^mp92`Wv;UAD^$6jVOC+b($zopRx^(ph`O@l%I>;OBn#M{bT=J{O7p9KPOlIgNi- zhWx$|xSUm$1QnYeF*<5oJqEW{V68#m;HLmw(~s;q|Y47G}mA zFkIy^dxo^rL)jnlIFkI~{kkEq#IphLAzi4zAXNC_!!ArbBiI+A1Geu;ly`kVs1!^W zQPf3@HllnP(mw~sWA$?FB#QOp$k&b`-#Cu4cNUVbvA!N+lfxPp_$5MGSlUyAdG5?S zd2FbHY-ki)kH&8W=(#k^s0*)_B}4+nTk#?nhqn^0;8CNb5bZmmb4J0cp{V?qEB>hT zU9P}oxl0y9&r@Pg%mvg*J&Iu1*F~f3BvLwd<2*#5J%yA9` zKlzTTkK7T15T2SC&IwDtDMDEYmtPX<03Yq=b}%ZWX`K4EqvDySc?yw^H>VRgwMQrc zjT@D5tj7T%gNNf>^i1$5>C=pyw_Ff{eF8XRNT<5A=VPXtK>DDA>}6kur) z0YYCXUu<0IVb$a-zpPWdLnH?ExV1FVn*ROQzy9?ZQy%ks*Ijqn_xJ6D#QSJ6K#^#?ceUap=e)96f%7hWaRu96yYQk35Kn z4jtri`Mvn~CqIr4edL4qz=wzre+VD>*hlfPPke$q@CWeF!w(1Jee%?4oaN?Yb$tW< zG$X$RVk0hQ>JW)A;DW!`8zAS&Wm$MvWJNDcXUX8l+^r{kM)QR4!IQiLH%+ddNaD%= z)@Y2iYqc6|F0D+VO3PDP`^UM>XzIE2sMR>K>wVo#?Q~sXK5|mChOc8;! zbH&|RU&2k-&f|;U_!7M0^KQrVxZzHCfZhsM{wwFevsd0{lPO$Kc=p=>ED0EjKem9; zLpq_UG!{Y_4_GQgLxl>@g+tz^u~KOLT1t2I}52O4ee?^341l1!V7lo_%$K#5~r_AoX2PYIX zr{d8HXkNvm<+(Ji%3rycra$iRCcOnuZi=+vaAzoa%x-fD#@veGSPQ!}nol|}wQWcK zhxx@hjmPk4nF^X+{gtbJg@!ALT+BVM#@zdG;-vDYZC_eO%j%9&=epzK&#{UCCa8>u z>mbfqHSY!Gu`mLs?Qo*jBcN3m#N1^^+@TBO*qZh-^q*A(9%!513H=IBm*dS`#)6B~ zggmQ%XnyZae=9m+wa{b0U3dTFSf_sQ%euw6pU-9eoqe}_Q#V^q+QSvJyQ}C7+sIWx z@hU(M?>xt(KS+6Geg+Rd{16U?9rr!>;731zkKOZ8eDZ-$;NYPLaP0I^oLxB0UH3B9 zJ7Z+w{{R3G07*naR2!_TjV>1iLtf+MT%0QpOX>&h6IVj8*1~f^CR{A1)F<8U5dB_? zEOl_sm)6$LUVH6I`53zNq{(8QG(CBec0~Vc)zy=PMtYT zA?hKc!gQ({gj$9*h**9NckmLxswp%G?TEG;8l~|n4dX047X>MeYHzq|&z?W>@{^|y zfB*8*(l>WI{kek57p%LawCk@Jc?x5xKdGy$0mg~$AlQI-#|B(jgs8tx1+jnEG~V!< zm*91;`W#F&fxOpaA1T0j7n$zTEe&eyAp94Z8q1Sz-iwoXKuT?u`3q1p5*ErsCO{uU zW{r@;)1Z65fp+HvHa3o+z5Wn7s|V5FIE1Ww1a5c|Ha!Q)&LeSaXmBT=@P5z)nhiXc zAUPKd1?ealDh2!^G%Ji(kGgD9n-0)1V-ETqafMe4C^nO|Ooh#;$Lwf1k_D}{J z3K~{uSY5?-zlJ{*s{HCc5^89+!;|+HV8|p5S+nIK_KGh*ATY1^UsMnIrzTh`#!yurhdi10gl zHQsR!Z->T-2t-Bz4RlFEUJyDzMko>J)K1XnxTw|m%o^l2Mox|G1+bL2dQE814IcYTh#@l|9cPsqsUl6>dhfIXm&;xf4;(Su=*jZPb@8|&EU zZeTFzVZFVAPHzKg&avddJFJ7}=~n48-lq={C3b`w-I{@k$N=`;aXeL@6{SU*2@QFU zA+KIa8hFq4;;6AYJ2m?O;CaQT?AVhi1G)1g>dlk1D_`=8cdlDE_DhX;dL^+^bP& z)VZ@uyq6k=6}+Qia?Dw>yt>NbQM)DEE5i%5~~M$UWktapwZ_a#W)M$F@Q>-%9x9`imtrcxFv8Ap0O`GL*Hk#Cz`81b-B zS{y{w9i=EgweQ9@|&*D;`VkE9JXV{9~!}a(O;geLfgnAI4hqI+}nB?r2VV z&T*ktQ~2DYOiPYQfxMs6h&zC3&@tp36m@CzJ)?u)gPS=Cq-nj15C|V;qH73!t2A{h zY>%(}nx=jQNrRVB4IbK#KwYI-p;JMzy^M(F)6x!F-q9dCMyX$80rG1T{$# z!&-|p=VB}!Ku;%xazjBwNqKWBKlF*pNgN?b5|m}R1#JU-v`dEat+3pk7n~ElZc3w` zK~Mf1jeIQ`JF#eh3$Tk3f>g+zBTC^e^r)1z$~Pn9E{+4HX1V8_%nZsGC!-krO!zPgTw zA3lWBrxvif)`p{SYW<3WI~^4;!&#&76h22Sq6S=#*fibKxT$a!q(dT$T+^vQGSbo+ zb}7s~IMSr6X_r&jbDB7|k>@G;gEmq}BWeUJ#(a+VgN}xO1vg*Y!du?_Qv8$G+=3lr zJ^0>fpu32ICku5h5L&t$=TW{9-ltjCtGg8{JXnt)(`-YWfp?Y@fi4vwc~Ab+Zb?+_>~n#%w~s9OSJ$3Owsk00L}-Y?e_* zdk89sXg+K`o`n@60ucpsYX8^=?Z+l5xYQl>RP$SwvjBOc@M4&E1P854nC?OS53f5f zwjAnW-#k%5*bM2T(e|lB*(*_QzN7S;}-2XcltL!ZY5{pcgzD z;-VQ!GlV;0Z>S5%6DJh$969H%jU@B~JgthQ#kR&a#*(RD>Mit^M+ukz-tF&>e(wW6 z`S~9@`G>#WPLFe4#h z97X0~C1G>XC$5=)&ZG7XPr}DM3H$VkFn-w=fY3F@0#C%0q?s`pE}Fq_XV`O zeNbjVDTLSId|hLJmxN7dGs2wqfyCH?3*P3 z3$S&^uaoelIw={dUZF?(;NWOvbvF+Bv>ySiLwRP{7t7+Iex4Xan@|HS%+E^rvZpd? z&wC+X1j-k!N9)ubIb+CYW+k6Ncq;QzI^!P0Dwtt?k+Q$)7%R2~u8YO{*_ zbX{$?g4Q7->I6vO9mqG11;?@Co$`OgPY>aEI8MMkEl><{6ud77=ZzEwmW#7dUe7w1 z*L#5TTgQ$Op{&kHZC_n&)aZv08PUO|1w;^E|CoQSqBbJ_Md|EUK#61z2kkaglGxL)9H=nnI7w z!N*$5dL;0!q&$lVYPA|7o4|jd|Gplz&PApUP=GvF?Ft9ae-@6b`l8@bmF|Aa z7zHPJNy#sn%DoK=@3^n_+^|c4hIN-%tHsd&$mRO$ujk$R4AN|XQ>RYg^&$^(T21?Nh(>p-E+NoPS?a+pLg)UwR^%HpG>z|8PyyWS;>rY^KPCwk}qr2P(Nes#Ze6(!^y`xtZ z9KjZ;+kL<&0rZoIDsYLry$%s!s@VjcE4*6fau{WI02ct;(?{{2`YWRCKQf=zRmH0R z(NN`6SM#ZRDMSP!BV3yHslwvHF}NHY;~li`itYIfMs0}*43)d#yfU8S&iNEkd0V4? z;XE+#oOcBmZW$MT=^#VSdsy9>aT;?R*|-`FLTJpZ{;9hiV7dXIpcCFLdc-n(Aw@(O z)>q>cfQS&5w5nlIzo2?VB&2yKP&rTorBJ`pQ01#!5zRk>#%Z1rG*m$-V+yctL3p$e z?5^?3Q-r>=$&e)MgNQ&xLKvL);G7e~+3yR|Z76f2-Eb1sP)`~wd*^rm(6_(idryuE zJo5VofA9yj-}uwko7=smpC1l3p4A_8OuM~~zW$#GB_Pm~DdmTSC^OlfwyCHVt;z#6 z+}b}8L;iTAX%^tvA2rq@0^MZ~xQS4sT(2kL?`5=F4d}1qXq!eD!d_9r`1O=< z$NM8!(2y4q3a-RG4Z5Qa(8;e*;ZV1N3S8+OR0*kI&hD5GKm9(xb7xQjr%s)sVLyY# z#YLo4rb5947ZO@0s3NsKUpgod+!8#=hUgEz(YTA!#G^7tlnm?YJEvjK%MyA-KP+-& zzC?!tJsgy9H2#B57m~Gc-M%q=@f%-=m%Qi(Oip@~YiD?u-G-FxERS2Qi|G;K!W-!E zJ_vZ_52->X1P`?$T}@*q`bMvY6&k8s_5WR<^;`~Bogx?0zCB)KlyC&kXZ7)rNCV?OAH5>XVh z?g+Bkccr6(Bk#9E<29`MMfszy@<`(x$7jezT9##@-d3vx{W}?xlatWrE!IScqPn-Hc4Wt{9VbJjPqEKcgZ=~?@Hf5WtJbFLJHBr$ znZ3IaPZS~oM+>LMkyqn1!Vz_v4RADgC6z+y05#erSL-9!A;d9W%ZUg?BnXD((bv?} z6n5|4jXitzz)?V#mX>hh#3>r}Wu!c6^Hf-lJF3xr^Qf#SA3YTe+oPUB1vStmk4P12 z$WtDlW+XJt3k-QT-5K^FR1jtnYuj;vyi%;Zlf!95kqb+}rvOF%vS^&MI^Y|zCt$!-EJ59M`dhmAuyp{8uYZ) z9RJ^Yk{g!s6nxB6u+LBUE`RB3zj1Nj?(6?!E1J45i5e7Vi;{{^kE}{s>aq|x?#>;R zK%oe6DrkTTqvkECkrYA^SLL86;UU%_;=S9%#02y|b5S7!pM`~muv1@NT1Pr80UuF- z5(<)KDj*^dPuDvLDBMA5dkQ)u8N(8AR5}XkW~foms0eM81OX<&JZ|rz=q=&4n|I^O z-}ExP^o6(b&YV=GYe<*RA@8*ju`bd6@aWFckIhFF$0JdO)C=*X%U3i3BC<&zF8gS? z%O+eQQbe{=u2fd!LY}S6n;wyi(?lfDC~{?Yv*_b?ls?7dE_j6k+v{@Vag|L(D(_`o z&)F-S7XhlfbB}^2K_wTCj(6s6P{7kz24l{;LT#MWoJ^c=oLltECODW-zou1qGzF(L zZ^xwsiU=GHjzaTQZk12CD^z1%(6}j7wltrJKt!r+!S$Z=*ppt$hDP{J`Osr=1EDz* zh&&QnpQ2insJ|>+SPb`R&{e)YEkECFaMHR(glzH9g}PxSa*4Ey&u(&v`e`>YU8=&BrsJsg-W!^v^=X9`KsGDW!g1qv? z_Bg*~kgZiZO6U=JmJLadV0?TW2M$~d-8f8)Pq7YtbJ&BnWjLJNH00{v4m}D}lspF4^2#X{AUy)p zo%-C|4n#D(t1D}~Q$L0C=NF)Vz`^MeI&~E7D-;f%|4Is`f+1C6R4fcJ-cXeplsry# zM8T_pA_sTnB|7VC7<8A>FaunF%^2SFn&;t_pLZ(~*MaYEK++*{3cCJ4R4qb$-&A2ZP?v;kj_MjI*3)krVJBD&(UrM1aQ8m~*h8P-CurQ-JYm1i?p5 zm*Etsp!pOU*1X#RQslKmEO+rM5ccCj9Q)`!cjY;BVeDB(*rzJJs#C+2zj8HBp<$&{ zU4?LA1Hvf{o6vmX!H~cR!StNM*5_ilM_xI~68MM+LmY&~_E7Q!1#;8fF_CYhmZk9k}+|17Xw9Xf$ca zyF3A0g#O1-qfCOP}NbT%#7cWDilimRE_G3@t|awQPAV(6&?TMd1_^rQ59wh2QW6hQUELlj?laH2qWua6(x8(brG@coa72V8 z`GAn|3LkB!f-XEN|D~?TsJ*HhiY;`*JW!)fet6NIY98SdaWhvi>LqC?L*Hw)1{(EN zInf;d<7=+D=Az#_|Ao7Mq26h%f9V4c9sJRae*1IR+8eUEwt<{ulBFIS>wV~npVc42 zl6HR?Tum1&wZ`)F{K%srAfNhUwn+l^= z2&9)`C@t)dx&`ST4u_$HFiu2*ADWNgw1naAQR~(O5rN?jQ)HA+V7I~=Z^b}F2+m5n z-Uy5(iIkp*e$V+6A!7B0%ijpgl)Kg55GiFCq)XdTxm6c@)U8nGsaCyKTfXl4>wfN= z-}=ofOb+jT_uZG=_x^jn_T=ePKeW;7Uf1pStcfgVr>ER{-cJvqi}oImwz>8OB!(JU zN1h7%TyjC7e9(oBLjBumQ2&rJZX?0;%p|V4W*=_2;X3Tyw-YgCcy)D|I^;CYpFa=1 z4+jK>oi4eU(7GxEZfyeYzPTt!vA(_@?$;gXoQUv*ui(i;6J%34J~sX5vy-zQ#Z&k( zPvO2lDZ2huZ~CUS>Dt_T6W98wTGUu+)apoT5vWTbrUYea)K4lIVLF?eCT+~*{38g&O{9yq_ zS`1-1o~9Hy8brqvo*}PNa_-Q|G=pOpdC4*N1USqdLSw8G(sNPF%ZHd0-uv#md+fLV;D5aN*ooucxw^iwKPz%6xHu>%FWOh_ZdsPRt4_I# zE}&vd;bV<4=@D>v;W+`UwRAm{AI1VUgv+qlDvBJij9g^{*Z@l8baR1CwD!^qY{+q#SIc6-o!b3G~KbO`P0=|erPV{&|EV{U54 zFaNVQe&c%R;8XNto}&GJQg!?5zT}abf#G@&8Fy(8u$v1ic3+2 zx4WTpp%(o(ejJs@sE$#ZhCSo_RvJB*eML!B?YfyNBAdREBO(yFGDf*Bd_`m{pna^c z6}jmVkx;J&MMi!R(cnc8k&sX1^6o{Mw*{(pN6MdL#PJ^aBi3hOI0r=N>U<#J1ZvEs zAO~rsk3H{8g=1*|wgD&|2*%w* zyvNATG!GaCh4~aAAN|$KB+ey3(*j7N5V+*2%896qcod{7*PSUX97X1m+zfK8y%J7u zRhC}91|r%Ly@7J09+ErCt$Dpx$a$`GdoEc4k1&5&pEWUKg!v6R1XyCP& zme&e{8~9<^iVb(?Q5>Dvw|CDkyyY!#>1m$7y63N^fA`P+^51^){!jkr#nsilXD{eL6$bneWVGdOhRM%g{`^)(~lYb#f1fIIjXjVOS`~Rfrxf=DE*_}T&(VF|- zBx;_GZG*euIwhVv0Pc!}3RQ*OQ5sZe6oxx?2Dt(@C<_TFXv}p7QkEsd9wUJpxmlK> zQEx$4+&gycfGY4lkNq~NTvk`w*x2asev7+q*5O?YPi4ao)S#=N`?8P743P~MQMVnu z=-Jod|M}`S;f1$f6Mg`0u(Cjd-hrV4iAZxS7HZ4^&v9~8AP(#X{SjFe-$E zt&d~=QMqPXNnHQ{5CBO;K~(MO>enplD%7t~e+3N(hzriA`c&mE=W5yQ@fvF z`K!EIPEmy`cRMZWui~%dY8~6LJsGHhIRM_t>hZc7a?izD zNu%z0A#7+Mqf%fwTtb5>G#Vh(K>N}`=WK^0xD8T#$B5VwE zoNuQXNCd}))Tj`p#Cs_3lr|lMi0O@_UhLVq_jhi8#_jiOzTf_{-#+l$|MR!r^S2-U z+jnL@+ohFT%W;Y#O~$Byx}rj$N%g_#SMmyu1GXORqqV~P89cAez|*!U7EDf!;hF>c zaLcVv!|k^}gSui*&=YFNAAa~@96fRz%S)@svyz*RI`oHcwU1h{=RAQoY}ZnD!3{u( zZf6(_d5;S}rNfg4s&}+ZOuD%{_pQdn`MH_hKlhf`ep^_uXkAZLn5SyTpN#$f=U?>i zItOmv_g|-*yT32;9A2 z>=@Rs&{ir0ZRp7$qKdnc`>Dtg5%&E8w2zl!G#(zoRYS~54HdKXfaSu>a6UO2p+b## zH|3Q|21jF#%)xT*m{MTKR%Njch0iO9CFrJrD}q7XGM!ny1MbODSIZ=sKfF#pBa9$)#@y#NEyFhT1!0w|a$BB3sD zu3C0d=a)jmMj}K!c`N9y^$VIK@$G`ud?tAzXZ{6sxjb%tdn@By7Lr>6j(d~E+pw}g=24W%}x92<( z&*&D+=O7|bdURI}DcSarb(B0>kFaa^3=R49c;>U7jsw^3MWfN+3Ep`eIdX(JhLz6QSd5*dN1E3K@Qa`-Ohl#r(PhxENKe7BNfJ2v|1Bx zerDGnPEYN)=!gB7{FHn-v!0Uue$saOju(IH;KteAzc@3s@84M8I4-V6+4B@6%8(x^ zKouNSOe0|e1r=Or`z<&s<#Kapq{61V^wLqn$hDG3o+^CJW;6WCi5{hmHO5$pU_fQP zvDT*$4!EiwBJDZ20?}53sdaw?6IC$JvgfiN^228OW;l60Au85uk3Cuv}39iib0Ob)MzDyZNMACZg0 zU^^k<3ZJUJs{YEY;w$$G#Xn{A<1MfGAFWbNe>}LfE)jvq1#mP%SY3r^0gkmOIQCBe z2Kz7#KkQcYGLARr(SQjl5M_X59zvrI&Nss{;2c%@ba4Pa>ZkLF*FYd5oCnx?L`HE{ z#HD2~4OjUd&DAn00|l?26dD#8wId>rC@La>E=>@*;McI01udn{V+{w;sHtI7=zKOz z6p@f5o(=^_L}0iRk85?VfdtF#KGwMyN@?63jT*K+T1FA_(>OMh=GnXu_uk+5$y@LF z#0S6U#F=AXj7aKn!=g9XfHwt#j0O4j5D^Z6pyUuaF0_i=ARQLayKCK5>-;XtA@ZV6 zbWp1s%*~JEy6gAhh8wTJ?matDt3^DmTf@my$D#M+=g%)vAM{A2Am)9yF)`aPa9ntZ z82UZa027S035$n5cjRiw`&?;tB(wS3fpaW|y>b0?xbJIJ2RDYI8*sxnoF&4&99u-SrghjRAVy9FE6pF^}i=?wrNT zKL2@mV|2^;KXbm7lu^A&zaUdl7_0 zDmEjlIxclLlOK~KGNM^TE}LKLzFdOFSGZjIqs56x;M0}z2>VEt3ts^wRz@i4|`nfK5UTzM_gA z#f?H+WjQXD-_ftA!jIKeBd+qGv~4dVBB3$Qc~}7$%^7t?M(H8~5dlI+M)OSzif}#) zIY*SZ&_8Vh-T|!3>)d7imU&pfAHdZo zaKMD`)z6cT19#kUM|Mr~w%@pR-wj`}b7s$Pw`x=ENa{WqMNdWIhzoiIst~*iO@L)s zA&{VWEcs(7hz@$Zt{W*;awV0@SgnPzS`+np194mn!q*>UXj75*I(=lF4v-HpP7VIT zXWoc^`i7U|=?8YA*f@_U@1yAVVOXK45-)S8vCefj#QS&Ai3#?HN)LqgTkBL;p?-z>73x>0UjZ)~gtnv5 zfQYcF%R|%uKH;_*Ds4yPVmboNns5ck72+?A7Lm}$?eW5Z&mB;B(Dr~Qun-z-#~qy> zuV>vsFywky?zk}YG~~kUmORv>q3olM1!wkl_kKT3v)0s7~n2ZNOHLkJfawOS2ogbj_l$cQJ} z_p%V`6$E5yi7pp^9iBjFS&B9E3HeBu(xQHAxihcV{X}!>p4o|gzeruTxf0RbPi>f| zcK4s$z1NBUb#HvXs`AVAwg{GT%p&w>Nm)^iU$=$G3@e) zbq#ZMhcEctXW%Pe_xZSPt_4|NM$8>szS>4ifflwI89~Hj#qgg?Sb=ai;#8@Kg{Y47 zlv$s`prF2W)bm0G0~jV6syjoqVW>D97b+zcUrw3|I}X#_jdgAR?iDRuf!MQ7eS`6oBC*G0bNJLfA|23u(h4e#a85|M?@fU0U?uS z+y+lgR^dXrr`K&kHtPf1_v{Ol7Q$yJ?kVucsHussmm}!P_U~| zVDqsO!I00Q{Bf+)MG)@f91Ts;D^PSZxNgpS@*KX)9Uza_d8&(qyS|9@k(2NuVf^$D z0|j2OJ#{ym2;&gOgT`Eqn+?S3_?INrLupjwqX_vly(&zqK{&1)6kG{Hp+6G=#)$~K zFU)NMJo^y(L)rl0KlYD1-@+NB!xAayea>z;_808CD6b&^!C`_vobwvasqR|0t zlFdtq6H(VPUfZU_R}D5ZK;cZuS?dUp!(4b$vP#bFD2Y+4x3DtEad^GN@otHMwa9G= z6A5c^0kYW#Z-lbp(Cv4ywy`S9s|(WWZwL)K^aopGn;_ynw&QU+s13@^1zBujD1`j< zWd`s19P5-ud7TUSE}9L)o%k%Sy>37DU9%6<)3Ye4Cl(euIC$_l4jnp$#ib4OIp#$P z6kMdoye0q#u%!H!suxIeAd@TzWA0-(&Yym#z}iX&8|z)tSirO8z)x$C)MG@kp}tDP z*w<_&UX^Nb7c>)?P15P!E0anY`pPR&&8hE2%ZVkvCLc0-+Ka4Zz6b zG{7`fxau_{5$D$hjJ_U)%c(d@ERt|- zRzcI%udc#v>qCg=M?@eZFvbLk2=vkX6*Md&Fyy2DyrN#IIQ@e~JeestUv!5q8Ap(F z#1_oQ`9g-nmu`z^G|e14M+NN(7vdck6QB{-c67H6KAv&g<28-Z5Rrf?tZ|Qq?fJI* zh1Mk)4lxk+88FDp5_%G)znbsc(%N0%yqFMA|bzsx)NwnzGc}k$Y@byDA_NC zqp{UG$y(+USQ`hq1)n@mk*5O`yeAi~MqHVq-iRUY}%ba;}d_vN~2a3y7n zJOv-k2lB*oQRApnRJSx5Em)J3wPgH{ckJBtzB_KaLtA9-tFLlEXQ;YY@f6^|!>?cX z^F2GR|EBq=`JbGq&zv1^&bnGOPEo4|L0sc*yW1H;1yBXCz0t>zig`$7tw?i^jH~&S z$E-Qy_4iYU#Sne&^84wKyO|V?dL3g83-~ssCUU&)HP7Uo`EzjKzzkfr!aMU7w?pGb7|}EG zKk}<(9vM4|xl)>lKx70!U4u)Xt+Jkc(E(HUG7u_6MO9wQDYnA&_zFVSYyupU0PbMb z;218u0=$7R&T}5<-z&&?9GeY?81@Iy--1X7L+-FL6h#3YpU@T;4h%pxUG-OK!Bs=Q zY4n8#T|uo+Vmy3EbI>*o+cuke&&mC1n$&I+32N`5FE%1aJ__F2S3H_hDivOO&cGbjvrmXiDQd6e{OZtm~+f?z?BAyvQWQ3#6Tnh zC~KYz_>v2}jQV8IAEMVApxtiMpm)&Yyh(>S7ad?;8$i}D08W>JV_ZTPH?d7nGxeow z_FVU~`dd9A!`06g2h5i5s^>}1fji|+_w`@=_ER&@x#8VAXAXQtE1CTDC~7UlQ4`JD z1P!gDfb}S7eF_$ps2B>9BUGrsxDo}8c|ql?iqSJYqcL$-P;m7>q+%DY?0vq0sb&^_ zHSLRD_gw7VX^?l0Vr}Up@~qFDyTEWaq!3Y|7i@pFDVW6{J|b{q1IL|#cn`z2vH4W>ZU(oUE5&bRcnqGZ4s{Fz0a73Mxri}Dw1r3XH!KiwsYd#U}iTXyU+EBN`DCO~f4Nuz%WrXo4Rh9@L zlE~0_C9t-EPGNC$sg0E(7jQfYvvC8SgC-(d4T=baqa?yH<(N@40T_q~VTn;ZBf~2q zltVyw_y~^+em&l9G-_zITG+dHKlbe2OaCN~!~1yffus2Fhwj1s_a3D&UxO z$8jCjsI0||H=HMI7csoE)Egdp%2)mLdZ(ktyo;1u`kXx1afh*h#BeI7>i4XJCvcuZ zTdOCiCpAP8`-#^4Z{B?Ut^XHSpGR=OJR;_5#FL%_Z+rgR@^}67@A~U!-1x$OePGx1 zZ>z`S|0j~hYCUe1jan0)IwQ+6lr+)`6*lS)C>%Tr3Yh+`sw1Zg2r?={Yh!Q=jzMP` ze7zQH36n)iI#WUJ<8H%@% zAkC|(Yy>`pOWGU3P`BvZna4Ed@#JoxwHsM&9s@gx9>$PK3kC0a9e0$DN5IrHFw}Gc zrJQweccQy}u$}GD-O^)&1lmO|@4^cLM#MkjTP0u7WeI-;FgmoYytEKi<+${z%3c~T zLLQSeM|i`J;Vxix>!9-5qn~1jqYiJyOqlDbhaC9WpM5&o~pvU&FD8cx_{O z9LpM^bm_PW=LZK13a>kEj=RRQfsn_r^P0Z`2RP)_{Ms+WPN=<8Xj}yiYud#ytdn5n z!gd_zR)82d!n3d1=Mcv`CAiK=*Axxgro2-|ZGhCEUGS<-mwX`AlW$Hvr{@5SuwEaEtZ{+|tcKYr%S3QnC` z#^PcZ-41A|EgX$H^e&u>jv^N>+BJUU_PQB*oF^j#-baT=?7eP^A+Kk$wBS&ChzKI? zyd%{iVGdf7C=gCbZ#?}DwOWm6_(n4Jz}(#apLosl@7TcA=Mfwj&4NcnUyXPwaNw<9 z`qq{2`j_ALyEh)V^`G8y!!2L4XMWFbPLEBkHtQ`!CWb0FsdNvOBkec}Uj##+78J@X zML}iV<9&FyvxZ`@f!RrmFL}e~<7@ADEw0Q;{$8FdvRa*+Yj zxC$Z?${1sUD>4dOx(X2?p8(6T*RfJVp7Tg_&>djNot6GRxuY_s6$BX#!#Tj3!h%9w z0U(T1zH{pfb=5#Bl)u3sb3T#j2zfDjLK?loj;UY4;1yp@S8UHvg)4V^TICm|lzkL2 z(1+6yYQ%Hy6bml)3ii7s_dJOsDVpUXY_D5HATq*5nYIN)E~JZ${33Lj$71Bt8(wWw z$1xTlF#j!j(G)*(|+r( zmUU^J2l_vvvCUSij;ZO1fQ)zW`lp@GpIhJ#y-Pj;Mw*nRDEwOL#(F=v>uX&ez4xGh zF>;M`YFMFR#rj&C#=MUK=TX50z2`#2=qiH!09{>Jfr~38RTt8nKu|Z1BN)z8)jbyW z%=ELrbJnQdVI?s5E+FZdk1`3ql$+n%-$ z*~)piP9IU~VRHk?KqC=_#e$(i1C6OF#}R})Hw38XJ=7=OQ>Zmwqg{yCyejM-B_oAL zePgKAr(o;T6#P0oVhAFZ7pk?A?G3qu+(159MloDr9eqFsM9Lbd8r6E>@{hK4DK4M$ z%CRDw^pk&3Y|ARLnYJxXy&}Rsn1Cv*?!_?di-agO0E!Uc#}kgc)8;M*7?v{@LhWg& zSS>lFuFx;sW&7N7=Ut*47N~aPS%+iIotFOXb;r9-$viCcsxi0Dkk16`srLNn3Eh|}l%`S50u5gZV8jQ{jw(<+>9I zSOg->Ll_!Dg~-KW%~NtLa-K%$udnrZbut(X5JeGYW~MMVKLZmRw0mtVt}J4CZ5dq} z`>ZU&0$z=;mjvB@jtwsG*4uroZFI3hqrI}ej@69~tnqGK(Ph0oUd0V~;+H9p9X5F! z0Y+f~@@_=CBkdmEme3y(bgp1nP_MO6i<|w4=Jb1a9(cz8Cu-@n6MqkQqE~62=pDF9 z|7V5+DkNX}>bI_Z=QsU_U;CWfp8J*8?!MvQ&rR;UXJ%r~wQA$A6yTDI*>iR6sI5H} za>-q~%X(-fC3Z|jcoQ5Swo!iPp_c8efOuB(`u!5quOe3`cUvyx& zJ9o%Y^0YzgP;75c>%SCL`jz6V^iSn#TNOT4-p7*enduP_>PUh~S9~iP3je9gy`6?IeFSZ_oEGxoElo~6I7HeRwg)t_8 z_EMuSk4q9Sv~h9v?eW|FfP)BEKAh9QXkh}%mZ5AZz;VgU7*2H@?m54LbrI;1e4Gg9 zxk1SVm*1qJ;tw_-5eYtxA3?YP^w4q8MR`U8tH<7I$ThCfXkdDJ61p?jAByYuyI5K} zk9KaSRPN%qhCC~wzy4Z*S-E@nU>5?st~A*XBT?X|p%E9cfi}Rv zFy0CK!sWH2+^UigQ^2cy@(Lj5bxyNBo>C9}>h7J_{OsGl@NNCg!dIQkaKKz9?rPLi zm;=Jq?_2M9$H^~w%d@*DSb4-6M<|6(eON)-AJL`# zR)D=$2$kUo0;NUEYcUuqYys3i(xD79u4F_72Xc?)L01i_0?bU7K<~lTKv4{pdas~?OT!1@yHRyWm4i2f5p7Wwa$#HeujX}dS=6)NI$76M&Ra4>KILO-!NAF%ms)kQl9(yqL@804MwQn z@6*jmroeE1f_baD96aM4$z`8NKYPVFQ-*Mrr*cC(KuDt#+zV-=cnxb>1?@M<0T~9% z&WnkVa&b|E!B{N`3niO0v6u=zaO5mb4@wN87(QZalodmMN3hH-p*^kFa1o;QDqq!T ztA9H*UVm`4)9FC3$ub&s!*<8U#<(+|ff{u+@XO1~SX*0#{*4O9MXzVib1vRX?$E6@ zU^CFV)K$aoIak%ltE+T)@?X={uWl8uVf9yW>d$n1MW8YXok)6&=TaE9sXqiqol``p z{lWZ>Yu@uMUwvm=>v-bg@1Jt>_us_T7M_S4xb3#vvVVTZH$C*)=e_A$Z@&JS|IhXN zp7uv`Q#(8JQ*%`4K1h z`WcEcg`=FQCepT3WG_-EmH+-{044SEVS2toNc3lYo8g!sq%id;;eDnUg>F?AAwLWZ7h7(aA>(oSkVmJ(jXuY4$nh&+Xq4k%!~tO=hDOoQNRJ@U zuo`hSF4TDDDOmsj5CBO;K~(VY97`}yppSkXS8$$a_)=)T0Ol*pGL+RkxWc1piYp{P zYP6x{vD|fw2$UvWBxsp=1f>v7j)zz>t8rhXovBv@?VI&aC3#QX8u!<)K#Nm=>@sQ|GaJEC!A{X6;i&gNQ);s(l6@ zbrp`s*J`ZWJPKE1u4R%W!PL|gnvF5`$3P9d9>Hf>3P(c@QlW;n0>nW`vx?GX5zuUCr>+<-oI^^{n2%__DA2;}<{w#cz4RGhgu5Yj^Ma+qucfeoSG% z`MN!L<13zp*S+*69M~PB?4Ci|S>#>$0K(P0G0{kUQdFnBpDswQe z&=4pfBPHO2At;0JEBB2m0`+Wzp<=UUh>|<wm}7xQ=+ zh7Elep!eej7Loq*7P_mhcIA4kUh*!q7-Yz~8&}uym{Ze3J~iY#N>_gcZV+jJeSqV| zvqIyzPJ?GyT@E}HLgKcAmI4aHc!e4k4TE%Vho&jCpNg$CZvX{*sIT@*7#7y2(6|vy z;G32e5&Fp|@>%c7Q|^S-)rB24+jS5YGXY-IrFYvaqUnIh$i?G*l(lgj8K)i~&?h3nRo?2t zMR(=hZg*=}ZmmVL*}(YtIL5d;uW_LOJ_Q$W8F%d}lMWnTVI}f@e6o_O9fna1b z^5AM0j6@I`Z-o&H5usrkRV6YiZ)hkLA`;R^sbGDhd02(7X5wkxKl$Nb{kC8E(O>_LJAdl8zWb~H`)|MJ1;77ickTV_zy96v z`|i6hp^%NV%cFGotH1bL?|uCjyzsxh;T6wceC3Oug`H!9d~FHo+6r1+`8RmKuF7Al z;KmLG4f?=E7?d&cs0E3~QAQ04as=>BP?eAv=9ZCyBn=FVgV;bX}-FeAxb{<&xUVzEypf zO}r4PU3oM(s=7IZ0NchkSO|ih*-RIX zX#fo=IF{Jp*S4${D;BkILB(y0a;F=UxxQMa_Cblp(!NAvXY{TQjC)RPQ>)@L- zz6oU}FCbiS8Hu@`qCqD^9T&ygyfJ{`N9opuiyocp&RlogYDm@Kt1+LPoaD}Y3>wya z6*P~CKuiR&1_BEm&k7}ZvmK6OreSSQ0fsd%_-qerT!m_mRAJ5Me8v(5l%87BM58v2 ziPnyj*X_CKTc5N4)qkk}9|bd9eIFwS%wx>=__JLt@t8TFJLq5hgCGCA6?^C%rw@Ph z2OfCv{lE0_gMWM1!zVxSOY8m9KfE?L^^WzynLFC)nOF47rQ36}e%-(=?=8&w4*KT` zyFNoq6H}S2P7dAb)W9vx49b-`7Y%mirgQzETz_^aU3ukNZ{e#J)=s|r^y0CfI(7c= z|M=KLfAK3HKKhX#`-=yE|GR$rw}0r(@B82X{n>Zjeb=tL{^qX6z&EcFp{4 zFM8gMGm}Y<;o36tjSlp#wP9-Tg{Sg#@Lc8R0`zl_40V|L4m2nBp)t7+_0|l0oS^hv znKMt-jtaOc(2+ZeRsW?JrRdinHZeeD0%{BK97fQjp6YJm147POhTuYY-YbYYaRMI(S{=Y1DNL zxS59-ztwN0r6{wo8`s|}@AW&B#Vi=hv9Tsb$LmSRuRKwR2&y`?yz)T#;kfV$bSZ7_ zf^i4u3m`xR&K>a4a&#_wMFb+-VMDrT9W1BPs7Li;g_Fl{P8lT8_|l%aYu+<^>&<`s zidVd%z}4q*ap0;!e_ZVP%w)d%?q8_ib=NOn_iMlNQ*Zt0-@5Y`*NY<`T3I{z#|0Mt z%bAr2-rjfTUo$8dZqEGD&R(%NHt@^QpjeXba1raha{((nR$lKdp*?7$(;uPT+X%lq zy4GI9>c%S6U0hzk((*D^*VoYQcF^k&q(4ZLPIoxI)?L5u+{UR_oLqhQttZYu@LeYt zANtjUC+@lX;DaCeo8u20{DU9+#UK2cpa1oreCHqj>94)*_kQn3zvj>W?1#QKj?VwW zuDRrgY`HQv=$wWAezGkh9&tw~haP3FVrsxmq5cpw3Q15K+kuJc>k&_04~*>%e*nhB zbqslI=z_s0sO(EJSIg45+)D)jLPWMwg^Ix%E*6Bl7Z<}925O8{5g9HhY{W%_7}8+i z81^h16bKEzl^hD1BYbuq6+Ok3TGuo~zFaVbms6U$3%?lDjmlGwgG)>3ZhXkQadmU< z&fSnZJMPGZ3oq*d+5^IH2dZt68;nzXr+{aeLsC$=7p${nq=Se09o-Sw9^;iZg#*iw z=h`a~QD+Onx(!3yA#!mZt%F&@E5f>zh5#RU5fU!>RAod2A`()oxIzv2=FVK_nkE_= zM}ZCXX}${B_J~NROKDbANk-$VKyZa$1)az6ifqqo*v5zsavvls%++)2J=}YG8E5-| zYmB3Sz#7 zL^Pkka1z=`ptQDXV`~TYUH4u4pZCmPxZ~=7x`I#VF`rJMe*|Toa3oMj_0H`HIs9->vrs$Xx3Vgtmw$f`Z-C9F50~nVimnX2g9t7yiAet z7&&)2GOom(6e!9eiXtNll-!Z!s=)MzTk;ZFL2#!ws^6hiQ#}vcv@x!#DQvM3(No!0YsGvl^gqNs9@qahC<9J-f$riai^m+ zfsY8w&{c(`eA1&z?;T_Q?l0}s-@TH{)S%RPYEpGClN|V9*sstt=2PP=P9S8buBJTi z$_Jf3GVaDpHRLq%nr@weZ zYP<~Rs)*2_1D^b1)=@bl03nwA5yKg5pmbaM)m3=!5FYXhg!0@dj~FS$C=4!=Mqu~I z!;$}TY2`2rr%bJ}SrpMEPIUwyKe>kc&aGh>H;{~tq0lWe>rRp+(CfGuGseJCZal{> zAEv>Or^7zfs4I>2MuMrS2_CPHLw_C3T3rlymT>`|G*@8NU>6}G7ofyy!l!_U0J6yw zj2;mQ^(c*_n9i?iNV%MkjHJ?_g ztA!pj2Piah?AV8Ped7K0caKmA#|Uv%qjd#7%`30Fq&ZUk3ahAj+tqC&(J0>hQMr7ZE*3#O!UDu_~*Pz;L(j8CYP zYV=#K@GZjv-DQ>VTpSb_4hr-)3LeKYe(12i-o;wCjrHLM7Tf3K>>ovvdc^1ajWTx>*nA8{nCD}|>OvAhFJX&`zT4MH-7+RQa*?YI+icF*eSTsabUW)dbK0&r}JV}vUK zY@-6Jz@<>Tr}cQYdszrVaW)lRtqz8f6&(Ev7zq32HvuYau?Fgbc^yl8(!3pv0!V-< zJxoq7cjRR+L)J-=wub>F$JM3eBY7=l4B>o&_Mb+*ECf#7a?yw)hc9!Mc8HkYa!eusG5yLnh0ZZgBkPzCkKPDi4MZgL&pRRY1|VOIwJ@A-B#vn%I7yff zfRya7_Bm(RjbI$(M-YxDnZi7>1p)a?cwxIlL7G@O7=?oO<(^kz(BCyD4997e0+>D5dGcnG{JaqVXxAKfqFEC51)>g2O_ixlCqng;MBAm{>XkUK$6qbJKBj)KQ< zB?N`AkKBF4wJ~I*Rm+tu>zL-0ZYmuS8X?cz!4GH{mpgu! zMm4iah{h2J5Y3Fo@@PAcCEsJoqjgo-p1+Dy*FpPFkVFxIh=i<`uAxsjj%pm#sH$Qw zxJ%Px^|Uuc-p}A@$QK&3Nrg0LNf63L-2|XgH*?FP`ZE>L;o+5mE0&Wke)|LxO(PSkcY6Fd1;} zrd-JBt~_TS9p@8>k;_aF{RD^zXjh35y@s(0?Hj#eZe={KSASw7=ucsXG#Yoqx-ILn z@UX_fa?Tlbt%cBaY^LyzGt= zk779HoO0slg%_nUi4OZMYL`g(%7Am%QBj-sMQ)!-Bj}Qu*TE7I3dUA zHnm_(WGA6l5O>g!g*!9h)V&-Ggp>=wuR_SD z5g5gR)8P)-q+i=ofP7Gl@*3F9e;U^K09pez+&OomYQ(et06DKxO77a!$cq{svMY=e z{f6zS(dOiXFUd{Dc{c9o-9ibFt#Hh%P+CS1XkJo%(JP!^kHlsc9cxeHunn6j04PD% zzQGX@w!_9Z(=}4tQdii2MHN<8p*-}YS^FP&VJx#7((F+bB{I%kJ$5c=+?{rcshYh4 z*rq}q9g@O2P>jf334bstV*hog&0;78JW&F2<|*{lh{jX$61qFq`5aTAbX*Kedse34 ztVCuF(#W811zijDBEop|xKJ99Upb9`NrRq|_B@%uFqy>hjUN8y;33?5@)XvYub_b* z(C~rt#c~WIhtO^?=0%FsWymN`WE1pmywPZ&*=$1Z%404VL`1s~AVH4=FUOFTjl97h z7RsV0Ed|3RJP2m-tXTQ%*sqezQ$N^GK%g`!m=jD20C^$8G-$Fw*hdk8jztnhsK*I( z&J|f6{IgTj*fFzXY2WT^|IM?nx$Zw-HRkw?Jmxb}%hgH(2dHq&#~-}=+T5IcN2}$2 zZek|+XK^i?G;zlHKcE2ikrqP?cptvFwua*r-UlCi5Qh&R#`*K-F{DKm6G22meMTac zjuBuW6ifx^2ZRD>;A!|vXQ-zQl_P>;BOUNm3ZBP*-Z`l2)TI&+Y4l_B>F^HSQSs28 zQy~@*9u>N@kNI&M4EdX0{Q_LOo5$lD=h0tZM8w@m!m@^SXx%ZDl4JXMNr6qKP@CL? zI`7L(eVYBPv27a+o+UksK$kHa{VmWc3+ zk>UtG2SE`Kf|vPdKQ&t23L1U{L|b@7>{f)9-3na6V@Syrk}eyqbq0*O1@&K=QADUd zWyCwyZ>n%fdTCT#TA~~l$az1mzm8U_5$BFvkHYm-H0B(Ms8kINvv?1o3qoBXgVo57 z!U*jc144b~zNJIpe&| z*{^~|uq3}6`wv3~D_BT`e!AOVHAWiNBuW$1aH=uaew5^yrW>8B%;d-$<(GziM0umk z48@_%Bjm0r(My1KUC`1xiF9qNLTglV(dt{Own1qCS64i4Y~0oDWqt~+uy>LLQAQ+LDa zdKzVtzBQK0PYo+X1jYbDfc2?dV)M~_>^{K7geSn62=X-4$FQaYEJ%MzCIwyttOYFM zA))|F36jWwK@^;uE_Y}|bxe#+xMtFN5T*II`y0t!Z+OGEq%3~*{S0uxdcS_L_9 z$RDa5KK@s)snv$RGBa<#uQ`DmyzRtkxypIlgC4J^MF)#3=WyicQ9ShU!+7Y>AuKL0 zVxzmkb#aM^$5C-?DP&Zrs+Cp42!)pr?}A{ca0rD6&a+jZq|iFjUq-Ax0cenGS(or=S;zwABZgkTL`RkjC1O!ds~E`a?8p$bZYpk~}w zIk4;nXjvBhJG{cOmjg&y1yB0zVEyd*J;hg@&$y-TEgmBCuT487fXw3XpsF z%#93rH^q?0>xKTXTUziA-E)U&c;R7quN(11gf(e7wqX<{TU2l!h<7k_jq_xW0(6C- z3&W3rwka%!&9E?=qMARIE2LUs=w5`){8~n#oo2JF8ypM21w*DN#IdE|AGIAa@*`sT zHKf7@2=gcaw#qg#?#lIuT~Sa5JkyQxjPq2qEW;jx&^G;*5UJNN-T<&3A=;A&?XsqU zeqo<{Uci#JgbPTti~leym#tO){*X-M0#KrgBy2XcPocofGvJ?_?7myM8B-%H zv3tk$Z+rHxH~h*Scifrb>hl@nfDY$ptg@$KMV!QP`t&FF6^BpXnKbd!J9dq~xYjbY z0$rM~0o0JUdn-77?l=w|eFzUe^Z*VXI*3zePhq38&RzMCx{l%#dFaa4@d#WE(WMAN zM`OhHMuO{IFw!bu4iU@7WWNC@d?G^eF^p4K3Y-C8;R&H=fk*n_-L)$PV&ias-=`rT zbXPE*WO&U>pNTK}f)`-ljD_@;khRZ423;hqg^dSgZzMv&@>vnXMw4ia??QvSb6=ZA z%H#8lG{IW7%|hY;DIp@Hf#qoE3p0k&(ttPS5RLC(*?H8*rm3K6R5FJ2vrJGDYQQ}l z{R(V8p1gq8E`BrSkh`?hKfhYx)V?PDHM9VUiMNr?tDN)uRC+g(+xHJhKAd77q39+pyh@9 zGg}7SL&L%_?K+zhcq(y_>*^|<6_hyjSMGK{2L;Bn6rt(cap|yJRE`-nte`i%BC^$g zkt_Q(vz8kfInN{A9D2;I#yoc=`DrjRHdeEa{J{rkImKo$IF_`JXmueR%F?I(QbR7v zKk}{=pvxofRi9$j7U+-1DCQ>--?$gaE!UuS>$Rv8ja#lq?P=G*+^`Ss+TCymc0sP) z#qeGvH(kR#*CA$lx_25I(_=Wwar)b%C-K1I3N}bfmy7GH$b*qCc|5I#IY13Ai4q#& zT9QD~sBOv$t+EISB;ERoT<~juN3xHukRmez5k9o?kI8f`HXVF zd`4?~3Rd?1_y5l4^s<$onAy>MXHqW@42J8bbbS~KUr`RQytaTN#}45W_kRNSKYSlf zo;!)n!Nv^)j}cjLg2JH7reL$BRMHtd5|8@p#?yz0eI z#~WUDJNC>+=r0|E>#ZW;ePjYo6%{{%Ra2K66jFoKW)Y9?Lyfz0@XlQ0a^}^0$&x(q zreMATYA>7^IMP|7fx;2Ki2^miHjva>NRm1%^B5{-;~o7ZoXXS(2vr(;?j!j-=O01yC4L_t(+g7HFpre2YYYx7z$<2?J00Dz>jzaF}KDH(8# z-+}4Bp4+GBxHP8w@R#!tl5czkBx>!Z>EKE z{~QfD=lrb)U~WDDf8!pM*X%^Oa~9?7B;3RpT)mFmSoAqI)CTBKF4v<7{fTj;J7%#m z-oT-vkAuA))@ltjcI`rQW`>*sJOx)}pcoEF4WP25*J8qB`iK@X<{f#>M3i<*etP!D z5u<*ZP2(QXwdA9SKtw`4`Vn?cgkfudO?V?9nOi8Kbs3hkEMkbwSID7F$VeKQM+ghc z@l?d@vt_-J6~x4Nvx$jTtDJ64ef;|U*T3WQU;4RszVj>ZJbve$ce1k0dapX4F%Fo| zSWQo{io8EmJ9YMxubbI5_S5w-d6SnxO@GBiVn<_@V`*)PhWse*yZ?SX`0ztGx4=8- z;gCY(;3y0QkFOmeob(X|-B18k*sQgv)nXp0#n3oLZ@MP9B0>QYz;oybPY9*aQ&D;< zF;(Vy=8>fi10Kg_8ECwLJ`H%9dJJjR2g3sWVTwNQUpH1)QE+$Ouo+%(`?dIzH@paY zY0P_P4#A{tq#Nrr!Gcd>*xa^uz_xY*^%>+oAq>PCkXYp0(UjtX zde9wCDU9dN-g763GKRX2Vl5#DJ-i3?1PDVOMMfaZP$^~bQ9rvn>i=e#4nn;5>J1Q) z;8q_I349Y7xmEn9O(y5lUADkaqTnW34Gy2JHkagxLkcv1CVJ9BWi?=Uwn_ zJRhXID_0Cr((f5>nOBd2!>*j!X*c0%v_XiLC6p($f+1f0Zj+bluMpBnw1xqW5BRWd z4POpS4X=nmf2dN}c8IvbXL}u&W)Wc>o497y1!FiH@6!|p+>g^eT1A>&~=UjPw-KH9Ge8m=HR zN*5Wq8t;u~IZv;ndm$d|Gb1m0MMmj5)_`Yz5G-fPkxSY{!F;jON5nk!tJxZAam|f2 z#~0_Ocm2fEZ+Z4tUo-ZCyRQCP8vb4%^Y>c&leBUQPJDV{@MXF0{qW>`^Y*0asaI1J zeu(y@Z z)NmKkG<1MDf&R0O~B zfGdNlu9D2su?ELLV4-y?wBCsQw-7`ESRoTR7D6V8&rty`L(F>#S5Wt5LsH~1(HO_f z_$+28cV+XF`~G@6<1BZ>UdwU^uDkA`{vjCNq4s+@hTLVRJa+7~d+2mJ!I-bF zx6$S|@Mic=hK#6K{O^3$SN8LD^nL zw!R9kK2WDI*Q0w9PSPT}obWn8IW@iOO+J)z?UZ}`GGZ<5uS5wGP`LOoQpdtTE@P|5; zpW6@;9^*$uAToj$(fZU?sK0`S6;*i46;iqhjdSp%d;|>}@~v z$vU{BD|rQy^VB5c7(0$x!QGq`;5d2MZ9Jt8rBY=)_>^pMv+p3!wM|v{6*SCTd<`*r zHID3rx}%s2-%H~(yd70ejnf6mR@fob=K>9*SP_A9Bl<)HA`%j`?4{5I5efWJ9*NK| z78&n~3NC{6j$DmTC`Wnl1oYv&Qm4YG1%$Rls?dnGP?o&XMF%z?5kbtIaKgDJBH-p4 zDU0=M2I~z2xn>vYH(!VPO$U%pPhve5beMNgPmuDcyvuQ0-RR@Y*<~C)euk%VXK?!L z5*OAq-0d8BW_VgA=yeOUR|i;I>Y}%vazR(e%+wC{H-dH{N(8Nm1T(WO?ASStiSati z^^h1rjq($75gdobi*SYksz=lyJ3)9vB=k81_z@X}m6sLRC`3EYm7g~*30oo))NI>+qaw`fF#dD8u}VtRNlqY1DzKRx23udej;=lF1LykiTR1-kZM6 zFYkKa+wZuu%LcB#{{S2?{{U?~X_`3Y|E96Dc;cUT^40I~wRE@Ca_J1$adzPZ9z1kE z9(d>h96fdn=N1>x?hcW0)ovt)BH|I0p)ns*4BGiSX{&D(~CHH>O4-IT*Rr9=g{qT1N!}bI7097D1AsnuWc8F0c8)4 zMmWnd42C@_m>yi-HHap5Ba0H`T*hm59cU{CLHW>4V1Ytls3k3DT0$jKGbv)QeYc7z zUq;zEi*)@Qh8s)pSs$br)uT$Q@{e3MayGvXf}q(p!|HhNgJI!^X|$Lc`8ZM4p>9=F z{g;CXY0ym6%8mqAdBC0oP~!^ItTE(a$b9++>sBE_6AUcUNEkt zv5;WUSrd7vd=$q%v5#6_80Q%FH1?j#)^iu-d5^Ae4$#Gz?mis@N*8MZRk0Q&dz4S2 zly8Fd!YfAuHX}JV5uoK2LYgaRy4ipn(D7l+1;|6q=E_2~^W@R16j-J7pV+%OX zUHR%t2OHfS-JwU10?~68>w^;AT#%J9@*?Kg3U}%Se9xmL4eXwsN0Yn#k_+6t=)y%Q zCgz&hv1<--Vqi#Ppp!mJzG#@_qhP#3L?Chj+OGfzO9-|SbSxCY{wu7F5k(0s z7Yz!s0)`)L&x%3JMNC4(CPu`~hjFB;0A?pAFx6_J8700Ew~o$D?E2AL_C52hH_yKK zzrX9B-MM`At{nfMkNF2}?Fro2ia#`Q>d?7&pI zk35Vsi{~&XGDJy?xR$_BKwy={hQ__*rZ>ZYM}`CLzRgWpmJjyfq2CmyZfmeR+Gw@Yk@=DxxU=C$(3Eic$Adz85V0pCbXnsHimU2Nb zC?oh}0*%QXs88+!YE!%;PcWdd$W@aY)@?`w2r6Go!y+^=mdBEjlt>gNN91~tbP29| z2I<-f4AxGw?PUm+87(@jkYDUaNmu{Rbm}nZLp81&i~!I6XpE{fa?cp7)s4!7A)AL`Zm409*|H4X}$nt;{RXIQ4^%f$$%oVMPctzt)v9V_O{7dBk4soi*)Z2cp7jG z$EHPtIEEx1hc!(UL(eh=QZC%5r)QDWBBWf*ZuC~s>8&BD3+8vvVsdhl!<<8Tpu82* zpgam-e?$c8r+4cW5s1iEILrl4+9;PI%uk4A&&7v)TE0S+S6zk9r6j3?M3B}YaxO5f z#Aw807;j8rdSVBfQ8Sxv&EKeCId3`QJQz^>}@22>wAm<{z}R zCun1w@b=`1gI~DPIrT5sdgsR%)=tQwQ}^Lx4}2K+b60+P;T-yDAD&8qn=zCWAoUyW zT=TM^5$}g%@eST_cQ!icb^7RW*RA*7olY<8um^(yZE1=;&qqp_b?DYee?YF;Y+`(T z98*(Mn3;I01yC4L_t($=B6<{Gl?XLpm|gPns5>Z@(@EB1<0>_J- zyJF)oR&!`XLtL|?ftSAEM!e}&&%-tIb-4Zt`YY!V(a>1$U|^n{CjDpF_TW3hC-e3|5XI zT|3FP7h#Gv4Svpc3mOMcK3X_qk$XT5I~`>OgsBMkcn%=~e1xgnAn-5($R<4dvmF*- z??%u(3_ju&5tbi86GcXT5z*jQn#d@uks<^_1^Bd$tUjXbF6D;Q-5K=zOiKyZa~NmzC} z*bA2f#{-Sk4+Qdjw) z!8DKeYmJ0t3JMZmvM;3pAnpP{KQ25Xbp}BNZpz)f)a8Yf!Ti5yL*|1?h8(h%W;|avl^oeN=$CBv>cTQdV<2UTP_3K`8 z>kI$=+rRR=PF*$R_^duUMLw$pJP{iI*pa)ReR}!iySu}+`IXKB4xe}s4nQ101uP;k#=_bd)><^1b&O9mxEdd)v7W%( z{B*#MowL}nV-D)>uxGY`H@@nHc-aq4$PNkg7Ne`3AhacxPcG1nt^@H8VTJq-d7QxQi* zsFSp5S>0`GT{?fZ1Kg==xZMIhN`T#tA33a z8RZcXz#8Zr0Nt(mzvxk7oPb6erqM!_-BMdL;>#IO-#?1ufAdd7|LduFha2-bPJ zG+_O@s7$%wT3KAh^1>R~6_Ss!3!aUEw)E%0jW zJdPeYg~LZq;^>L9IC|J?`MVO7c`LS2L;_HUE z`h3<{39p8doRnKrQ6m!Yu60Y4%TU$&aIrnYG;vxMngma6z+yq@i=%;gCnY0 zm>BRhQj`fK^@xVMiPl&HEeeQYY-}8gR z5uX$(4KMT2Km>uO;FlEc!n4lW7~9%`*5rQnYX?fSxPYcBJzmXs;s(?NW3IMyn%vW7^bNoszE&!J^AUpR@V=ZYjtVZBDFGxDJK#qxMnunEbfRLZK0D%1; zfw>_fAx*egQh6;XJ0;~Z+C4{uz84XXy8FD-?ki7?MVUEt^e5LBSFpUgj$vBzG$}z7 zHPn2BB&s8_H8@1vX=hkup5v#_@ha{Z?mu)G4;?>=qvy`!B(KfRtgK!G(pEfp*+_y&^k37QI{o2#6?BZ)$8>(*s7X>j%V$H4*fkAsYV)Ko||OcP%?QT^qpwMGI!18b1U?jcS|5WC2r+9G~~R8}Ztg zJQF*|3_6R)k#$$Od+q=v+)yd1Y(|zwlsk@$3JS>>YGZS#P3(eg%%PNKFy@|pBR5cT zM{OZ6RDPCzLn9qw0Lj*owa;-kew6MhxZYWqYz?vNqK*^^&9&k2q8`tS0FFB$M;a`Z zS44#ZKFUKnTd!vuTY+sJ&q28q+mavE%i)8FY~j+(YLqvdLic^6+MSvP{=Nx(D0yHc% zM%3wK95d4oLg~{wSc;JbUd+hlm(?;$_%fu87QvOxxu@eEKptxy0Z!Nt!vzNCDQd7h zJ~m|nQ`BMd8bp!S;f4|1AVS%HFUNm;h27$xq8Mz_?5n)z?h&-_nXuXbgr;$gu z=Fz-i7bb2#fZw;Q63HqT?9}n^1 z!Y=0(nCCcXIYkNbm0_K?hW%8dZ;gQ!fyf15+oR=#Wt6w#tTL7PBSa=a%!SBAa}txS zDNMJfF*`ns`KkGA_w?@5J7@O%*3Oyz@4jZ|^{;=?v!4Ib_rCkTz2!f?>%Bkq9dG-d z4_-Co+ZgJbRp4=+)&W==O%J_G9QOL? zQmL$SWxu?*hSjAm+G|7fH&VKNWW3MIb3vPT^L-k5kcuMfQt_@~exkq|Uw#X|{0+~= zZu$pHM}Xc2xDtTp0>s0?T0@!y+<+i;5nMC{Q{RE{oi`#H+lkCJky3fMD1x`(BLMsC zivbcpM8gb`xD6DYljtozi2l-pDAtd{^v)p~E~A!rP;)6F?)-)AiLiWRP-;yk0ttZU z4&3t&-g?J;9-z@T%nPC42t45gP_S5dg`=iN~ENC(h{lT`PlOo+1V7S&pwlRe7l@J;@ z>zPv`M1*0#*=4BGbij1xEwQBMU%CgV+dS zV$vQ36zpI@ewKmy9FUyp1=*d$=&GU$8`dcvhK9%R99lBaaC43iVvb<~bYhfi2?mQm z_pC$bv>&-=N_5W^=r5EQ&>fu0kP`ic4E^PTJOCFmmIgmYLF1q230zh~Eo-8YH&Npv z!4?KQM1u0ci=ZSeju0ALZykgS521`G3|-GTr2Was7iR?yhEs(3=nEnC!*h%j4(vx{ zQR6*$WG(X{EN)VseDxcG=*C^Z_4CMgwy+U9^a@AbS|quJ?-uB+wz0mxfs|w7$YD-T zeiPWJfpqAxzPyIBC(q&7p_4d%Vgai>YR}3DPPo|d1`*{2EYu)PP0qK3l|TY(D@Pi zZU9b6emo5OZJ5_kCnctdgfbkXfmTc-UYqtUJ6_JWcJz0R?L5D0Y|qE%8oPdf|J1eL z{*3Ex|MC|<>jnSv^KN+ckNo?0{2&eZJJ)gbc|vf2!~cY!^Rr6iLvK9$fktEM9a+!( zN!mwRkEbxvoPnjVI4VHrsr_htN|$qou5c*5P_QCc-Xo4+Arvsn6}hDnGR82S?5CiD z3c%BdmW9wTm+1FW+S?&Es2r9S*RizN#>#RVYb$-m_0eh5xKr3Woj%$;zVmELj{@5? zAZj2KgI=2obPapvYk2L;pNUs}{xdMwDyU%3B5$w2_j*(k4mC?UJh+2!@Sw7_4R||& zsL4C?nQKwfUveknxS}s1>|X)LQv$9?%dFd!_$VdGj!;4%TS2qupa6$wvJ>U0}$+-(n5GOV3$W9j4umX5Ds;phg= z9a+V~;Z>YJyn*va)+{rE$ z&JM76Ziv|eSwV~;7jDbz-LM-!b!HLE>pkSWqq1=; z7<-YJ<0=7$b4cNlgI->#)i6HZz}R>Vt-4@}S8XM)y>y(XXJ&Y;pX0=-ljslo0Q;s8 z4=BJ#`{1=ABM@uIiH}1XRUp?)>&98s@4bOSa%bxz~FZoaJ`l0u}<1OFu!LNGLH?7@q^$)?|iT0Q$ z8o{3pdhe7w-CJM(j=z~4pZ!yz7qZ5t1aL@JkS` za715H8Y%2P2OM|NMe2E!S7O+681`eN+y&)SXjv)@m*{i`*x2Y%*$ucu$k1CasbCBS zy$HjhL8^bOf<~d>(Yvz(>O3l#l8Ot6Z9+rdN13i-*KCZ}z4Ezu-OHbcYxhjim@iX# zu7O$~CeQ@BLPbHvS;R>5i1wxd!{haMd>3?Q?$x04$kkDifrcc4N36<~yKO0{aE2?$ zJLk}+G4HOP#<07@O<)g}hCYgQv>ZPU`lSa!8HxI^6{jJQQKT9+i;-XBD^v!p3SY_9 zl8Q^yNjfTUG)8{!Nl!%?OBg_8R9cN@I0|MVbuCQzCsdLZ3>j~j?s?Cu#$11DJ?jmS z^Nu`EGa5|?DG_)K^6avpwbEU&a4dyN7=%~Dqab~BptN{xhlHzN<5c;pTlyFUjbCYM z@HPS&&ptTL%aVqX2E?aah~%t;VIB|1zJa#@=r0;kyC(8slZ(10vh^Ce z=Yh@{ht8=2%f~XDKRU$8V|^Ss*2AG=8#sNuhlP^^tkJl4S3DO&5qI+mdDnz-V-Qal z4OrBhzo>TYYg@22{dOXF*e&mV=6+@EFrd!$U00-jA7^8EOyMzL5J}C z!fBj4cM1}FI8&hHLZBf1Jjj3|7lWJzy(ByYBpvz~6G0(jA%qo!*ha2VYxc&P<7cKO zChxxWX*YlG?KeIBtDkqR*r=^gIo9fgyKz!y)qxGWs*-&)`ZLTkhaN#VtsnfU&TV z0cgx=UyyN2QPLnp43}AtN1Th)Qx-3M;mvprcjh}M4HfYzY%HFKDw?K=5sQJLk#ST$ zmUVj?g`7$g36=ch9>mR^RBla_rp~@a+$9yTK0}QPCNU{^IgX;U6z<8>^^;V>=V{1! zPmUgyNWtShBI4jk2?)7EcmX^BN7pe-FG3oC%3xP6qr!V>I7Dvyu&QmLNB3&m3eC0U zdJjjc9KUKLAJHDunFPZELS|b>zER0bLH={M?T0185qmE~#_=yXcHrVIf<LdTvIO#Du)UTSE%<-GhcENI<>{Bwl`U#~!#Ivqa1PVvx4#T$CnC`|c z3mNZ`P_fq#qajT$%V-pf2Fh-N!K$FQ;JI_&z?p~EaO#0&oOo~%XSkzYIoad2Nr80T zLsHJUyoShW`6ilX9OKCZCTf$IXz-*enm{WW3-L8GhS*NP$~dHK0z6hXX%iSU;d%)d z00!x*M}N7*`f7pIl>%#vSuo}+rw3R((ZkY-Hq_r<%+XtQ@L2dORq=AS>*^xBa zp9UIgV5P?1B{2Mgi%w78iSiO)oWPekji!N(4KftyTMswUfb5xr*}DU4kwc$pB`s;r zMecgLi$xmi74E*f!-7L#fii|T@_`GY!63t#b1OJ`>MRylH{iHxwe=R_T7z@MK!!OI z&WkZgFqKTA6;Gl@el_c}7$4t>@v#|<@t%CV*2F}80yR4Zuz%yN9Y{DfQIcHh2tJ^g7< z`-B$%fB3fl=i7h(>%aJ4KJc2?+_51doLyS`RXiCuV4e(}_=oJnTVDH? z-pMx+gY1hfXc5&MLJ}^Qx7k;+A8rHrkgi4rAgj5Yz2oaC^p^{?@vWd->;Hmr+V0YEM5<)t8sO@Pp>Qc!u z+R!lB;6|{pE+T)~0V-=pMPKwYMGXMmK*)tC?2*UUjUbb10})cCVsv>~8n2=LU@ zt}Iaj91C4of$^Sx1z1MmbzDdRwh*caRVnW@xg^sHp+e_;$vN*F>tWYnyhY?AH1*z; z#>n?9QX0sOvqPLcx{l*C;79H|7mWDA@eX>+4(K)z<>TDBPqN%JCTg>2So)(eG&vvi z_mCU4F(llj#x_ABF(L?J5@STzrU6K+XWJ+Y$KQZcn>%;F@nlKTi1o*adAuKSwnQ|} zwrs!;Yvf}@8cMX!W>`JZ#q!ZkIKn^8WBZfGNbl(l8qzKnc|y?TiIwZJu0fp(1wom` zuw@Min!zYWrbQ!OLu3-t$M$RuC27(>4wH^GVXoN&vu`&BlP#=~^ZhC(h%@(Gyta&fLZ|*f@e^9mp~o^8s3HXFh3RT3U#Q zF_w7>agw}#_>*UG=$`X9djB#`KCp^qUQ_hWIi8rcaQZ}!hnVLRA3ctHKXD3cOBrWe z3n*e3op;_K;#`V2M2eX12o5m~c*%vEgXcf;f^$oCOrzc$?%lKZ$DjAy=YGShp8Kkw zdC$Ln&->r?ws$Ryh{x6ENy`D9ici|k{e5*zkKpfk#oNyxdu{sjyLa67wR4l#|M+-g z&(dUTub-XTjhV?gG`VV5+3_^Kx>NTwzK#ajQ~6S;ASQx|EQ~SGI90@jE2#uQt^ufZ+da~qcQvEVq|Lz+)1yY zK{1|)nqcMdMag!_9U24J2pN@-qY|o5@8Hqk4ir%hL$=SRnLVS5&JYU?b-sfB#wlzp zA4Y%e7!BGv8u(R2Wt*yRh(uCENU4l+z!M@W2_`^fhAsu7|SUR5pLlduqC5&AC| z*0>6rS+^YRf?_M;avl*}Xl2w+mCYN0YGfS;5rK%Xzb3fi4f|qY$qVEjZs1V#a~hu% z!_E)`y(i}>OG)J~Oi##1HFB1&;iApw1q>S&=OOHyf(%6nYaF2lUgN?zl3n1VeFdo) z0ocwn7KWyKubE3g3V8C|b1|oF#U@6Li?ap|O3ja`cGu)Lg zV(n}XJ?@Y-Ps>hVqCSJku~|$t=P*{ELbE=JMlwc&Q4ffs1bTcN8_T(4ppVW;uZ;g2N9j;=%jRZVHsnRbF-$18Eo(8?9LJ8aNsReAItyJKd*CEa99qDk2hZaE zd(H%7{qc_+!$&@N7$5n-5q$i^C-BLSox%eTEZ`#_K8lZh>^N3dGLCNpB^N0033(Y4 zmJsNp?AAFT-f1vE7-`rSa3<)OsDE~5`V-e*fBjE<``f<#Hn+}uZH~#pa0JeK5zF;-!tF3{>@Fh>lbTwW~rIXm6K!h zn3>#(29MV2R2r%bRpx}s&r1fUKn8Cz6uy8O=&Ud(xN|Qk%q5|2UK$EF2oL??oRWs3 zV4l)(XTzPBOAG4s0VbLyo^j(mUjDgH!{icP8?aGN zGX+0{p)rqGY0SD!Zh3-Y&|N5b|DEIP!y7pJP#X)}5w4#}d8*=J^LjWo*Z&oZ_o|o} z8{<5!aYxH>=Pth_iXuZ^W+a7#8;+@U98V8!6@zOc52-m6Hs=B`M`<&75S+`Ri#Z`v zIM=0SfI3lQ{uqd)M2+c|bX(FLJBO%X8W*S~7pRUr^f~A1zyiGp(Vzk8H@GORqu-9P zw(PNVzQp3m6lYHiaE8b9XHRsnbaa5_<9#fh%CLN{z$*LSUh?R#0PU3$U7iZaj@_`= zU59*r7K1nm3t3OO7_^6IFSXHM@1fu+i=|vy%9tYJE_r>mi=)R*_a{;hK9@bFG7`&MzcP20_#?cwJu-cAsj#m$d zk8a@Hd7etF3;JBlcxh1|)RB@8!gic7aGaOUaK2hWNuHL1AiE{Vf8{slu?QnMXAJYl zvGoSn7-qwr@Ab4eB`h1 z{q5iT@ZsNlp#AaYA@S(|01yC4L_t(veQ4vJU%G$gqrduHKl!eo{mvhM*N?yB2fp#A zzWs;Z@%`WS{cnH!xBt+${?qUMiSKyLdw%A-pZCMR@}3+2>s>#*_h;|=;hA6fgz+p zjrt6dxCPzicnW4I1yylM0Usds1LQ76TBH*w+6mplV6dEWJys10~WdImO25#_0 z5xVlFBwy84DE$hsO}35^goJA;1Cp}|2mM(C?;XRQ<40ndTU<%MhmgN9qmt4fDJ>!% zJ(nFp`&^2%+|?fW;@iu_!Q1@ zfgrVU408kKBySQxVpvm0oQ%W76YyZ)YLhHiqbxPhY)p`E2Fi=Da07X5z(>bl;WdW! zd6oqsOw*TPog+*!%g4}bVWzj^4`k^lbi(Zm1o z$eAPm`p}7^|Lnd)_r2-p=_6nC(9s9peE*Sqzv|%e2j6_++_A4ewRrrSme$X|?byPR zf3w&*|AUJgr+@DB%86e)v2g6SkDWgB+Yg_3;C&CCKJ+v9Eq&nozwf8M{TqJlSN_vW ze*ORa{QkSH?(lUmKC9sHe^%Rn(ljn2{zW&vq5WmA_!qzP^jn_w^>fV|zSg93zt@a* zZzMABn(;iwTXRUb>Mp6|YAp+AQaH&`P)(Ge>oY2g0tM44zBKTT3K7tsl!q6nG0^)l z6f~q#BI5ROrdHy`&$%97^aany3vb(tiTV)f`Z?qq8&o(J31TXV82>+ee*$&ca^3e~ z|6O&?9lxpPH_rp+hlv0ABu3fvT_O4y$e*J`!a)q+MU`@dy4pgI5x~CtNt({XU*VTkV?UNRms)>4GCQWZ> zv42Va@2+dM1HCEzaui6e+A6i)To&VCDH&t3pavvC3k8;mXQ;`IR}Lsk>R~#BF><)# zcX-_4@fKz>Gr7lMwv3zI4#i`JOpsMpL;qyl^+`FmG(0;XKZhm3gTaAeykd(0e+e&< z>k%P*CA_dXUijo-jG{l{YJH;CE63P)d4PsBQb?wZp;*%NajAV!sPegTG$`7nEV^%z zh;U{@ZpaKe6f1Ke@cblGTNl0)V`@Occq3Lt44*4&X*s4q-9Woe)6+1ot6wvJczHKo z+tc|Mujs~w1J;14s0P}?kUxMSKecsA!~Q`19v9pg)AlfyYIt7Uf>|X;U{FT?9xjm; zn3xruQ^BqCT_cAEGKqslu8r(~kqWJR%GhaT%&?U)`ZeR2a#$GkOnXJ6gNpUasdbT= z!<3+yTiGeGFwY8c82$C%}}=JYkm}%C*w7Oba*XkAFucN zr6XOscBGrLr0T>;bsv9Ji_L*{>Zxj%YfV0{O%AV*lonaN&Oi=Yx^#J4 zFQ0!?J5$#C+PZ2cVOq@@pV$k9POqQQiQ(Dy2Y!G5S6|hyzI2)XqN)zwxOJ4vLJGN+ zZ{+YD*yjauEluhbQQeT(PjiJd=Dc&Hz|BRsXw)qe`*tDYupdH&?2}LgvYZw9&yWuy zQ}8tN5`)B1kh#n38EH13u3z20`oiUFSH5F=cl$jz_O>6rvVD2u#sHW=XTSdS&eh!; zc4g<9c1C-;v3FB94|a6(kPuwixk}m9uzh$V9L)BM-N|lmXS}n%ceHbQcXadd>-*O} zaAoK6_g=bj@%LTXz5LIgzx?{Y`}wba@xT4--}zhr^Dln&fBfs8{&)WL&;5sg?ce{< zFMRG7HrWTJ`_J#F1Xu>Yqy7B@-A}N4bF^!Js-E_LaW;vw z={RW3-%$=v$^TIbVPxIgHa4h#&=vA9vEl1nvoV7fuKn4nOZ8*zpqM5=&M zazkE^7rY;zwy2I~YB25@2<2|bzYQ@2mKX$Sqg|#`@8fouwM^d*#p8Jdw?0r%f}dqZ zXvO96Y>E6tG%KL={Sa5pYmupLEY%4eb*!tt4b@$2XuchE<<&jC@s(>B@;we<4IcYI zgK|SB)=z1pkHPPBSqr8ZvI6vF6;fD`t~h!%DspZSk(b8y~Rp@aXab%Zop@8=Q zm56L3xdE7&g6Acvgr-$hYy8Z}@06PR@wFS&dHS5}@iQ82l-dc6#`Kp$XvgT|^V_W1 zf;A5n^>bg<>lbcnQg>v7ld}E^sXLVKIb&Epv}ldY7r(MkrI=;4`5bU!WE>&M zkcC)~hVjoBfAc&OJtp9xsmQ1aO2@Xs}$Q@)_?EzH_H&mWDcH%3n22#m)gO{Pb@$s5~bHVzp1a_8vg+RnlD z!x;L1{N*=Z`tyJP^MCKZ{F|TsZ~yB5`Tz2-{N7*qh0km{kN5FABLSA$?@UMku=QeQ z>BoQcC$IdOpZW7Y-ziT0aIZM^BO$H*waI}UEXLj34W}^f4{3dPPHV#x>h=aIP|Rxcx1YxQ zvU;kJ8d2QV3(zqDaWxjzm??owsg99D)G|(R_op=~>F(Ut5PnVO*U88z`=zQ|R&t~1 zkW2yrULtjZqQ?}GN7V8^B*VCc;Cwn(awFt!tAtb#i@EYyF;jT~myWgb~lIfUFtuIc8$AYw@~J&yU~FkCmWi~+CVlKmdpr@Fk)EBx=}Y}bb0xg&&TyzMudCK$p)+Mi=QvE9z?;AHl{fVA7hc!pi#uvkkNva-AAR{P*p!3c zf`ftoNtqwYZmkWhc!Mme7&J4<`>Qy(IXpknzr&~(K!oD}ifqh5R>7gm|HV00zUlk$$}IjR4$ShY1Z8z-+b=!_M89A`Ae_m=!Y8t|26wy>tPMH9#QUWX~sLg8pUI1f)1WR zY{soeKHI;n+5Q!5z(|G?ER=*3A@fn8TeBleW>KQQ%+_~^(Bb)ka`D)4_{}ahHojJ% z`8WKSSz8Bv*M?>$GgCkrwjl02c>jjcY~vUqhKt;5Ex2CyU#%|=C&U-Z^F|TFQo8X^ zff1P^-$Br2EK3?hP-uq1a$a3o-wn@yCpZt{@$!Tk_AsMx+YoJO2-x5?GdCsLvcM!- z9TlsdI#pj>(rD+>SQox}1F!r*^8;Zx*L8AuTBkSh%7<(1)q1c7>NRJL83t!Ye4&7W zL^O(pfuL4KQ3?(UVx%7n^89FoK5k?f2p!HlBv=<17YXi!d>SOg3ZOs3_=aP2>7$@m zPd6A4Hvkm`4k%xo*^r)jNV7+_wBM5sF*iBVUofa9bvm@9UK1KQysaUdh-}=bVL#}X zx)`BEi^dm#FFLTGu*R4VpFX3Fcd^#b^mG*F%GM|?<~rQl*Ys#2*d$J_#gb0|LkA?y?^8Le`5%x`=4)-1j4t7?tQX%Nx;nVkN@D0U-`HHcYpcc zed>Yt{ehi}^S^fK<Lm}k@jzt+S`uW-!_o7ePyPb*Cu-N{0)t^4>j7^ zQ{vj|cYWyn`U9W(q@Fmtrs{B8X);pax-2hhWfV?ilZF`8p!^z3cpga=#$CO2^)}9d zhh=!?*#<(5Ux#J^2z7`Fr!cNGR_JOyasWqCG>Sc6>o{YHi$dCdFtl>f+EO zcXdFU?|Ja*-ZcWF5_P>ka2^Cr176mSO$oXnYe`#LhKvWbDpe*e3U$&@Mcw0|CGA{1 z;+lP1hd0JbbJJR9L#H-RYjbl`ydcv&K3O6)yx_UKsp9-1QlYVz&qfGK@G8{-$pgJ0s-MAZk&n`)l{`E zTzo^)kE{3eNlp7TE1Vk%#_0#R9j@9J(}sQm*U?U(RJS)Ioptrb zwr*_iC^26y3(HbDnQLt{*CUglr{>b5N3|Z@J<|JoJ$={dQ~I8>XY|5aPw(m`J;N9t zrnimpzSd_G4dzpIt2sAc6$izdxvrH1_7V-{=HA#H|&V?Tkr|r0-EZ3WUpb&*YM6mxq+?fh^?ny5hy`JEeyQJ~X11$8@Vy?nw&|IkXY-o|#s*cQIK^SdWlE(vM&XgJXjzC?*u;E{{C1@(b+aGMz?Zj_q zELcm;>^RJl^Je54MmBK`++N4>YGOJ%LUuFND0T#*m2JO0 ztKfjL^TxK`eB+WX9qg#!+Wq7Qo>qEzLp!~NZl;kAXNRild20X*WZue&_0?ddvM(Mt zMA`d;5F8hb)n~~j^1Z$`sn%zegDG`>7RJ+8U5&cpVW^$ z|A>C%*@yM1r_Sno&kpsGVbJ@^r1x?-dY4JhK9Eu9{ z`lWiqQXO7#6a!N^G{s>8&0yRd{P5Mi^Z(cL7yswK@Ynw4zxVOae)hA4?mxe65(vL- zlWBa7f9c&%z5CO>Vtr?NnDXQ>>u6Ux+-)#4SvB6`+)wyj z>9L2m^t(U#Vg3Fe{iL3GY)hk^H&l&xWKFGVTCuUR3a-!!Jdmc{aC50>RHJY+9zg7@ ztJ^=VPUn;q`gm`~9;AG}uf_B_Uf(56_Af&7szP(1B8(NoMA>FacvyZwa9YW-a$u+t z#Q<%iuRsluxu@`v!t`!SSQBma2{4@ z$Cz?k$9(*Ef_z(nDkAPM*fY{j!=NcT@wg~Al(!*X=fnAl)={2i?OfZ}o3CBh_QgXr z<3hu7T_;bjX=AHIfl8sQmBUhSWgyCgsNHConW6=mnLM3~pbA4)#i*t%S%*=9ej~4y`bSay z1JmK_H+1p!*L3CJy5^?}_22if?3pu~p6;sZGYr;O4|xoD?ciO<6y(?H6>>|=m!q+W z$btg%Y_+s#7Fxtw1qa>-KJbJ(53Fg-`duJnGtuN= zL6Ra1BCy7N`-Uv}tws}S==s`dlp=E-MMWxyMm4t`zVB=9`5cL~{J_Zi2%y7l&VXBr zjkSTcHrKVWv8K(2V9liK7 zpa1;dIZKcCKfetU2)_+9?vwaUO5mq|`k%S^(?9Y1|LR9R_`;t!yLIM&9K_*K(Zt4P zjWU~AvjeSnXL|C{zJB7zzE?l>hd!mJAL?nr`|U74lDtk`t6N45dtI4j`L9}Qt|X0( zW^C}9^0Mq`*zc=gqbpVFM+P8m_NVVv9<` z(+<#dm>Gquk1fYZA1!+bC>)Q?QWjJ2R){>BdLLLG$`X*G8||z%ZdyLx*5zZ2E2co5 zLJ@B;OiLYJo$2z++uFN)s2>0Md-CCA>Xf7H)tYib1(x4l&4WXv8fkL#LhV;7ps6hrv{IpoQTpS%ZF<>=w(k z$IG%*D-(uSFOV$ca*OlX?E>UFTHYJua9_Njc2>|vFB8L@Nq4k+|uDz z)VN!7FlZE%6gc!0jLl_3QZA=}p$@F^z*sZuer~m9gQW72HJ$swW2%N(qh_i^xiQ~| zT$4{mGsRdaS6tNB%>DIi+q!c3niky5^gDg^ENY!Y@(C+-j`?^jcL>>Qte}#E|Y@OcH>9ePG4!$3H=&a72JEK!>z&HBpGv*%avOg$QaH#9`3$ZK| zS);T0p$^8oI-2c;@$BZKGu!)Dzx?Ls{>p#ujyYN{9#DnM$uUbKK0<2|6+4TYvwLm0|YYS4m-zH2srLz|3pU%@aM!c)kK zlrCY^4=5Z*Y(wMQ5m7dQdO?Xgj%=Y#D^O;RAPWVEiqMVZ1w{oj`4d7w@z75LDS*SF zJgv1joNIb>r1|bx)!tm`5N;<~VIEZQPP)Y7ifBF@C54nl4+=_(C}QP?LDoumNA9I9 zg#vxvt221Yh{JDAM$(jtx7>Gz6?+%-706s zV`dTuH3KO&67OOWoxB}nWM(ZQGAsOT?D59PweVQAc&Umg! z-$|y?tkx0l(ho-q9b()M!2ZER3*NOClS=Vmqybmx!|9Rw)ks6IwK&oPyh?bAalB83 zK8mcrvncd^L%jAATlzh`i~r#V&+13cooVsIr%&qpH#YPU>O6%pKg&3`Xg|mm`?XPs zQ5jyUCaGra`SQ#mswqr0`?4vdOep#b%t6U|Epot^M;V1VMT&xit}t{)$fcr{3ha5$ z6ZMl5B`lfC;cyKPd<$%9$ZMYdu%iMQa@nJzD|I?ioo-MWGc>ZgWl1RcK~L~l!dqZ7 z%_bx59A4J;^raI=;uS;3Z&5*%eMBLgmd000mGNkl1_;P_)^GKo&=rq7xL^%Z+O4e%f=9L;0Z9Q2atGOB! zYnnn~A-SQCbx?s34-IYb*f!AGlDso?oan#lGyV|+P-0f7{tn{_nU01)?Q>P5- z6jIJf!c(JZ`*9i`F*6TjW*`HXp-YqMK3Jb;Dn}H5j3!410p?&7pB^&FwCsi)MG5M#*ocag<7*% zX)%xn51r83INb3ezLTBDENV!<9x>XwBfw?2Hlz^F~Kl5jPcIQ(+^xnVzsekNafA2?s-nLSXS8oXf`Z9uI3g`Q zbhz^M(Bb(jtkQwHzBwJ9w?dnyOz`8#D8ePO+Q%z^#A#i^D1r7$m(bG*#gid+E2L3E z1)ziEP++vc`wtlCj2Dp7w4Y$zPoedZmVI9XufDa(YRrX{kAt)w~FW*&<4Yfpr8z6+!NF zG10Y61jq*r4ow;`K%6G$5em$?Pu1-DrfVLhuqb5jhyyh6AgYj6qM;IX1 z!orLrB{#*3}bnpm%rH^+NB2KGHj-7m78#PklX?N}a{{Z*V{fT=S=TrkcZhlb5_0 z`=U@}zZka>L9;^Q?Tuy__a-!gBQ?Cc&)n{b+YJvB@9`7w?NePTA480z;Z-T!>k61Xq0Z(3Bpa}}R{^s%Qt@zKXW_3;ls z9@ctES{$lIA&Jdjgs75t;-QF2ZZ^!?(=K{L0bAn}YHVmnM3Lk)m9b$BqZ2fj*<6J! z@a{m&GN|~-+!%T2xHInQyJ1G3!1E!Y0xBLF8O}$G<8re2Fb2MFeZ0adK^$EP7kQQQ zmN0}MGp8)-NX}bA6*&y0hXzxgH%eTw*W(2q`9jrXPP&r+d0NC^xLl%)|D=#I@&YK_ zj1hP5!{KySxI4Yd_i)8r8*gbfvIOAHJbkNz!7O0D}N)dRE*gpSmL`fb}z0qr34 zHU$Ct3?IQ~V4MPswwXg(&B4wOX)&7mA?kGK>1-8sb{4cbHRVat?%|G&!#>dLp@HTP4m3H}RdbH_yHB6f+GD4b2Zg3h zr3wQ+$G}c_ckkXg%qdu@#DOgF%B88eI^UaWgb}KpdA~P~g-p^q1AG8ueui6}tx6g+ zVtx|yohjvGCJR9!6e_GxFw)F(WzG@aq*>ET*>I&}5!qObS#*7Qv5aE0G!&2Q}-806A| zicl)vfxvnY>wz zzsjL%n~ugmSQq2}=+PI-ANuV5U%~4)@`T?=<@nF$;#6-gwQAj>e~5fp5@hypE*P04Az1O=W&OcRA>yaXP)pFQqv8QUBwSD`S5UX|2Dm6C0cA^@1{ovaDJm8dTJ_qbIQ{Nbnt{Q1FHF zH4lkWNw7m3%1DPm$d>_Oh%?GQ zu$I2lvl!ZV4#=ZmjD)ca>OiyX1jQmt)_{aP$=bw?exNFKZelvg;ibzvX-%X9-c?`R z-PcRIH}uNnKRL9>F*HKzzjrPXc**lbb)B(fgV}RwBw-Z+AGzX2- z@UuS4>Mx{{1Awoq=m$kSOwMAW3+6D1*V+>KZm_B>IsCnLdq89zw)=^hPjpaqC`TSR z>1xcRY#bJ%|HMHB-h2t+l7mQ>b|tq=U9PixT(PgS=1+4Q^$2U{DfoLnmU=#R^b`k= z2Ut`apxYiSlR^kGCO%THWG(gxyEp@#ZlUNuSnHSS4SO06`&wTgYQuk}?$}s6POoWg z&GXlEVsqGHll0o4r$Miy0Y<*x?XiSP(UaQo(YJ>cJkMq`ZUe{iYkB{dE-U!=+Y2$K zmM?hcT%%w;6=hfRdak|kb&cjXid2qY=x(IHFzLMXUF;Fl{pXvKK=`I=-LLdJB7x6+ z?&nXJ`NF?e*qcAr?~ZzLaiHnWwr2Yy87JF9f&a3EqH={P6a$N0D-faVxLhbrG)zEw z2CEd(TuL*-O2+9r;ukoCcll0(bGl!z%*%}p3>$A-&C9(2jzp(=>yuoEq24?fE#$!+ zaGDOG9hY9U6y1ZOzl?`dVYug<`YTTV^Fs`}8*@DLl`$9EG&H6qG9g@`NJM)h8f5ar zk@I*r#N&Ach%|p)m{~it08K+FN76#sYB^K1HMv4>a$-grCFZUM+0(#KxLv%hZaHLn zqV}$jbmQV4bKYoR8yfaDv_9O>px>2Q#(AsMaO09W2snQ$m>K0J1ycqVO=_7kS!CUi z7g{iKGkHBTqj>>5FG?ZOHYyP+7IGtB$0$cbnDDe6f}T;`=p$gvMOq<9!QHxPSC%3V@Lb@`<(YOLkO+NBJr_1#Dh1{z}Ahr=O;e67WRJcs9bIN$)_VZZNfy6W`{06oItT6cL6 z;6SjswXThg_4dbe>+9>x`#{}pS4C0C*M^zNw`c4w}1UVe@6G8Z%P6#&2OsW{Yu|j37r4zf7E;M)a+-Q=H=hlEbvG6&TDqCtrDfs z;mUOo*+6W<$ons;mK%vVS9YiZweNA+w7wZu$ZYt`=4g#Sn>hnl59VoehZ+T?*z3HMkvw$5*fZTxmuBJ#xutfGT zx{@JW)(vt#9J=fAJO>l;mXhqs9U99_6c+O$lyW9);mnZsJa1oEp$>{T&r0oFInwnv z_BGpY)D3;D_11K1bBnOcTqQ`ZK=k-yW|Wx}Ox7Av`iy`yHb>}h1>(_yLbwZ5C@@3? z>#M*xb@1Y%E7n*_DBh-sN~}3Y4jl=^7*&AMqDlp-6^hgi)D5@jn?i8VAKrNgQrR&K z#84+rZfXN>aL@_Dt~1A#I$Sev&XZ2hqfSkNHjZdJPHK3!USs5^hjY#LW}0H$cMrz8 zc=M)S*}1N-9&YPGI@B9sq=PM!9@2e(%CNqgcORg3ip({-&9CLSnw3akp&zh}8)xD^yko<4J zeN99$e&?OUjKx3Z5Hl$n&0?b(2wqA*aNxn8U|*c0i$~_t6TG^3vgzp2#^lC+NWV56 zD{&BNs+kfzB*w()MUgr&1!ZKPSew3=+gCrwdpd@oB1DyuF@jaRNAM<2hubM1fii(mcg>omLn`34dQ-=O?{@$Y~He(9I~(~Z5}n}2+B zeey3`ef>ly9H7khRN!Iz37@^JjDqpa*i>_DNRq{%h+=j2vZdBIy1`f)^rQyw$2Vz4 zG34xqGogM>W&ma9Wr@w~?Hy+3K?}3n@iv36B@MOxkX(j%yA8_4;dv`$B3?hyufyYa zg5tM+S|cb08X6^m4IWX9jxtGBWhFfD1qyx6JGmMKpT=|LSyHGZ_rhH{l_=G+RjvU; zY%RTvf^ztHod;K1E7Ub71;*g{4&C*5-Zz3b30ZA_8NT60?F0Wp&Sr+pvn(_%Wc2Cz zG%_>gIx@ae(}PAgFCFOc#zbZ6Xrn*WTCb}$-USEh=2XiF%d)Go>?_1bSVG$*)`G*= zgrSH7r!|XG@c1zz^&^TohEf5gY#5|WUIRTx?hce3cx2U}uf<@f1w2%PHP!vT8q(J5 zD^jN@dw6+Wm0Z&+2B{EyFh=5LM%)xa0Hzp&#QG$uAqstKt*@=Mfd<`D{W7Qv+#`(~ z%z7NyHh71=HqI(BQUL>1@p>fJ+!kQ$XNR@+c8|2Zd#E?BZtIl`7xk6P;Oa%)s79Li zv!-EzF|QRcx?)}BIt#lYV{Qr`H&85;n4dXibN0XjPdE8G!AJ%?@eb`u_FI>mpdNKP zlt+vnLzBzbjIm~6z@?JV(UvPIH?^FWuQ9*w$AQ+olXWx<8gG@Fob7A=AToVqQ{j;l zYSv0+=Gad+p)iH07Ha1(s6n<3`8DY}(wU^lp6(LmqC#u9_QP4Djd7(j+&DbR;p|!K zVbsL|X|DcwtT-F1Sxi;cfUEEZ_yIWaYN{sgzf7NVrSIW_G5d9S!?-gt3ovAg%xP2z zg^Ji|=e^5SyLIOKO!> zMkXtIZAgKcOSO`PBx}PHRe~(?`rqjL`CYWUkucZQ5)+eb6vF% z#$4ICDP&ksLynnPMs^;`=!>!8;K<`=%b7!@d$#@vT7_~^nz-xT4#>6bxpx& zt*;}D?F=t^3MP0~^OGku8Lny4#SnFec&Q!O=*S{~PJ~Z0gU1L=@^QjzQ3TPwq(pvz zr7YuSsn7e{6YB$=+FHYNTi1~HusFTS7?fgCX>FRcu?XreB>(fnIq$9)mBi~+U>Jmg z{HSy&O>d5M{au`(U|?@cqc!nT}91>a%fZ+ zNi}>dhEd~_9gQ&Hhfki+;qzy-c+Vqh-usxEr_ZWBx4}V){ty7S=JJ2fAtAdQBwtPy zhP&|tr;Z;E92tl;PB$DTA~I=IvX}ZPsL#IGWV{b^(0#rs^&CVW;J~uBm~m*tnCAs= z9V$)n>=(SUsz8Hm+_27P3(cl;dAMNwE#|eVMHXip^o_w(AwyO%bk$>x4pr}Tc&w^RbZ_G|yqR$N^A=g*#+{rM9c%_CM% zG(H%q##<>-+8s7S`wkpMm@(FV^7jqhaMDfevf6>w^8BQxRRU%vv)lOE0eD%Y&Nn~x z8Rhr2blBfyraQ`931#v!h9wyhK5kUZmI-u~oK~Ci%1J760Pxe8tdWEQ_wm=`TJ?C& z_48N@yz++k<#}2wpnweol$M8_UW9cN!$;{zc_okI5Q^=``(42gIgX^PRbE@^_-mEt z;c-?Q+?{SS+b?UsmtSES_%~i%j6n^zB^d({EGtCG=QUzL$kzO*(e|5%+Q&1G6|Yso zn$~-3+8A^d@HS<0DONHxxIrtUC(c4f2|Yt;Lt8?_HsJjU4e9f&((gr^|*$|{JLg~<*17GvDRYv1g4w8jCT zi;M=$``S^Z&6%{(l6&EuQO&z@C!&%;{0?@`U(_qZk)^3jv$w0mlx ztMGV7z!+l=geK3+71(rv{L1}dbjZ`Jr3aA12K4*%QxN8+Mx!KPnpc(FBX_umKAX)nKAPz8aHReH zL+$MyXy@jhc6Rpgns4gH^_$whv8Nk1Zt6N-wAb0EP7CTEFs=h$iyR&vwO;qNt2?^J zZHk9CuJ7sQ_JMYHM%vq*fFtd5+v10Y8LvNTy!M*+8k>VN8g$O6XQ%eox@Z65`yYD# z-~OQ&erT-w&o`7n_=Yv^SNN7l;6*(1(Cq)z1LvlHa(#ejKHpO{#>1$(oVYg>_)gpv zTBs!yySYLSt~!0n0GnGp38jOSKB@~Avk-J2~rvfMy zS1gW9KW_)J+m9WnoLXr25?JYD)+uw!zpPnr+-gCTh z|FK&=o~hx0gC<1`S!Hc4TrB1a8O7!bKO+YR)>DS^6L6)MVa$<`frGP9tFT+Sd7F%^ z?tr&Lobs>*(#RMH2bqj6wFu+~Vs7N`#+~&d#w#LQ0u>e%IJlH5)eSvqQE2ztSpFlp z$!eE++(@xk|N^M&{6#0&4& zsrSE6gQuQSK6_S+;ZS}Un8rdm1}T{QF^e(~nAbqRj>wgID57o|^_3*~Si)?dgynR={?66d#2rKpM_ z&Dd}CW>53YQuDQ_=^*Q9Q0s8G&}b{^;AGb9(Viwx4K#UfUDNlT*7*IWb@0Liy7{4V zy7s{b^yYic>ZQkr`r^9ji)pH_?C$H*rCl9yh_h=mtxqKX1-)OLcatds1|c=2M#xpl@s7DsIdT##(YN2ISi#}GBb&>rZGe(WA(X#c(Omxdy&!mdRuy&1Je4e zR)=*zFOrU8&9-V7a{^j|(}Dka-#7^^Yx}x(eOK<0AMP)7II46sZnzN<^Aoh-nxv@H+K~J^L+5Xmrsypn&v(2f^DT+nvVTkVDq&f|Yjk5)mRRzRax6H;W1!05S)F`{rUb@h8{$^t|+gTB({rgY+@%7-6S@9C#?;@M}l z_4pI&K5$Oe8b%#{>JXIaN6;7PK-&3Wiexb;MtI;f6UaAXUA7a}C}P-4Gp%FT-I$*m z_I0|~*E#B+!BcyX`9H^+IF0AFf$=Sy3Q<;E7cX>J&9paPVvcAEZVlE?ol<0MDxx~@ zULfx&SI7&Ddg9>c^KA495#*0dF>0CpreumL$%{mTYEo5*nifHaydHR?tn|h(%l~9< z`+>d=p4ig#U1v3W?*p2D;33UF@|dO{epKTRJgm`s&S?9ole+j|M_=8LzKG}ktBXB- z?&dXp?)BI8g|EJk#${Vm7%K>>!)?<#5tYbd}RE<$tV8n4?g?xAAi@6 zef%$d{M{el(f#L}mO%KXro_^9000mGNklN;+aiI_e)iKvtg3(N%-ZNrg~gj& zv;8-f=OgjTP5yVH#-eW)nZAOGvgDxNsOE0A=Bmum5IS-*mw122{*~MGz~*yECINIO zj1p$$vXz7wJ1>p(kt!JPpH`hC~`zEs0H@rZq;p!H6%^;E~tB z!^XXFltG|gz+gM9HX#W4DL$b9{5^RE-IS6XLfN#SAH5BnPlpeug)77y;55PvpSDed ztX!!j4hT()rMxKT6O>SKOITi(J56Gks7z_5*0Mbj#x9u|iZDZ$zUVJdj3dhX%<$P@KV8*qA0ypl42(r0giNzU7%;9LF3aGHJkf*Sb~uC_jRQ|FN?X0I&J728 zfJ4QD@cA&t_)%W9Jjwy@VGPAt*32e`p^p)6+?X$pv^^W?23NZkybd-t75u(c}p54+{9vbKi8>U~wi2w3@U%zzoihlXc zH}q?-yr%OnzoFgNuBv!*tg|;wPaO^P%xtJf7JUuynhhhDn!pkU7L;P4R1}hj$RY{3 zGA>_l4(GAr=+Rh%Zm6|r7OFvJY+|l+8RcVO3u&~yr?7LR2gjy&rGcIkGUk9Yz`~Z$ zukl zF$i)r&D2jaGYtj<^}0h1yBmppapCDl-u-7j_`)atg&+O?pZLX3+9!PojQsxRo0UNL zW-H#W_gk01=RWuI<%6dNzwhL5`sYs$$ImvCOEw?vslYG|i!LYdq>Pe@D0Q*;D-=(b zDYJR91^H%n1C~;)l!1q|&1_0C+H%+Ffi`BQ5SDGSpnDhO6&-r#r=|Ng#h&k26`7gL zmRRL^=;UJvDy*QpAfaT_BB|jWpZ_3d!IfS;nyEgj)bQ@qJ#{RI0&a&J^2p&PqlnQm ztzmS9?8zikD-UCQ(9FkJ$|lKW;qeuZ&WGbIX$dMI>(=At4$m+%58eVVbKK7K#!DC- zWSY3~f&saw6ex-@%Kr;FAcwAo-Axr?sM%q{m~)d@6?iCp6_jVLiVY=OctcOqK3UO@ zRTxA6!J$y%$xl0yj6TKV{hAqRBPIE_lMqk4hTx$FGibyUn>zFC)3V3im}9XlGA%&~ zW0sTAO-Ip{H~l6BW#S7cQYpdwQe#PV#t`y4BNeqQaC(6s zGgE@U1yy%r*7$)FI(+=B4xc!y{YTI0=D96h-stJIj`S5R^kQ1*3yZ0~czCEU+`OqT zUA?YXE?(EAi`TS${<@k=9M-STbZ$TBiFr>?YN$uzP+PfFtWEpVskY}c?SMVTb6D3J zv!~}W&5=nJf*SY{yl2L-TmxDD_-`^w^7*j8Bww3e5@J^>N|}`koCFs)N2+h^>vSFT zB*yb$`aa3Qd3~`^X-RgxSaL>BFc(gHg#<5rSRaY308Ily0ls{mn;eys(RsK9Gt$&{ zxuh8E8W@ym-?LHYgZv9mzl}TREDS!3TQ-l>MGY!hdUGP+!$$g)TobgFHo>O6koR!m`mnfjUt9L zr;>E3Y_x}#0(>AKqfcWnX4dv=W^G?)rU1-L{VC|SHk=O65f9AAw4x_xaCef#g z621!f3mhm-fuUGK5sKOmVj=auiISYht)c9ZGa5eeusRz<72S^LSXn`aqBp#; zTm&&yl9_Ta$&E&&+~IY|XK=#FQwox-QHYJYJ=4aZqX&8&Jz944vV%srSq}dU76g0%PB0e-x~n8*g6M7k>HI^=p6sSGCVO-VXeBI_w*!Xt1`f-o}P{ zn*;UWHLQ1)I)ykhYGRe<n_ygD+`gt?yKz;YyK-5-eBn*~ z@@sGCS6_KUFTQk1UwrwJUVQb6zWVxgoxiZH8yEI9K0i`?bEeI!S&wW-J-=J(g@dj> zaMah6Bh!fq!ktaEvpCR&#hzZ7?CI6%q0Y~a^yYM|%i|+mIXcv}@kl$Q54njObI4o7 z1dkbUW<@M)H%UDNjOkX_f)IB*hX`5@V@(MI-Yo~JkkjMUKuJ> z67qIL-rsk+S%YC&Z=G0w^~oon`d450=qG;ikN)UC|2KckehdNW{_~rZK=@6zyWi|L zAc4<(<}-1;Gyg}5>gJ!%_4a$}>5fegZ=xGboq~6jT(>3eHv=0Wva#liiTuhnvRP`B zN?j*BRMt7(Ov2D-PSKf-W$Y{qY+oW4vW%_hY-J30rre?9JkN(pYnU>9WHKBCU-L=c zmRv9Q4SpLLYFW!P+a5Ypd&|ctbi~F~;DKa3tc-`%%#!?{WpzV7$1``MUQZblSLm_M zazhen>z=B4#}s8`lF3_+N2u=!+L&3}mz3KulkZ$LDwM1Pr_662C^rM8c~|3uq#KtG zw0C_hTbQ~So@kAF{X#>o!!s@XCc@0PMs1{EQVvj+ECccp$+9oX_$UZIJwllry8T3y zYt<|;=t+_H)WzDdA!p$WLe1bz+ExJ*^8?@0c zbr!?^Xs4&A@Zz409lgi;dJlBoje&ofHT4Ky`zhAYRwXU2ALymO_o9CFZ~v-Zd+`k& z?JiWr0dERUYPfkq!;MqwuWzc`UsvD&;{S-tf&*>E{8xC6{ysT5-;B-qpEKs8Ch5Y> z9ldhxx?X(aihd14{)I2Up|5=9O}+feWnFmnny$QhUAwREXn`jlu1z%Dp6k>u#(Wg@ z)U>B3CLKLK?{U4})z&OX9O906y>#Pftc!;Sx`Ky(eKyBSU+BhUrt3!&T_25g1LJ%B zXsTTfzXw&NBlZGA)&k+x%yFa4+RSRWmB`!@K$()%u93ish^?F>dL*mn#57*aba-$e z<5k8(Kn;kVNGe%o&>ft<5_$MXl@q3J9CMp0dQSDfEanjEk>x(bFObj^nn zB+(jezfLdMEa(Zg|&$&1GyCa|MjDH8z&_-fkxHv*#<5T%Co!THY6k^;&EB$Le{h4x8!BN;TK zg%$@hEk<+IlUkaYRM8K{9Bre7L5I8?XaPB#FGu2CpTC9>GFZX9B4|DT6ckdqgnK3M zk>SDR;_cmlJCrK;t0}D%F&qVoygi-Eip*88 zOqf&<@5BNr7Lp7_mX#tQd5h51EDto94|Fi<>e@Bw_18vv{iPl4T{%+V9eOWxcy}%h z20?Df-0&u7HZGn#m!S;(iqiR_OPNK<2AT)_urY?udg6r9W{vvaotWZ-9WGByR{i&x_ zV=z-FRDe(h2{s%0gM>2t8P?nI_#^XVHGCr1R0JgsG4oI><8H=YP{6QCbNTI&6^t!P zZrIP1g&syg{%@DR8@YZk_w-$}Lf>&DJ;N)UM^ezKanQ!7qw8PV(XancFX>nQ=a+Qh z^_$w;n`=5X&E`?nykH4MHH$_IjHr(#tE-~Brn=~9fefl(qPk{f#Cwl24&2`EXjE%* zP;0@f5=VWMRnq6?LDsqbq=)yjo*EZ=7l*cYHGMr-_w;zx)q{0MCz_7BsZa(}_}ym@ z>@&<+>M$>BN;Xi+ebv@gr9MWxr=zCOVHLD9X>^1A?kEH=okVolB&&zQAvbU0GUE3YTH8G3(r9X^vD$CrTzu5cmVthHwC;KW!E3c%Q{K_+v0 zjU=u>6W8eVc&_>3OtXWjW``5i{;s@zPu?h@P#vmJ2(&Q{C~6o3cjOpKFaQ7$07*naRA2t;n|kS`D|-F)8@hh!u>FTK znz^Y&F?K_V5zNrZYK^v;d=BKs8kqS!C;C|SE5@Yb@nraK>!Qe|q-8@70wsJXH0F*N zAIhmy6oo>sD_LY5MnH%KpsJj-Phm?Q10edAyq(J-D_A2}+h)+WlD2&bFRyd0mh@B%oWvcZ0HbPkH4T3vV!ZTMT;+^cx%bJKmnMHj;FL zxjD_@?;&i(GgYZ~*M;80HT?6FQcq$`AKWjraXl%$e4y=LIj`6L_LubfFMUOC{K~7k z@uf@Jf9<*^Z|-Tq?T8K+>Ts}J;}GZ=z~z8L#}Fa-HFS^l(C21sh(TQ+H(DPhZDP!~ z_A8xm>{U7i9wG11!$yyfq{lGwk4%Fef$jsWfzyongaxf3<8BO!#B(qu(z)o!c%_gz zj7ULdg+lDMP(+qRSH6~NWWC@(G{TGCXHV_nu^%of4vQu?*2W=9AqWEUed;m}GMUj& zN^NLmp&ScU=!_W$nK?HA1)lmDb@VuWJpd1DybdcFZ_w?>&(LN|S|taAKtDkyAsg=} zF)xWZ@qLr&OUZmBG9%3LWvpR;sIB1%t@StdA3XigfAj9gp8whRz3T&i;^%(yU;o9Q z_|#7v>i+ZFDuM9ZO6@+ew@Bb~pZoa-H`W&a$9{kOyK@?a#eAfu{ozxg$mW&UXe@l+ z9OkF&v@{+o@6yA!5PX{i&DmD1s#UhzAqhDQZTR1K_}CPD-G8L z9(Szbt{fbm=RiHs3FD*9?X)ui?Z5@8!y3%6Bhxe}AzxuQl{?6yql||n6u2WFhaL|( z%0OUzj7-rabQg*VI(YUWWJNrjh(U}+sfe;o$N;BRF|JFtt{PA5Xq2_RJ=VoHcl6q8 zSM~DC7j^B%zHV;sYqUSs49_UwwsvBvZrN!OT~S6zpaji~K283ZSsVAY;pw|!#~CRQ z1Bitgl425L5#!Bm+7Di4CRf6)kX6!I40vI+U`4X1WTjGwY8ZGOq&n8B3s5#{9K2g@ zO10(QCMhagA;pfui4!WnSK6o_H`wx10dPh2U zajN0VyITD68=Cz6^V8zweWObl=DI8^`??-$(-C8&$Yp z{+pA)i!c6_wX++upV=53{eIQkz4>GZ<<`i(B=-Poyi?z3uCVWf8*xvsLNk-uJA*w6 zg9iAxRwvLZ^!Ob(t|bp0Z4tCH3c%5(`SSi9#!1{2Zba>b6xhf?0cC2n$!aNbC733a zYP_BK!9;V6)^vZO#b~OGm!7#^jE$-Aw$LpU+pV8p~vqAWZKUv zUl<03N^R&&$)A9-G!Wkk#vyok3mA`@D8sM?ED9f?$>0g~h%xDKZD~bRBWc077g)U7 zt(y&HVT0@RtexGtE?qg$YZtclDu(>Rh3mR}Wmh-1M_SCY67&n+rXclZ&9lfgoZCd|d|_|M^(p(&q)5 zK1T+f2rxm4$k$(A6bRjA(6%FPD@3Wmh`3QQxHURrBY0_Z!R>{%E{?QueqSdDAKGs8P+UMZ0%lr26ywZX>&&VuM&TJ`*Z)BTUW3FqMd>Jwi)G_9I%1QL#@JL4( z_CX!>0CIbnF`njDp~qpwT0UrnjH5~g`d186A^F@nU!?_gEK&-rq;*%l*w1U_aC~xb z>Z=c)eE2_o;@p#e{DaSa;17P+qwo3`{`4RHi~s$P{lHHjm@}gL&$mhf;af%cA7;9L z_wRnDzgAQ~b82(?voUY?>)8!8i;)VGO1!g>gDO0~WGFl~p5;bv#2l`?9KR9ywMtf| z%qCkw3XllOgbvZC!~0%=&E;DkRhe6$&uJrhepIAol%YUE3MxoD0_o+odXoS1tdpA~ zP4`9^^NFg%xiSXc-=Q1t$K9BV(6}ZIC177va5;FnzUE2fIHYK%;QiCL*S!-JMkFgL#-Ym6z40~8cu%^sTkGnz zv0i%RnqGeOhF&|rql=gJwT*EfbLDCn{7!ch`E?ZCn1?P3upn*n@dx@Un6H46Rr{n? z4sDb#=Yc?G=D}M+GrN_piPy|>TOXPRAk?-=)&?r@oJ!>1;N9uqK*D?Ej3QUeE`L$c zW!bi+eH-RV#@ZAH#zVL!E#wr0G%z{^#Dj#K;T-?Xgb2yw0}Z}mBT3AUlw?5E3eXMQ zM3r3cc0ft{2+CtwwBm>~i>4e*0Y(jwR6!3NiTBZ2L)xt6tbUQS*2~)LNvBHb3g>-w(whCVSF>f?tUedm7E_Yr>oL06wV>a_SrW*vPm zM*Vm3{`-5Hp1za!^&eA5-%f-Pq7mTN`?8 zb4?Fzt?7Z2YdU>$sD~dop@+_H>cP_+di3+LQmS7{9v>W&A>Z#K{MfWSPpVzL7Vvy6Hycea_^-<(VLuH z+uAv|b>`(q&pz_sJp1sofA+mkyzf&lJoS<9{(ax^p?~gA|B=7=|NJBW`2XhRPkrit zJ=eFv^A7@x;U9!S+)HEl%x69mA2@ma2TrU_{>4&zTh;g~N_>I>tO=8hO&S7Xw_0_* zP(r!2n+@gUn|FnFOen)u`klhI-tm{$IgSc_*W&JUBEyl*h@om%o1gyeSR+S8wy!B- z2nx{U+B#y?gL~xDga^LR(cY0pH}^HcBd-qTO1x(bGbwW7FKXHXp%5Gy1I}2y_g2P(>;qye5$N0WJ>J>HjIymWvezo9RE<&xgSGrxu>zCWroLq6Gha^v5j zU8fwV%X{W-(N{NiROAR7nYAF57S$^|GUtyT?*5qBtt_+KaWPKk)!`Kq^X6Bw8QHo# zy&U_X#AIfg`Ix*eJu7ildvxQ5w6`bYI=H7KsyUf<{8Vq_t1yr<=nHuU4o%U1z5XJFy6-y-lKH(Fb3=)4hrXD(5WJ8Gd5b+LTh=>QK8m=bRXgL zK)*EgI;MVC8uX)jJ!#kt@*nq|TI=iFdS4H(uj!GsVH-Zof#A%>KqrS?ZT6xDg{km$ zTi0q9Gl}se##xcqFfJsf6ryUb(G%-*TvclSAsyu2Fy^Iv6Y@rwk|Omoa(7QXQHL{B z+n#f_#u)CSy+akeE_<*k^n`WwAn)rp7dda0{pqQV69=c(P9B_GKec~i>(tFNr_Wt_ z@S#Uvdi>$Xe(}jip87AIdFYux_rdplk;}QtJ<30WZ-ri=j1`(jtuoI+?Z(1O z$#^RXg^<~>Z$(~8$db%#8AtJ=o3unG1eAD96E;3ze#g<@$`^R98Wf27IYxW$~4z5jCET?0cAhDM4o7*T?b06X5+T6!!q z@^uI9&d;=UNYn)$cih>=<82=0%X$TUmJ0sEG_GAE$~w@Wj|WLBFlMugQLV5b&VW#m zhFpXZkdiV=TPW)gp%uHzD7G5UtinU9Q(wFLjb6X7ivho)FTQw5Uw!R{u53>=nnlhl zrLr4qjDM%Up|ZEGvO83Oj`LFzmg=wuOmZ#C;YPd<&8&&WufXLG!Xhv;nVCXs!6YU{ z{zk~#$hgPiUPF^L)`g9$T2gBFVkJEO!{nj$O3O#9Tr55!8$D_7PH@EAddci27l{|27!$xfj&YdxJ=AL#7HunkWR zd)n$38kFLIl95G1J{dH~D=Q;|P(+1TDo1876#nxSP3Mi~(00$fX(uR&q05XmxgoR` zz7{f6eSKE!@1w~=i^Hk9ye4}HA$(U?4*&oV07*naR6iXC`RU%q|MdPxpZa|tec|ID z{n!V-`@^63z{fuN@sE7Zhd%bv?|tFD-}kYPocrW+pZv>z?mzsAf9ucw`+w=@e(KZz z@1OZ2fBM({@IU!8H-7BL?*9$B-tqaJDG0wa-TcGR%WGfx51u={S^wF!-t?JN?P@;W zMZrjh0v5*1j7`O6tExG=a3%{4g-QOnqV~|SQf6(O4QWQZcK|zG4ywtY6&OdnfjSY-y>A_rC}{u3 zQKGkRA{y8vLdyZx99ljY9p~>(c)^|NyW?K|PC)0Qw8|Df1~XF(Qm{;zWTBEU=u7}% z1M(6Q_Q{kPv!M`Gl(M2L6+P8>ax*;fgHgs-Rr>17*Ywp_uITmGwsraHfp+%i8c&5y zE@;zJ$<=G8+ox$)-F{CU2YKCYSG`Ub*#y+5DdL@Xh?N!h3)3ztiX;I;JIW5rD?VLlDqO3BT%0;beXHV0s+Zf_K2@g#u`)Eml zRT4NDCfa5TN-`zpl|f4I+jfJ*yuKUIRL`WvRP&oV(!mk&g(7VNb&XiID7sX{Lkyu% z#`rWvNAqr}qfSSoZbxGb>O2P3h@-aL*4Ob9ptX`mE0RDthCEY06%uDSxp5ctRFv}e zbM{%~o-LlgA9$D~RnwUo4lc$mLS%dnj|YsZaj?r6+2qJG#JHgMxdPs|v2LQ(3b9cj zG#0!gW4sL`A3IqreKq9Q)U60B4?&$5+~`!Qn>pjpiX1{5Sry`2sNwLFG4fH3;9UKX zv|bk4>UVS+1Mi;vslh;7822vY=DeYZ5Q3nEoXku{KFq2eJ8NXdxNF9pT&^ zVB*Pim6kB-2B@(46SVSi+CxgHE4s`=U&*ilF$IN$5?Cb7#xt(dN7}n~Q=^*)njKD5 z&noHI%VY?O)W;q`!&-Q}aixM6+J><2p`-RI!*&eLA27*vkD0ZKC&=V*7M0*Bt4-%) zS-3G{7;<>rk*Fv54T>r(DA3sGC!>%ZhLV+PnIUh=T%j#{DUeTBDRczZ@a z8Dl{Q652B~Sse~G9R};(XMkrPXN*w}GlM^)dIOLP;}wsOlR!(JJ+_4BM_HTjZ){vU z51#Lth{Hu9H*mGG8VjB+2B|Kzhx4S_n^!dc@+;D%i{jKNP7epP#KydfKnkRsh+G?TAq)^$5?7R455dGKYE`hDr_Srtz| zuK4`Zsvdbr6THzy$@nqum6DwAvMd$h+c_^h!~nS*6s5#~-H_LWHS(UvLbIZyy6h=+ z`Ym!nJa&~q>~iQTx{6%A2M#Ds$LW_C(6WpQ-Kf|r2!mp2GU(=*6hcx#yw?fpQ>WL7 z>e3%cm6m}hmm>L|$V>oi(vCd#A~*Ch>LFbDEke7`nU5Od?uQX>`;}-T_I`nJFEIXH zHW0V-TJMx{V}6?J_)}cZuOXXK4fD+WB=%rES4P$?eFdln)yRFuYm^$pp2;&a`c3kE z5@oEdB_i_jxoOy=77Eod>X3ukcs7=yvmQ)^@h<&4A1Zr~>;Cg?BY_p8VHLR_{^li+ zulGccj*WGE0b zkBhjq5RY$Yf?FS>MW9|pLzZ}sj#veyS*_}*(tI@443B)md-AksqarL~{x?-_GxT)!))b4(zH+UER(yLeW<(IDN)z`Om<=T;M z?#{JX6l$7|a_B1JiFdkPm0b)DnmyvwE7BJShU6z4!hqs6`i>Yi_XbN0WPu@!a1f3| zv*q{|O#V2Z9xjoV0pG6g3EuHz?I9s3X32aGBoP(a^049CX$Sf%ur{4!@+;rY(L{^O zmo@t8%hDScq}`k1w1$C->V&9}Xvty3+agSfLkKobC1owg)3N5;*R}t~d8PdWX);y7 z>omqJ+9eMKU-TPe2^T>Nj9hv8gdY6x`}NrG{zvr04}6~<{qB!z^T|h*1|1z?sCb>N z6in^G!R1DOF+c}|yHUVrj4}s?Nf;`NL4mlLy=9$c1lhs|0UtTAMwqY6+(nG|60|L{ ztT0GrVB!p*Kz&D11V!Sp3@ShXu`CqG4-sByrznLG6l5T)AS{@SV3L`EERo30k4ecA zO}rlcv2G$dBBC3DYL!Z$Q4hN7-Hz5-bA9?NkgZ|7eU5@}ZJZZBKrN^@hnJeeK%Xo4;lcO;JAdtMEVV(afQkEcoS8vWiWU|4&qHGuNBRu_c4So}TNnztR(*u=I+$p-JJOV^ zb^kN81%^G(8YPUm)mg#4c*f(Q5gG~tO~Gf$pZ8A}s3MdKWIgUsAfbSAiIT@1Eyw z4ZAedyz1*<6mH!hBJ?Z#BR62_tJD%k*Z6!7r6c;ka% zSAAY?mi?@o>THA04XTin@yj*r)3*#@rE{#%wOpj&~25pTD5V`8TA4L*?mQZp@9o zy&o0?>5MF*+Wvzn;++p4Jg4G4PfPE83OuL5N8hiL&p)fYG1QS|H5jZMqTH)@-UAc@ z^Y4hzC_o83EHJJf?^;kFdG@K>CG0Tv65+&!q?0cX>Zp;h#dJEVP z(Pxf*X=W^gtmXVC#MUrJtSk0F#IP5PWxy@MdcUI+$Y_%*_+hu8p9}@Bn<{PTo0^Xg*>I+A*<*vE-q~DiRW)op4_!3vrgDSmiQgj7 z59m+~DE-_b1#NU1n?AIPHKrg($swa?3dM?=i=;G7njZh{_+)Ra@$ON3wcc<_w<=?V zq6*q}pjY6HyJCthRmtej$MMZVZ}YfPalB2@tt>N>ncYg?*&hN{*(=pvd&YqJ3(D{d z=W2wHll#hHh*9ZlJPEqFztELyBVD+-t5;sThB3d={{8s&&O}F(tP0tL*jK^a6`g@P zot}DwzWONAfrtIBI$aYwR?#ZZNN$iprbqD@1`zv!+=Tq34poX{_?%d#5}Y@UnM8pdc_m1ajrwU;x`T%uj$9`CkaXeW*M417y!QHyi>j|&R(>E2`eOq!MFZR<-;l z!pz!vYWsFZU?`;|M~!h}`KY@#kT%v3N+Crm42LRDYzQl=6oOQc$vjeWn-+=SKyE?S z8m>s_#uDdRr6m>IjFh2Ki-L3{9_fO3z=#*2Rv>Q$;~=(*hcXKyGVdFLfkRo5d2QW{E%z9;>TxKpO zhsPb_mPF_b0|jI3(Z>esZ*w@%TCapOy556~19ail;Tqzduc--NAILLb2j}O}s zkioLTYU~9Dt)m?JN*buiYpUwLcJ@;1b^D*1`7hO9zi?BRt{-UUaHi3u!DBZ(!a~_f zg|gFn^C%7LnwUj+w&_tbBC`> z)eFV>TtmF;?#(?s?u#0I`6cat;S1WquIsybovF%sgOxjbik&MvK{0vm>tO>9ZZ`w2^iXwBSalSyULPAUA%_ zZ^7D)tUa%n5msgm-WorL1SrboY&p?0`_1MujP?ov;~klVh~N#Pj)Ks=cArB>!Gewj zQio6t{Jng}m^&;=4mai@1UZ~1M;S`35UFIwSOXx0O*zD~;@9$Wj(qN|$?`A~I()2& zlTOVj9F2!rayfc?ue&;krG{}b-adzowO&ts4jZgm1>_Ng99Rc|_HfI51W^?_(1Knj zUm$0`y&hqpjffZYA3)WZCRL*&^p=oYFE2uIu=72m&wc6R@WKFf|M@nMK=?M`@opCH ziS&yverD~&aPiaKV*I`JY$sIHeKvZ7XD&8bE&pd8lP%abjcSZQV$)=mQU+`}BQIFP zSQ7ZneX=VO6i!B&m|3fMLTk*UEHqwk5dZ)X07*naR5P_`myRnS`nHN)i~*~-R-bdF zKD{~8WaqH`r&=5Gz1$5@Cl@Mci|vwO3^(%CR6ykDMmanl(6VOGG(_4WDN}hhKJjE$48g6Bm#uIslZfZJ{EYrQ z4keO#DSwo_&t1T%heabd3T`A4v>i2L$zoh6?>?UU5HEWT-~_SWJ%snLtiCE-Kx3v zuUu92m6xPf&r7eosp`d7bn`X(8qJn6st_E;E+8XEJ1*!p%Gma-MCkB*2V#&|pbe!B z9)|;QK=llIMJ5sJDD%g0XD+oM>Y%^KJprdfu zqe>X^l)K8UQ4cbdkhr#GH|cGmw|{alxpFbH;|f8-i06P(X!A(BVreMGh}RhF#h6Wc zFxJuTp{6&FG{qyY52k7k7wz?X0l`iRa&J7M1OlgZw2aWjEo(3@qfXl~v_0)WXVM93 z=-5klkKE8k4v#P4qnEty-9Y!EM$%g#L#f{dqau1(0RjdXsyvi?NU@_Pb#!!8Yuo=F z^-KGD_4ONEr(e~VU%JBU-Xm@A%yl%C>QpGlLh2NX` z%do-oW3Vb|H(+kpn}yc!rq`xN+Bh0%?dVYb(Y`u|`zmJ>ULnj?Ld$<;oiO0ykY|k5 zhv4?a`AN!hIE#rx0}QB({2ILaYrpaZZU6uNjt)Ni_qF?XKCk)3s|u{oA+I|Mc=cBl z+>)~l@+~>BB;`xem!QWTtFbpSdJDJEz)M46qaa6ZrJ7K{PbvRlV`dLZJ!o?pV(*Qy&XkNe)&9hrtZgrdM({WSkT8=(2%M0*#PEpq^z_paJy?lr7T+ zpTGhfm=a+=hO^=bjM3qHE^z>(mb$zm81(w;aqZtB9-!y&`i|Q)0!fEtW-{tI#C$tF zpSPlk>Y|@=HjOwq9Hgmc%~W|Yj%jD-2VVKrFTG#)pKl8ZENA-Lg4N$4CclYpUU~5^ zzh_YF{ah~|K6G@rt!6QkUwt)PW7a4{C$UtPu2N7;o&j2=o-;B3u=zcZhmJPUe%-2J zZBps3aG86@H_iRzzcj>2?rn#8l4Hty@$t<=ja!d=EdK?&@$S6zO^j#T!5BrhxtuS6 z-c`HN+57>^xk7g4mD)~otME`(D2I#^kbD^};b~XuZN+!|3JQ5yU#*8*NS=1w)n?Vd z+<1qMEOb$TOZY$jvc1>njY}ha`Q7+ z?`cO&)Mer#WfA$BwQ5wY95Lp;KFmyaJ}bGcLNB{J?&&+>hS^~`D`1eLhZshmPp?^@4*-~y8 z&u8s=Pt*zC7k#$lWlVhNHn%{K2q>#yjiq@bfzF~|a4#|Q?ZC^6FMfJ$ZEg1_PxcN! zJh}OL2#bkYV=CjcZ_4b%jA9p_K+GMy-l&pIQsSu;np0;%&01pXiF#%xW2>wnw2Lc! zhsPUbxuWurMtrD`LJBB_7LDZZ!i3Ic0g=5$V_Y z7E;J~v>9*KT%B82qODOLF`6-&+~_6oghDEMs!~^@Y0ypom)qVr)aSo^O~3x)O}+RE zUirnjuJ0s`xNcuC#s=e1!C##!`MXm0zBkwVTK=f0t3e4c?A;hu!ej(xAP(-7jrqjD zLSkf1UZ~DE&JT#2CF#g8%tSQOMGcsro1(xN98N;28 z{tt{VkVh)ji9H!>wB&V1kHbM(P4Sa!b&)}jad=-D9uo3FnovcGNeazv#ANgZ$QSa- zjDzlEnLe@zfig173KT?-Lo;v$yanWC>_s_{MJs0o`U~>M)0uu61HXP*Ok zDdVf588FyR#`*HLO9X`!RItx_WuYzxy`a8Rbp*yO%X|_e%PmL}8nOv33f47CRmpD< zi^L5=r4%ygXvVzF{5KAa&qn)ktv>ppuT(c4(f#M1mq57ZIlRXhtmo73?iJ%d*o?1t zG(TWt)pCUvlv+-WjUp6G3${>@@eK^kq+0UO*f?yuM41~*R|E;=RD*_%>G6h5P7=M9 z*_h(7b|bRqP;%M3L?>n5Jq8u7G^bh3(OfhC)%xy;YxPNsh*Ao*(Ix_eR%ju(x)bJn zALnHhg+n1SDpwKo2VdfG#o~}9Qmc>AGDtE7lvN-!GntV_YXo3EW;YOZjUsF0Pb^Gj z85P}lGq4<%ZL&pWE2V%D2z|{KQ8#xNx_D(@ubki2ORw(e)${v0|K@@AkEF@0z|eM- z@ZfW#UF^z#k?cQiTVEe)ZLOz%zpJtnR2D&zeqtcB0KW}D#>XQKGMOC%iHpbVINgTS zHKv^Lh&CZ6`IRSnmztTe)rh76)ME~PT`)8y+Rzc>67(r+X?do8^A9|jnG6cB!Te@< zU-H8OR>0?}T}v2f<4V{to*oJ#g}@}z4MwA8PHMkhYQtLYojDTk)-!cmKqDWcHTG>h z$$vTD9$Kj9q0{s}jsASlne~S~MO3DkgSrTUSycz#%OLnoKI)BY8**IB~H z!-nweuR&XOoNJ;H@X(lp7D~t|(VV&QV#>X`w{wWimSMo-&Oa5&O7igtc^dGzVX(#6 z0x;w!4msXtMGH=eGBA2`dV(fHl`(dsMDjAp%*66TF1a25YMeTLS{8}Ju^I%D=)V!U z&~tvg&)j4gbwW!%9a28^m9)<{KvMo*3NP=w}is8cJn0Iz71n{ zIa>u=>^CY5t}y6iIM62lA+ z(|OS0NV;-usy8kk={z2}hnKEQb?wGndn40yUTThKnDCt43-8jt*CMLdFEm{1YB1;# z@2Z0*Qy_ma3O`(UDCTY%Px7!u$nv=}Gx7lPu@xA%0m-BmP{5Fh`E|I#vy>HR8z?vP zdTO7RHfb}H*%EER+kCmQUS)gSu}XWmO2ewkMj*(%05HvrDx>p3f}fN*mG{j=Xwp`T zGL~*`4**373gr3$Z?Ra&!$`&EXxiVqbnS=#r}JMtLGk_1JuCsAzk8Uydxy20U+ly* z{lnd2{wd84t(i_0bCRJgGcL#qLb>}p&E%}F;(?6`u*FJvqYjaFSY#7%rED1Uh_NV$ zdl*%yz`z#jC~_^`8uH`69ryojY2!wFw9t5Gq{+^arUz41q?;K%WALFW|MQxlC^Ku} zEIF1+AnVTXcD^g6B_wYb_=bklQF$CM6|94SF65gC!Md91Dsg;_B(b-P`aWvLir zi-6Kl;6Q+)sJN;$#*1#$41;nsUTBZQ$+hi?E?qj*`AhqH-92&Qm#>esd)R1#w_foo zBbNhp2195^b$da5|1+~;hp@zecj&jIte`8C^WyM1@=%71;BOg9unDY%-r+H$+-2aQ zMQVj5xN-6K?|yi3+66ZbMdAIbm5qxf8CCf#4|YaB-d=*2HjIl6p2?J{ZQi4Z-{jdp9)Sq18t0ssII07*naRHPnH99$f3+@1d=kTsSaXI;1Y zqk~l(R@(eKLo;i~!!~F`vt`=rm@VUhy&>k#^L!@F*Ih8lL8iC$J{TIaE~*;2e2)7j zcI#tyTy`r*@)Z^>C{SkPo0-f^429QQ>=x*-yk+w`q{HdQ=a+B1mumMv_pk(fPVZs% z?j6>4cFsRlgwY=gX*4|Ay{39L=1K}TM@g*#$4GeCxN*0vrSkSL%im39&x^}VmxD$837H3rTT8rL9ZLt?-uHoK}E=n7`GI@P*IX4xtAP+ z6ixClD9c(gB`rrhWR<}1NxLpGySgQjmml_q6YB$2el@|`qvNgI~H`*8*S z%_2kFal3v(w9H3dLiE=fzQkImF1@xN@8@o~F?9a#gtvG3w~da!%;5Jp?tz)fYze0+ zR=4MS{BDRInYj752wjM|Ue*ROsUONyZQ7skFoh8o39f} z{N9ct8wZOe&1o~sN|UC=>~O02;Y>3;@F~~ob3EAUaIOa9?hqciU%|&Zs%Q$l`M?G> zx@T%pEDf7=wdtiKe=lLs8!-%8RGMhT1l$LeWj>XeU7!^gHA8H!8vGfB% zz>o`sP)LkT901I^WUVR2b(GdnlSypc7$%R5uDqt+jUQ};b;E4zIhk^Iv0zq!TBYcM-cxm9PL4nr9eQp>JV>h!lZMF>M zk1IWg$B$vQEcY=jvzF~MG7)qfUS{;wq^XqW91X{$ccK4(dCpG zcWUFbryfzg70g4UHqYm;?K|hTKaV#)Kga2|UmAP9qosSCPSo?d%g~f)vy5leIjMrb z+UM`Me%qcl9^MiF5i{fa9X>u@=z1IP+e5J~EApniQXG#aYL2EVCbO6hk3RVeKmYTa z??CB(_B|j0*CO`-8}~K~qtWccML7DAGLCIDx~{sKXug=K{oxQsB!LV(L}#%7Z*N|q zOpML#b|4T&DmVg$DtH`2YzvIetWo01xf;#1@b}>RN1FMM+ja@}j@mr`(OWaYfX-9v zjYnLvP)mN58rg)7Ka3pQoO2&GhG;F}f!Mhs=JgfJxrB4w+1CVdPeWL(CG0-?j` zFvLb7*0PWYv#c~3r-O*WwOC3R@+L*z!4+CG9nE;xeZWn^wHq_NasE)RynIt%{>pZX z3l}E3a;4GkUesjPSDn`3a7`f$6bn3g##2DsuhqLfY0x*VuSacc6k1ygDhtWIWcQ9E z{1~SqRG(sy{Lm6iTEPUbdybj(=ix-q)mV$MGqRuM~P+D1sa~%*Z;VJ^}4r_X|_ayw;Dbg2Yj%C$YGx0SIoOS zpPY(xO<2Rfqap5jZq$Ww@~YVl6JV;3o5DNmbCtd`Ow{xGo|oL1Km%(dhtb9XJ91>o zVYn^xa#$g*HY;Zj`NF7p8SIh29GkZ_&tF!$mGAXhAU2l`!SToCi$tjJ`Cki@^Xl+2 z51lo^uOl-bp6A9oVv!-7 zO=Vfa;ZlU`v0L$08KDr24H^j}Mk#WQ-l8^zDhRu%h;7c(S{m1C#+7P3?+Oom##Q=! zZ>k9%d3Cf}v}i){@LTflEihm9TB^f4rb=%@)D9(Ob{&*yz{EV|h6zG56mv5P|L@(!-6 zd0p!IwE&wyWWTZAcym`@efgTc_@#?_@hcZ~{*7Jj?l)RgeGGn23+h!E{so7dnuCrj zJq1&NA@2{m8m{%UHYDtp>UT_Ccq+I`&(&Q1Cuis%`ZfjHm`M(gm(E*^ltuFF7D5PZ zUZl)0=4Q9c%!r3917iF}UvkUkX#XLgin3bE15K^jP_5o8#EMwYYg zd=iNSO;@5sm02{_Mgm;{IXvSyWc_&EuLq~+##+o}yVmH$uTYs98F^040V&ZPy^Aon zp^Ok7+j`XfdSSa}ST`PeJr5n;uk+_)J?7fGlbI<1Gi&2Ie!Q;3*Qe8g1$o+wJ#?(% ztI+e_30~*x!Q0&~cMe(Atsn?}?8cGJsKFjJ;u86`OiQNJsO8TMcSeZLx5MK;e+W|< znP;XtL$?KqOx?HkEmjbfbWD#K@NzSg*%H1k&4{x;6->9%3T+()=QT={IkJV8W&^Ec zOS-!2F{9wFygQSyDT~kt0!H7SZlFD9zWoNkRYj~C#l`$gGah|M{`9BAoiy&p?;Q!; zpO|kj7t_7H2g*48-Im6k*?5-?K36@TD{uu|)y;A<7@JIplHC#5Swag>lN zm&UK&{LhY3CHKh9tW^MJC=iT$&e?Uh*sluY89UVNRf$0hTxXj)XqzBcPEk=s2*(g! zR;FF1T~g-UE{s~hLysKWV>IQ^=jyND{^I;_oZCMyd-L+XUU}`hzJ&4q(pN9)<=3vY z{}YtmgPM9pYwW9I170yMg+igE&u*cTEA{?bskQa4hUC@FTf^PKVwEVG0(lg) z>xGU=jCKJZ5hWKG_nn9``2Sj1l3E~_X!OmP4OvRhWpcHJ0MV08j(A$sUwe5HbqP6^A@D5+*W_&+eCOngx> z=edPBKMs_*)6cB!rmE$kr+r+a-0R&+Qx`E&CoAE#J2L%>eiM8oXk_X*T;AfO=IhQ8 zeJo~F%`)hUdJd1bkf;G`&|AsO%q60>%_`5sC7hO*e=T4~i9T*ac(z%7t?u0-8u_b- z8va&kxJRGD z-uLC|@Yz^Tuw`>$Q`o9(l;q3?C3YlwUjbtsF)}d+wISZ7d*R*OQQ%5F9o3rQbx(K4 z@_$2pynU$g%|k8RcpuKBaYH?m{|dRA3U#4XLaW0;pa4!ckT!N9dvqcEYkJsx9(Ut@<;$2AR`*7F6OM7p7#Sys_4RFA$o67T~|zZ>wxyE=JdsLc~SZLasU&cUPXNFBz| z@k51486}ul6Bz@S35E3a4v!t@XXauV4FnDX=9+Ilt6D_)v~x$;ey}2)Sis_3^>i+y zzo^^wy>4cXEoCK?zW)njiSZdqJ6FgTC7s=9g9g6axYuWmh&q9G-d=8)$@kFd80DVt z@Omx0E^Xu?^lS7(DI<`fE^2iE%a5S<>@;;&^*rd~Y`1Ap`KZk7ot4ZhbuOR)PrZ zt7hI{PYI*!c;+RB zyT~Ow+ED%h<6S5=QAJbWArW5$g_)G6Y+a1>jBE4h&XGnp4!}M}e5A$hM9tws_0e3Z zeJ9>1R#_#UXBXqy!SI!M_io4y#l8e`NxAJ)?DTfivH99}_ZV~!6;LRalt>Gso-x)n zURYICw>X;HagPVi(VMl%V?)vqAC#VCuokBqw z8P!S{M=?i@Yho=Qmo=hq6Rt>&L|(Gc=868;dFTKD5CBO;K~#D6{JY_WOf&M&Etcuq z@xtg8rCUI&i*YwYF-P)BWKJ1NLIr)5Ak>MmN|AWPP;?r#hX5rZpLd2)|L!`gyo^zE zxf*izP^3M+!m8b>o`;TA-PL%F?i~!0nG-=)!eDthMJ?Z!J+_4BJ4C-Mb=~eqTBf1K z9OxS$Q|?&Sqb;(?jMrQ?lzGU;P-?O=Wom(pDa+^rc}U&6`*hwN4UIf>UhhOh9}dr3 zj%fMqX@{p)p~uZkW-A1;nBxpj4ptr+?FX`Qi1p-%GFJ;STej8lbBi{H+$$J5;kc*` zGDMuen)1ZnEjv-Y(yX7-ljZ*OR9or(*F7fzUm5otQ}+r(H@2@om&Usv4zq~@*QjBh z)WLXnxh^j7z#Rb_Xj3*8@3@=MLKWWj3?n|-J!*~j;kA8@c1Ci~d~q;WGpeLnR;UaE z9#twTvY9vzVD<`>ZDe~|tH4}Yt#E4tA{^IiX4G+Qtt2zzfFeH2G!e8@P{82DNH1ka zp(tgs(?WRTZ8`)Q7SRzprOHlEMGuq%#iB29rC!$^9gM8~1^VSnW1WBfK(D;Iua{rh z)9V*@^yZ~~-Q1q(Xd-#t5C_m1s@ol?==9ZvW``^3ZnvYd8@VFRE&STPh*A+VGH4ig z1KYJC zZjCr0hCHCX5tI78QtKG+lPA`6^3=NApbvPL-$khvWp4Qm+#0l?pOS+{0RlZFyj`EO zXn}Dol`h$D`|ohiQ?l+cW^G~x_W%Ws4_v7;2C#_%&o#*1N3lQO7* z55wri9OR-f!T_c|HF=l$LenU|cMg|C6v^URinM~>n7D|lAYl1653 zvePG%K-nU!GN(Dq>!c(#%9P&zIGt5$6*se0)?32dobw7UZNWf?B-r?pR;)Km$^Xc|`%X)e?OplaDQ)yJo#zBd9#kK!f zjrZUS{{h?XRI{D2Cfg$&ZI3l}+&toH{Rl67A#v>->(CnT$oup_mZu|E>t-ce(l@Du z&Q!Zei8R3Dy6Z`7VVQ5|hWeS5gKgbz&Ll%YwLl43yVrw4D3leID62u^@z_-^I+}|m zP!#G6*1);~hP+Cprrb14=0V##Gp^GoT%jN8b-eJ`Uf*v$@=KTJx_)D>(I{&&Yg98X z_vjTm@~}Y}__bh+QAHUs$^l4anN;FV7Tg4Q7%`$PcR?zG$qi#!c-^3)C{$q33({`< zV9v?mc)Yh z(7|*{v|WXs=eVG{^?mzdW1XzjpBwzOcQ~#=|nmrR1)82e< zPvaYVnqsggH!##U#~Sl4{OHC$p7*4^LSJx=?!QoP@Y2(`miga@FQixnt~^V1%+VF! zGOpCEF}dP*-B~r5mz*%rOC&asFm7fhGt(-rJD%imYrj3V1d72U#>O-=+D5t3X;6$w z97IAXD|?FlAy`xBZPI67<7ug#y-GK3&UABcK{(N+t9!f)-^Pf8H?}e8JGy-Bfby9} zRnHc9WC^LuAmlr6ZK${i`4BGyCInp3VgC?bi{bOkDSxPpZKsI`tI-0 zLm&QtiW4VP!*7P4z#)UBz?_>T=g(T6#WlIpcfKi*kHyRsp)DTI_kKMjDGxpE^*mgq zSE1*v0P}KqdKG&7Zg~Hmw+cP(eXZc>yWwTVmxDk|DP16Q2I+N4(03p|c=4@JiE#rD z4cP_Yp^@ik^ND-tEuF5{aYUrz;|_!o`GoAWTJrR@h@(ZAO^2R^0)rh_k}Vqp2n!hDF%JA zy{9=wd%8E);$WeM_uq@7nKD=H{-{LjsJN50|j#)0#|@Du}w4r>S#GL`6#xV51{M=pLC2#iU` zk9)frkC)mqqc_Tri`pQ+gJNCgTU*D!JiWpyEqS>^?XxVGT%jlAGl8dhp99t74=R4( zdFg}i(%K8}Quo{`Rb|osb$*7Y8akQvksQZ|1c=D8TCC!ChhFY^E8Ly-{LI0?!xoG+ zL*EUwqs>cnat!PU%p*zayGpwRf1uR81?jke&tsRZ+9Q}mIT7R#m>FM%`@HYvYAaz7OAns2{lb-vRS3L$SUxz z%iLF)I*R@--P28>5)_#Q?;Acv87M*_V>2(W=o0bFc4W`9xf905uYH%uO0EDKDLJ_E z^IzHCsrW1>nSf=CqErS`!Dfp=GQI+RIgIOcVaRjQQ(dlU8aH&Hp>9@Py?JQ*;-#tn z|DV62|L*U6RiFL4U)EP%x~iRnq|rFyolkY;%As!VOf{cLvuWct5v5Y7+wH5<>#D@1 zF4^4J+jf(e1!JcF2tVe$;0%)Qi82&DXyjz;w z3l1?ULZk@L45ds$X0`zR}fyYxZ70s^E1%p#T4cy*~}MExFFa!0*f4`%HIyQw^vp03;@Y5J6I;Kv4rJ zinbJXIOK456W#81IIQ*$hSf47q#nW=IvU}Y6dew`)s|(u)osg?2Te3*5FkL1AV>=H zJXWDlsCj&^>P>ez=j^>R{e8L5zW3Z)?-dGUNPMsGcAf8J=E{{TS59kX=05KheUqR* zhGeK>NldUY^!PX=i6A3uI1L-^4xo&?$BQ!lAyim@g2xRR7Hc(=G7Sa#(iI-Ih)c*m1+@ z0iAdZmod$8V~9H(;If9(4u3DO{zEy(2ap`1rT+&WheL+rJv0C!4r?iE!5P4+Z?$&% zxV{zd%OuG?9t9OwTKpbI0fN_}$8>04(pdL!*(~W@53T1;7-U$!waAwX z1R2k%cBmuPn%HBVzPE%*hr|eI9So4YP&@jrEelshz+ReibU4CNYi5BcbIVR~$qwez z2|q1~peyY0&tn4S*%0oKFZ^m^yj;^qs>9v7UjWE$rtBo?VRb z^=kp2dS-!NfABJX{?pIkAN}s*_{Sf60l)mI7xBo&B{1GW6#wOG3tZLBL)MD{qmwQ% zj8VrC#&Hb!T#Q0cTu@?E1=yL_A)=NJMuhsx7EssPK@Ksh|EdVsVNb^qzEaUM!0Zc19x4R;@s{SJ6i!88$>t}g7@-Z<9Cg!T0~JPj1h57a7i+mP4bn9)8jMwUKbKofEAc6v)i5CBO;K~&{iOQTyvo3`;!(W6WR z;Zy16<_r_hVHy0a(`U@k@(75b2IbJo35nE#h-yKA)`}3|1zX~Q$buJ&cfMMJ;)51> zf^wh|(3(QjS4C%;qk%lScv@t1WpSk>!=T09$qOdk5X^-|nCK+FEu6z%*u`^)Gdy&O z_~KIuANk5lc>gD##XtSfL-;4Z{t({((I@fIFX?^w#faJV{dmh;zYp6xXVFP!$^n$) zMyOENW6WloI!#Y88&A~s38oImwVZQ6EiYX~g=$lf0}RY+N82NGP#d$FbhIM|D`28Z4J{bln0$ zgX6LCmHL^pTR3xe1H0u%Zd2(X{il#_9fF)VnV>2FmGQgz;vgg*M<(3)xlo2Ll0#Hu zlLe=Pbk-<})p{q5nDsNG+vL!)1E~54eTf5*5`8E=La9@<)rTb9Hz8DFr&P+YY)#4% zSH*HFjpj_yMvLI#65;wB*l&S@1r~axa&YNIKd2>oWVr>GieGQ%{IfYxEx zC3RiUJk*$U&BnaCSg#eo6TL-xAlxE)+(o_}c*2iK-J8#nRJ2i}0U-2ZyaMl}u(59JIy*pL<{B7<6w zD^!M@a(~4hlutxCP-`8vxuKkQ3|j08snCvmB!}&5^oh#(UHU{Dp^^Hnu2ohW7MG{2 zaZsNIs7(nvtz;Pt=wX44k&Rc9t;AZUj)(5+qR`)nCH3 zFY_C{-mc>~lPV262bCwOsN_WTfC8)@W?&uAlf}~pXkLgHVwQ2$n*od@z7mpXoyJ4z zqdPdj{E?@Ck9`sN%}?R-M?MWc^CHH}7L^Qt>rKl_-cnp z+$c2PtA%A7BpnoZgEBx<0!9SoLVFB(HwuS(i!+7hkRj1_NQTGv2pU&)uY6P1dh2(G+(m@#87ts$r?nVQ|-!v!#=~`(#iUL z3s|mEbdXx4zEqrK(UYW7YMbPg$gBJs`l@G_o_(aqbbEXgG2E8au|gH=&GB-kZSjin zPH@Zg!13taGAi8~ioD|;Kjllze;&5(+MAqx9mczNBTuKWakbUSa>vVdUk)OJa%4JW zmbg!c>!8+HkkRZB5JEt%)a}|FFxH4V3CB1_P&~v4iXNsFf*equj#TFiN%eNK9dw%) z&`j^a9?#?X`4%3#GR5zF=^8%x=}Y*<4}TN?U@x4*f;&h$(cg3SEdIz_-+({zwg)hlBW`qpTO8_SmlLd7l;aE$kgAAs77jt* z5;VwZSwOTfg6Kh4byb})9s%PCF`ZSIZPeJ1{&u33780SeFnZ#yMdB@u%>_hkhH6zW=xI z(r3PcaQPZ4^_@Gpkt~u3Lc4aI?eYWM_H_*OA;hU?pntpJAckZaYmx)0wC6_hju_S= z=37hNFlwFwW>}AtJgT2O8DHlm>EsFBd}20nU7q6u&Ex90G0@lJrkzIVd(Fj8b0=v6 zWCYa--Na~~Xv~`~!*ik&4??e#W&VC(9bA401EP{a0HWrVZC2|8%5sUOS)f@gX5Dgs zQW)-h-BLZ!kJ>Gz(ygHk<$v!e`WfP+7=Iqlx5+L|`T@l!b`Sj<|FpVV02nvvf zJ)CnAg+HrhGQ8_Kxs)eJ9+3<0Q3|ErtbWW#Eb4gG<=;`L>5BEtzFxKQw${)XAxJOv znT7>X9==xnu?4CaF{>&}_7-R#e;#=JIn1tV!#O`hmP21^Gt`vdY9!Qp0UQJfAw%N= zxDOnM9_u=6_g3gn!# zHd{S1a!G4olx2N8<+Yl-jpnf92`)$F8Yuiq9DPWF>~TcANhC{FFHbVp8bGSN<{A3l zL*osxUNu@bO~F|T`Hn-sNuT~fk~NBdkk#MXb-(pTf9jdMb^qU7@a#W4i05A7_5(TW z+*_ROLe4KvHK%n#3OZfKQH>~1QjaF6Mw8bt=lPCA)?el67rPrLn`iwkj3Zv6)kl; z%qv-r8zdo>5giK9*pQ`H)tao4=&@|N@~h|8CpVqCN0PHGz8%QHt8XhN@f%%{S9L(0 z0>;{`jHD<@S9hd`6C_KTq{clLm=5&c*0qm)d*4GjhlcyiaC}`7$&wuCLs*EXzJ|OZ zT+hA{Z9U&{^V|s5%NX*^dqZekPSewhVE<^WlCYB>i7MzJ{B^BjvG$94);Zu8feNu>u1z7Y3BSpxV3s9+*<0~ zTG~)v?R&oe-H+b?z+3-vwfnZeRG)eK7e?payhz*kU@_Uj(x>U!F2eK-;_NJ{jWY#~ z8-=bsPIi$;+sO3}IyvwrZs7nMxYADW{9=Nq_Q!bSO2k*6&G_tN9X|Td0>Aa8J^Y5= zeZTkPFXC4|_5yzOlP}RI;=>@mmMJhGE2nZnCzT_PLxMbeh2^8>f7k+ zYgK2iGB_flPy3rAY@VIr%w0P;fA3B?k)KuE>9KAGv?IZCd5FXL9_I4{Ir2uPu0v48 zK{3jKIy6Y>BiPUy&_hz+P&*Y$Kdga@8$dvT3kebl`OboF=ds^RLrk#Ms4aNx~R97y*>LIy`p~$w(6Hvcqp*S_Mc93og zO|!l(tl?5&=^>`1d6EmkIUIYqTaE zI+HFhIbF^mSz!7)hBmCzZWK={4-muCuT6ktL98o8&mq<2|0jfTxW6~K^vpBoap$?^ zdLZ0#8r^~l@t!}sf9~yX`^Q(?oqy-ja{I5Pjn{vmI{&7p78~~-T*XelOkIm(7guox zmw5(P6uy*qVLZ3kz_ario;lo*{ec8?Ui7n__cgjM;RIjqwN-AY=p?9O^Xe&?kPM{%RS5ab06v z*9g`{y#A0Z>7Y#jLjtRW>|c866h1ieYDSDUD{Smeuyby+oW#$+?o4s!n{w{;G(dOG z$ZE6S>&+JjSS$|F>iu}u_JCQ<%DJycbq@xvghZjoP-Fcmc`9=vOp+r&)>n45usdH8 zfYck{th`PqDEFsGaQ{J0)Xb!KLKMv)AYDu^kdhpPiXbtNzlBs+3Lv0<&0uGwGG486 z8;$339B?qLu{^Vh`SujuY=WS%K^5U^`h*yg(kB1_5CBO;K~xU4`W=BqPgMn~g4A|M zjbwEq3t0-gJ=WdfD?#e0zjJ}(uNIbhGhWY&0VF}Pu)55v(aC(Jz_X5aU*_|Tk3(rB&VcMX5YQ#`$n0qv zQ}d}9sC<$jiQyUa9KwPAt-=%hs>}SHZhfqG2WhFfJDE&m3&t^}ZbSUXm74hu{=j=6 z{DEt5n6IsDU)K89cU z?DP1*moMWtAKu45|K=P@1y#42mVhERcwA_WvDCX1*KT*gpFi6`l8 zP-WtZA5a;ki{tLHHVp~wvO_D!xR^HzJ1q2m(K75HEYI!CdVNp~VoK6u9T^$2(k`$D z_bh^pwvm;RC9kFANOg~;i^nWi0Sh%_shf%Ix4j-0{`mLfu0QsJn7!d%(F6Q1Ed0K8 zsj;yhkd9F@)V0Y8%I_yCg(lBM7JIhaeGrVkkShj5_N5$U1=`tKc>VZ3h@@$-Mi9f8+9ff8;;>=kMJ5Gym1)c>n+Q@#`CZ^|!xr z=_^0~yI*gA>7(Dk`#Q@rU1V5AGhd2=lAx=DkC&3F)Af=B)Z)@NIl+d|blGarL(yq)6e2*wSzrThloPr{xE%GRiOe4cm3p#;l8)O4Y~?Z-=Ngds9%gycd1R^G0#E`<37_&@+UJmimU!PVv>0H~%2Rp<$ZgxZ(H!r;X#Zdu~X*JS6+} zl70Acj+av`(PK8`xSRVC?h0ul}W7%|QHTIo^ zo2LhqzmnuhF$lXs=y5e}kX1+|k3y2iVg=_HwmeYH%3|r{pv>?SX+7PhRa$Te)7HN)LwLn3GEax5BqDO+4 zeU<|cX@n{TR9&Uh@6&{iCpn%} zn2sxKOh?$zyYY?HX?(+1>cVtfV^T$UA(Mok!8keY9sDTPsADWJUk^Zj5kc3(h%!zg zNhcX6Hqc9bRMUvj#t1v-H}!6O8|UtkGZ%I*Y-4kGit$EW9C$a zwp}b?bUJ~G1ju>@IV>8gn66YI7_bgH4T3s}6p-*!zGb+Wp?;+}qR1+%RJJkoNzAA6 z0kXb+cb?>(hmcgc6*^=&S7l1d%|Fgx)j>ej6xgW`Wn4GaHn)#zo9R+feY69-^-aL* z?*%TLaW)PP4$v&-fZFEzSw(G})b|o>htLH%an+M@R{J|4TO8;KP>{Lml&Q~%xrZ~gv%5H`+VOOtb$oO=LGy@O^XgF88k zq>vluP;cFhFxi!}o}sFzsH-ums!|vg1_7w*j3}o|IdKRK9YT~j85S1wNAlgWrj;DaG&-F+?ZlUJ zZF$tztUKc%=VeD8Wq5-0B&Dm2PFC9tte#98Qng8Xu1E?pNXo!wxE)Cb6lysU5kiHi zjf$OhvrIaTFEKxC3R=~REFRVp>heZbnSiLi2NRSb*;%1JIlzv_j#Mdts!#ozPv`<9 zLOs}*aeUAjddzEx1xD7b2oB`DuWLLXeF7JsehL~~RBF51(&^Ts)h&vKP*=AJC6p(2 zp5;JeW;kt_b{gw+sZc)Vam3*Or`Ivi9j?=dxak9KRL8*QaNu*?7}5^ajq1t_#|NC| zG2OHQr_%%dW}*3C4b0=VbgOZ7T&ooY?)Mele1_{Qb3*h4%@55(gov#110#|Ke;7aK zQ}c?k=OYQShvj@b80x-R8D?m$ALoWj^ITAcMJMGd8amMz^seHV^-k;-=z(wx=|4b`v*&eY6P%1^j_r4`2tDT#jXE9!~6hYDKv)& zt-@AeIz+?*b!brO-7-U~;&KokDLE-}NzxfoGnhxz@i*{f6{>!}bdO;f*s_g**00E)* zleF6EC+S!;Oh{B)unquXE~QrO5=Ep8*Xis0g*Fe1MN_WW8`W7-aHnzTGzKo$Xd~8iEta|k z%93xr$bK9w!=U6KtDrMq0oG-J%MGj~%X|$;ngd-yktZEI7pO369;pfFbs*sNNT@_; z>sVNMd+$&SFi7l1Djj#16^V>h=(F>X1`MoiN*(VCSxFoWJ)BE$fJa$Q+uY$s{RfQ{QCg;BB)a7roeT|tJ#^SMTd6YA}un+3bq8*JrOL< z#zx4-tLP^Evie3{#-&gIAtbpp0CcUs*7GD@ck zRG6ewr|aTej5$j=N^z1%l9_a~kgdZCL;dFOxNSE=Qil$>QT{-CnZVxz;oFgAURN=MuN3$AsB6t~ zLs&-A_&d|_b(FS4a|Hn&+pIWZDD$Y*_?C?$GP3$K6G+H#8Os>tIqmo>Vc<8bVCpj1ECK@l2yqVo(vfxB^qry8nwfaCdhK`aS}1v zn&>_640p?kUwGX)oV#}iTW2R2Zw7GOAyrH1yFij2oi`-voBmWT$pIjxjSPtmOxrYA z94@h(H-J7dMO5mSD%H{=u0Wi4q;L|UKy}z!QM3>N`CSCZL6AMfilqA7m&?_!tsTlY ztKH^_AVL8$kqeZiGSCwwG{z{|RI2|l9QRjGx$(DDv3jMCsZ+XPXHlTeZ;_D{k_2^2 zFm8d7ZYt8Vmoa2@BsVW1D;z(Yze;dGkp%bVYDzQc8x_WObR?$rW1bq>7n ze9xX8Cu=8plAsj%a+=8HL#mA$n=)2~Er@O?C?Dmk3pNvEZ%r_ggC#3 zrT>{)B1pXv&l#~I>_M{h0b2m7JpzRsf;1r{4*tb@Kpz)V`DB$(Oz=rMX=BjJYosVI zJ5H!)5gTX5ID5|y?t1-s-1lAg!oO7C*p?pSpq97guhuPdgQR*H6OyRDW-M)xBcj_U zM72FC%z9tl$&ojDebMQ34!sPnq+?`2lmn%Df+P?kNTNIn-M5CFs&ZCjA}fy+B?bh^ z2{NLD`-Nzh8B1-5%GJ6~!VtaWt4vOi6VC-CRTe@}ngE14g4!^lk}L)>2L$yu^kny! zSQPC)nSKBEy9MdglS77wrGRoirZNV!uG|SBtB?He{HL+TSUekY*0VMvELx!LP}j9| zayypVxK%ikBjIEQNx}eziwi6+FW4f8hK96LPYuYP;G+!*dL?7m08sUdr3l%5X^R9&hAKB+Q zy$&tCqojsjbH1NPo!64!@%1U#{ne>28_jWFx4Z8`C7q%M3@kE8BB+m!kWuCfZt{>U z{3Jp4fF2$pf@(MPcz%?%t@)~{qS;T3s#^0XmO89sqnosr={bh@l8W5<++sZtZZUmc zQ|i3uJ?}|>@Bj0+KKbYW?RWp>b35C=GOD=rB5h=NQH_vTBjtdqo`7<4es5lD!PI%F zg%sfbX-Y8wGB8OFE6EWhFFvKIk;`(1WdVkSiVI-*A~xf7{J@*f;``rn7e>uhEUsR` zMBC}mRJxKKXh8vWPV zkxyI1U1H-dU9G=v6K7txgN+L_jJH+a3`nYDsrDSU^HMGcBFG?R=^)#MHp#iG-k`dI zYKp>0Hw{%6LA@vMt}g*KElQ67BC5Pt;A?&x7s!#elGiaH#|lA?j%0B{0RUx910oFQ zyFhg%#XIGtHe#WA4)k7rt_?%8Oh_Gp(gV@GQXqC@W-utLgh}yKuC}EVnh50S1i6}` zOOsNZ`l1B+b!I zSNNKsFub`l&7m4tHhWGptAw1pc?Ov0W-yP-1dpXU)9q42;c$S{hSP4dp<2qvx*1N8 zQaVThfflNM%i1KZL&|F|bYf3X_Y|0yBuMq08=Wq$xFa+VGmNp*ScwV+lx#Yr^EXH;(pj?b41EOfilsSZ?{L(;s< zKxPDEg`hAeAc+_xkAmk@5RKxKMw0GCkFxQWK0*5Q0D@$S~C#tL3H>e7_opQi{B0h z5kOQ;q#^*xf+*dszr%_ZtRHmZOv?`KVS_9O%Pt|x=*CqU1xQlXaywbKE?Y}AlPHE0 zdPvH62lXdQJweud06_5pvc*Ry@>VwiNmew=xS^D)KnA@0Nh0;2>JCCw|77(~R$EI) zYAh*zDb6gj4e6?B0Fp&AOJ@Y+B445Tl}8dmAc?3vk}8^|YsT@DO4?(TB)boDp+R*P zK7|PZy_Qftgqj8fwYxAVsyPq@CrLUu0kWrA{(ymjd5EE}ca=QN=QP>NS+Tq!0o3|Sh$^r%xzxp#jbMeRi z)W7;S-+2E6|CJWc9+2{Erjxmxca~?7JpdWCDIb9`An9~O5h=4AWEcWkP-cKiyB}>> zk``j8)1Y35V%!|y^&0_y^lkUx?GKzqt@UgK2vR=P^4btB|{=pm`^SN>Rs0R!jh^gwq=o|HL^ z4@r>6IHE&yDN8fu4duyWKGO{MBhBf9Q>8@y^$6VF&wI?q5agAInjzRWfZLV+b;jR52oo9#p5D z4gtj^PYx$jX9c0tb$Tmsno;jm*g8MM*?V_z?sdDu8SLD>gPrqRn9Rng#}%X?S~<9- z?tEKaqj%ERr+i6+ACe$@IBk$b(1wQEorEMkn_Pa>w>X?*DTm%^Q)5F2J!D^&q*5rd z+@`Ds`1Gr$iPqEYok%K8MBM<8GD7M@`D?Tdu(TtecW4x92#}O9)B`o0ZlSym8cx+oIR~`|LH_|h=}=>rQ!9tiA!%$o-2`M*8qzG<%~2}R z7fF4e{8+yZuIHFe5+vciAqhWZAD)goJ%GG156L2L;CnS8*_R=Cf}K2>b|%@=NS1oY zK3vap{9A$KOSXeg9HU`*Bq1w+{z)*+arxiUmZMM( zGU_#eE+CMb4vn07GjCvL9wj3PB>T2VSziYlZoirZWfiDFpoE5K-hcpwLdB@|5~(5D zh2+bL+mW|Ap?BIqNHH)6M4dhcxXz(GB&i3=BYO;+QzI#_LsZMv$AzlCtph?8l`<`Y zxf%5J|vIoBY7l+Ps=OkH-smcl%V>;fckPk&eWI? z*?qePgeVq`4MWLS@$%71DE;q*ovrK2>MuYt6iHD3N@f-`lT|hsbjYwX)-o!awBoR> zhsR9%lT=BU%}4SrHi>K?P;!V?o7z@aX`MEQuG@u%5KOSPJ)F~HeJrIGj6MgFImA%t zr}IjCR=QCeh-2>K7)g+VHt02hT3S$o5~|!pUOMKy6T6jqAlypIe5dI0AN}-CUwzm2 zz4gERo;SVu|K6p=!;AS|cH2W;$w082?zF~>5s;74QUbK5stKY?TB~k zbpD4P*uCdoW9K2lcp74|JLZ23E3niC%_NMQ+aX>%E+{tt^HQ14 zGm=KKgxa_|%!!)UMpk$Xkzr(Nt*Crb9h#MJd5?WIT}WElt(>?|=1VzF^JFa`^BF37 z>XVf=63Tq1xac&>BdY2s(W5VtrBBI|p&3ZgRGccM0E1r6v#e0^6Dk6l(k3IZTE@xr zVoAK_ziz(<_7>~*)BCp9_&x7=Px}*p;t&7#Z~vY*{hznCXJ3gG+bD;gwQ`a@l3JK# zI7rGWXqfUTC_m~neQqO_Kkxdkx8IEm(+=(7C7nhO;B~&#uAjc@&06?Eg8mB$IVP}b zfl-T~D}s8Xk`tfl3VjD>?l~juV#inNa_aTw2w@tNjyKQ%01yC4L_t)MtAv(~9C<54 z*TX;Jks2}tk}2bo1UqsBiYB#{cD(T945q9kd&uj@3`iY0^A3x>IhOkibb6<4I@Mk# z@ruXgY(SwWTehGq7i(xXv+hg#c>TDnGbGVGA< zp!zgl#iewG0hE!_iLNSj~68g&WK0QwMo3c)PhB8V0We2_v zGdjOGawiHjcQZ=8y>3KOPbIACkzSUerB&vb%Nw2Y2RNNABf%q#B3}fzNr7ZbzT{Y@ z_OfUs%%}WZ<{_2e*F_TK0F-zD5sqktA5|kA9qXa4!2nZ8@r7eJk0eMs`xJUpvJz>yOwW zdXa0TA;>WVIe2>Y!3(ca7ezUnfS~2;A6kvlAzruB;)lNHJl_81bEv{0uJ65srB2__ z0=8jEavWJ6EXlMK`Eur6ty;zzVUjW1nPKa!-n-wus}uQI?A*1BjoleWvl@`I_mAWJ zYjyw1y^}1|L43S|JG$F!*T*C7O@fFJ`WOI6|#l%b%UiSl$>ZVne% z9vq_834M4C5XDpEX;af1RtqS_L9#@g?>bx;X`19IC#%moS;+FkH#>7V^5wEACv}NK zO6Cia<60sDTvi43vCN@9$3+4;=`r-!xE{j}K_esm(h*F@>T!I)id2GucB8N`=xfMR^yp)n6GQ0ufPv=lG|V&5 z9RK~`W^&f)j^C^f=NYH-$-bXY)7R4pny)!)TJ|Vu)5MetM5iLFkH^PYC6SevRNge} zFu-y;O*GS;pFkurmo&0A#Dc=CndeQ5|GAocI-oVn7>YiggCvKljzjM_ODahMwC+mc z=u6cc#T_AEE`warBr715wMnjMDN9?Igw!FTbh2ZIp5&Z*dOglIt}1!w^ZV?9e-Xu3 zmgrxE=sPlBZ4bQTO|SpxJKpj3|5H`*1B{r<(`ivBT@yDTsW^2lp0vQ^2DD;pIr|zd zF6ce>yZ+Dx{P5e)VNxC7+WtkI3^SH0;}c>vic-PLu!Vn_6(@Qxzdgpr`7QXJxPRbw z=5;&R*0uU%cZx6z$aO}i)41Fp=G_8`fZti$SlP&!K%H(QD^zw+GH7;QlvB6wc9^w+ z82G!UEo}(OG^!lcya4|j?QXur^~+b~%vFz$DwgY$GhL|-OSbx1{0jB4%-nW>oVAj$ zGk4r&dVPA?ovzoHhfC~TKfs|j3!SWGV1*E*v%-o|#R)rofa+fW3CLYmT=h|n7QyWZ z%9k}3Jqdlj=_-M!ygF8(u1iN6kF?mAaXi50RI;$tm@UkvtrV0SpuLNvqR4?ZM^XJ$ z3Xhl-32We5Igw9% zNS3;#HohIG7p1%UkP}d;jr7TC6C^u9-zz9M@d+hWWFwa{ zNukpvLtiBI-G9hF?)-v2sWss~(3+AyRi)cF@lg$l3H0nWkN@?5{m_s8kstnx)9K_Fmdm-8RELepREwG*B7zp% zQkE#m$u9S950d z^U`HvE&i(Xli!aY9?r2iSio29U9q~QWt!4_Ig7{bGf0+cE^Tz5M6H${scSUXJ5Yy+ zFs{3>x$}kge(d4Sb4&I>Z(Xd~9sGg!z<>L<|MoY&_Xpqpf7{xe{L^s^2m5>1q*H(7ty<3Idn{E*(A?`|~cIM)fgM!ma@$=Ng5KEI2zcW>j|T{}2` z_gTfyVE4ifw$9Bk(bDND@R+X>Pm~@3Qd64(X+CeU*l*;}TgVO}x00iBPzMf146BD^iDhY5pkx+E zeJLlPP@xLor>8mw5UC)lzee2wwyOqP+6dLDK@2UXo0U)_YJ&tes-Y_foM73xDw1)7 zXI)3f)$KB-xj;>TktM@$akAGcNn%#iFns_i;wnKBBuk29iIb~P74>6WvX8z<9>s?U z$x;@9Fi4g(`Rc+!PHCU!7Cb)2knyrm;|l{v_2ATFSpzAF*C2a+veW7qhhQAokV*CH zQL;obA-d}_3P}XTUC!+lDT^r;tj0x~I@62})EEWHC9(#~G>2}Zr#>VjONOSqPU1YGRg@q7>`FlMI5AddA(`B_OYk5bH$zKR_uXrD=PJ!tkci`7yrdmKl#W0*k9S6 zO@DD?HkKVoNb*=+@le_=cYBzH1AOmy?c)32tdsU+DLXO8WMhQw3umx%;haw2a^81s zN?8JpOFH2oA5oHK$0M^kvZz9N0 zL;-q&jEDl!02_qDFB(Z1gaGG9Z3Jvk{0CiHz0YozOZcCVvFl6PcnvVcat7Qe9aOg@ z!?2vCPCCoO4$H#^?k3sTo+GHu;(}0&YnHXLz*hm1AXzpQRHKYYh}O#>DPNxuwegY7 z6JL~jmqybqz0$Y`^_gufLI?l;0P0m5+2sjH9}TR!gr(2iRuNzEbDD=_;WzzTp)js} zncv9>stt~PYmnj6k_5@4xFckrc6=!7&@hc;DO1`P2)EZaUev^_8MMa_hzi?cBRV8a@-Uqo zY~FR@!FRv=-EKPWJhxsC^fTgLyk2)|_%?drzx>bs=Hq|pt#A3ij${5Gsw(X1<{1nL zqpCybuHoEv(tGm@c>VbZw0lUYMLnHhva^Zt))|dbO%mpba)wetC-Q{yqc=J6#X*b3 zq1xI2Oc7D$C~_pu8qhavwS=4ieR@bp5-eLxCq5*P(rzH+( zdJcz^^LU9fTN6x0QXhUrp!NrlLtqZP6>o;Cvq=VN-AVJIjc z$HOWQC3G7GBxD#v`gB-~w)ApX#OaG%DdD)PENxuT`r4H7`}|msn~Att-a4IJ({U|6 z$V`eD3d+a*THp>9C990{9U3B)zJtahb7*BiqI1-*isalUN`IQK(6UCd$Dm(QUSA4G zrHtkE6!T=w5yqa@$(grpi!!ezK6$(p_T2uqmZIid^fRB)iLZxdp7c!ud7>al!ebY} zqEP^p&PVZ*77z58Pl4K!)aDFiLVHtG(yvxL26F>d6?M}Tfstl#T|ZxMZa(aCcW`U= zK)5y4x}(+K|NDRUnLqU>|MY*fwK@HH*#p@DIbxlzM|F#Lyyb5E;P>2(^IIL7I%XJ>$&*I_=s3tzps#IJwhoA}r_p2Q1_gjW5GKz*`K z^9k+`>(I$)wvywx0X;*g=nX&4KaGLWbjKZ@hUo)_Jf|He>F5nX9_N|YIL&C$|;LiZ2dd=d|#ML;V91Nc|HL5n)b;smW~pW3K6QYwoZa>hZ6+VEnh3`bTZX_;rr zlo_JA`wNd`k(7ME71(&%`8IOoiv#JY(|PU^LJmhDDSgE*M=2sGCyCG-97!o38#grw zolf}k`Lg_SeZIfILYst6@+hn3|FKKOkxu=U#Bc>T`Z|XI01yC4L_t((<@cWm@KfYS z|I#>zBC03~6wjpM8Cmt4<}jA_qezI%aJmi=qo{gDQ?!$9T(7qA^uZXPd2%1W_PNLL zzRx^@&pdufv<^)@g5@wvMwg`*fCRNUsC!*suUmo`l=ZDXZbJ{Mi&@x7f@FygUp-GR z)A5169+G_->$XmRrMPANUSYXIS;t=)?iT|uYa8^Cz3z&Z&mi|G)BvWgWT~I>%h2V% z{|0`|W~fLhFwjaUqUHE{+&qP*Ij^NrWV(Q9N%Axm3(aZM4bt^w*ohCw;w6coO)Ay- zUVjdI4DCvi)fJtj0*)S)^%BjnT+O4LkFstQk@$YPt3HzO20bb?uS6C3Wqz(`QFF7_ zCbVMEoF}YftOLf=Njurx{K%j9_y7ItxbxhyJyM)I&=+DT_33N%NeJ5XL|M+DO^d|BAPL4AsDyfgX1syRaU0(_sPWN9 zuHt>4ejFcu=mmWJTEfLt;gW758ae(>g%g1;9M{{|jo!hpYPEjW-N_+;h&ycz=^-TP z=qrMx`j|K6alJRobe+b{@?R~T?CU34bRqljM&(~^eK*tF8YH10gNbPveH+|v5p3d;R~>+t{mPyf>we)5Oj_BY=7*1P}qpZ$rqEqAvM zk| zDqRK9RToNKk|_oHjRt_Ew2wH_mzi>OL?t;PM>O*!M=n`7PEqZQDWEt6wKuO|-CF5rr(>);7?KETVPr0$(Dsj2FPV>HCED(S=tUz zzd}yL$P7|XouqW8^cJ9TC~>H@iX_3J3KUPK3s}MV?j0*j63Gz|B#{e_<(Dk!0m0*7 znF&ObafDV(N$E~sURw&Z&a(QA@TvW%zcN)Y18OJs@EYYW|!7DQ^+|Lsaf$L zo=SDbR&A1wDUQ%qM8gHhLrlj3qwy4n_2%R4`FkEw!8@N@xCg>5tlJ&c)_UMafApRE z|H_Yj@0ss^$31a-*6F(W07;9{EYz2lxW4_7G0_6Y3SQ*33`Yz>3pB#U1X{pBi`*yB z2)a-K*~iH~G_jSl?24<*ASncwK7x&c`AFwUekVhtlc|6B*3KKS1(+diqCl1Jfj}58 zZ;(Wg5hSI*WVAZXEEg>ft}k@O-e5Uz(aO@Yi*okXA%b%FwoL^xs5te=uZv>irK*sM z9j$ixq zqxisQAI7I1y@)6Ffa}#J7SnAs(;1dJ#qaBV>Avo37iwEqsa=*MN9p!TF^ykxaD;(= zM7T-Be5Wz6k}Of@mx#WGxWm&>cIgkPRzj!OahiWUz0UWo;$*MaX+Fz4OXhvpg5335_$T26PXaGh4@E1p7^il!AAiaT^Y1J=`ylRcBmbdqBk9?^u=SgTY0 z=a*aU1T>wIlrB7dF^f0>%NYnmJP!ngq*nQ=EZIj$g>vG`)+Y!z_@rYgB&D6oXg&C< zKiDoP+Z0mrwJuW>4-*(t*?`uwt;EgUFQ1#9x%2)UxAt?B8E|VW{hHG5RW@d8YyYj^ z`?kA(>i)a8tE`jb;&5LFVl6sSjx9?{dKt)E@@^E$?TR18$=UJAJ<55p!0FSFt45GK zPZ+ItIaL$~KpClkpmGtiSZEhZIp9Ma92}G|$$|OA5X50n#^*E&-KP=|zH#W928#p9 z*`G_!91C6B`~9P3^@dJbm|y5Hg^}_YIgP*+L2U?Pm!XcrNDg%@k6#-|I*n&|BSQdL z*XkK_B%MTJ)nYQOF&+i@1Pq-zZS1P)7?a%{M4h@XwR%tf#6I5t`4{lMFI~rPeSHs4 zzBI?);T$b9l8mACQZ98aNYnrG{doRvXs&hB@cF z9#na1&h>K0HC-jh^}0`uMsv@9`E8*w?==5R6U&KDsY^QshtN7Yk*NfwcfqQjVO=Wg zsGn#e`kGB&hl;~kr;#jW$v(`?DxC_=b3TBYtwunWPD^6ZjYgmR;?~vy?mV}24}@D< zyF1F29?1DUVH&Rg@9*Ai-xSj!lmEo1cGiAJPI^%SWFMkDE6b?mNcMD+$~mShA;Xj$ zXvwkwdPwsrvif*|lLUwKDs*5w5|v|zopkl@|1IgE9Qj<1csXAp={+}PtjtT=5N6dm z=ztQBL{9TtlQbG>u-kKY_mqN8Z1n%KiSS*EjW2(z7d0p-vshYcYM=?Inj zq9PDuEU*l9p{`92!LURXJ4ByI)Sjr*co=UXPj_)JI)kU13BGV~jt@WhEPm;e-@yAn z`xw6X%mP=ii_zwJOm=oq&2(j76P<4~WW3utVn1hGJecD^A<1GSvs?jWK2;g~^iJZq zUz|4G=v3~sF*f~XAPMp$%uCY24dMC>XPypCyBW-D+$?{cPJZk9Ez20nkt}T)(nGk; zb3`M`TykE4(#}izG8B~jq&YCe9cp`ZL@(vk-vez(+o7*xpgSzDLsVDRMh-$|0D`x^ zKyl{Um@d0kb0TTPv#Tudx^aZ*nCA34NFKGHE3myoN0n=5uuh{*6Oe=qO^>Cq+XfAmq#TRsn&w%{mAwB*#H=_>z<^thyJ{5`b=+C7kzbRa3niWSIXXA$&~t2ZSm$55b;or%Xe ziO&!TB#{sboGwsX#8UD|u7%P`Z6*M$HXWjO1-2cC0k!6` z<;6DXrYqvG1H$IkR~~rhn;ycQ=a%n*aLa4>9j@S`kA8T9?$ZBZR$Y5T-M)zC+C`+r z0kYovwz4=Riy_fEM3B>PND^MyWFNwh7IF#7st3vfUeSh0khRDStUXQ}1BsN(Rl>2n zOla(k4;BiS=o(poj-ZT9#j1qlP=gBwSWn0}+G+c0-M?C&AGYwR+<%X~voTSAjJ4WH z6$&7uLK&b`g7QVm7}|l$wPm|Q#k2Y(m3E0yZS{}*axtVbU`kWg$~1>0;aiT8P9fus zE!5lR(M>Pl1v&E1J#&Z;e(7oa(x<HST}pCM}lIv&~UX zT?t(Q1UdFBxk(QFP~&l>N!XJrEeO)c=yg>{wMS4%Wc9tn0YiEq4{7JG$DK9?+7OyX zvedaAC+TDXc^of<8+?&0GRQu>0m})}di_Jjjc7yO%|e$kPUjD_^)RVNGkZ?c)!mvA z5ORTY3SB&%c~_n4GA`@Jh?kI=6xcG)?-1|2-!ok?5^SIoH|NJ^hjK@>hG21!GcVK|i zhE3W4r(X^)-<~7eAqqBZh7FKG9vcliVv=Q4lALa*D{`N#8#(hqrZI<(I;lJfRKWJbXtz>28LDo3v^uZkDDx(~o$|+{!BVS$8*_Kl@ zD`MyD6GxKnUG7vo>qI2TICZMltj?DbaR0%WE*Kb!xAkHxqxvxvfmwmF>UJ0q_KD4FR9U7Y>z&hOsC#y>8 z4+BU1eZEy2&`C|-ELdlCIdWFtE!yKyKtudo=IrYUb_P73YT&!i^U<@ z)RgWac@&Za$&&ULc`{D2 z@JZcQfR&tiEd-ev$S|4a(6CXop>5>6eHuT|`{kv75ih5gQvl`g0xOUNR6ZqH6@?^0 zh0vou(f9BPutfDgQng@8!F7Z23dx4JgrdAh@>&`J5n2R)2k|= z(g~d;6hW)^=vURYYw|Sn4ETR(n`J>sLZ8y7BFmBZ8-1ug!_gDO$D>qVvY}i#9hdRAIvivSVd0aicH)NVhWo|n)IWy# zoOYh!xM_}C?f}m&F9*sLmQTh+r$H_kIsjvQf(7`1f&srOrt_bR<2x|3Z?R%3aB1&JxQRc?> zlN)F6{W|VEw?z+x+d|9lP$ge{@i*_CRQvz>q&gh8*RP|J=VI3(wGCp7u%YR@MvD+h z#-b~L;-`^ydL3|_2Q6hm_ioe{-KR_gF@QWa8YB_5TaOH|=w{Jkp;P(c{#@Y_S=Y*x zVPdE3WkzmQR%tu)2svoQ{fA(D65qdmfW2$`n5(>0lJBLANRTm!r_ra?PUvx4ZG=>V z+T99jFO>>92}}1bC?P$A6wG>m8T3kGB!#BB2CtbgQvRft6R&D8j(}={1!wsBwFqcKuK4r^#r(ypWc*)`}}tx$7pRH?CmbkFMh zB$`;E1_0_)gXD>^Dm184h(&eueRdqr65xH3Lqlw(Jb6+MNd&bSYY)k_R3Ekcb@Wt! z2YrRoR_7~m^l>_?F$USoOj&Ig+&@5HHZiDoDH%qeZ$9-NlD@LaW~!~~=PX_WiYEs3 zW9a)ayKIl7xS`9Xay8z_@p_yJw1k{zWbqlDLb@;2*9pB0>z1XT^>q0zXE0W!zWH)l z{ts$N`OpT?yd>dAY)C01Cy#}ZO$I5s8s8+gtk7^=Q%mats+~s|Xq21*0d#GU#8XgG zpQIl2W)srTNE&?izp_-0=$h6mR8oLWoAhyv@cOAV@42y&MrY1_bn`9uJZ=6vxb=G= z-1;hhXKI-9hpTq+{J$3Rl^+bv9@3)GLIX&ag$|jkL>q;KVS!@x4Cx_Ukx%eB&kLPo zk!eFh_HnWg!z!dKj2C_*9hQenU6(hoQ%{Qy@|g%-D4+~rkomDAHz1QSoW%F{53#p* zfI~UA<+7D=OHzw!hNfT^lzOEP$)YDYgl@ZvYgdqT3Wf|*)Xjw*_e>7hj@6FGj>7eUl= z5{9-XN+m=!JVYW$^$>w70MTH8(~L5Z%0E=bmE7X8Jiwuhe5;I{WHe>rlN<`xpBwgr zgo?-G1@*6@M<3I)g*!^WQiSADHc5~@EaR1MROt5@c_rD-vx6y4y7WgXbF?^Ec|Pa6 z+N>}0C@3Fsm9Fp%Dsh!HulvFgOa)0}5m2uTNJ9(GA?PxTdD6fgBFblP&=?IdA6 z%ON#&fMh>@_V7ikmzO7d zo}dXz_HmLJ(hiMrtqrc$&`6t-TF6>Qk}Unms8Fy)TCxdBqB~PRS#kdPiYW;>-c%J-JTuzE(ff z)%twCK)GJmX+G(M0T3k7KS74vaZkq~zRHmmwcV0$#5%->=!y7_QpQr+)XAliw1zpz%Tv{&hf==((WiVUa z3?yp&RDKwTM5z60C=?EH^`Y|($Vn)xd=jX$meKQ@XeZV4IVBmEU4#bMxeP#H&#?Y& z+ACp=*L4_cq_wCTj&<}|b{Zt5bvy~v1gJlua#ZNo5m~yB1WCAso+?SAveSa&34~9^ z*2Qr9yDWaSIU9}(+C#zciM+E62~^ieu6QLQ7X%~*xLy@SmL5uo8;To!o3bdS9)}hZ zL>IhK@T-_ZZFajX&7`APj>dB(#H1sTJc{2m!UU{=NYXYTFc(BsAEh_^h;N`%`Rhok z%55VFlIA`No$y9|p-nc_(H@~zb9K5gL)_U!b>YloyRU!CXUum8w?_|XTHGFrey3@< z+07djQ|eRcNg45Qvgs6%wl)Ax-(Bu^~d8*v55Oyss0ANfRhL6a&c zW~W0(_&Pnwf%`OWhn|))DstvEYK-K_$3iWgj3^H#A1lY*!YA_U*Y>cOTX&g4=^m7C z=jf2Ct+WR+nl_ND&<0x3@*!2f@=P-dQC~-KRaQy=i8eQ58Mjf02r{yWQ{<7H^Ykp* z*?C;%7QVSheCmm7_|R9L!TZ1XB!2s`YxvTO4KC(@>ngVxkI?93(&)s}$bsh=AQdGK z2r;3K8C4)^$&b=2fXXHqsr(EDs2CIqO1QX0qpP)9=$3ePvB2dFwA%P|tYklxkRk4% z5GS8Z$b4tGf818X97E{1VLrog<7N44edj#Ge7|2<&VW~vWu8~cAM&#LRnr5c8ZM%H zq(Dix&vJ}T?J*n|Tj`_q{wpVO^=ApiYng`m4WF*NX}vZ|s}ixqxj8lUc~3+$RBGbxH8_tTn)-pABT)2y_VXXG`gbC)f8bg z1w*HPX;Fm*>bgT!b#R|&jL@-ynj|FKaq(q@K$U@b)ELQZ<VV(h&Kg3@a zN^TY`>&B42F#6o}@}_kD&4(sH5*%NLY32G_c`nx}v~x8*X&xG;Ckg7Lf6-gII85q~ zq`q{2rA(M6HuZ-X)PH^4eFJ3ks=Q@o@wOQ-R2^Eat2X7Mu*(TX5}yGQ>O#qdq5&ZU zWYM%luxvWa^}5I9v-%)tK9JgGXBPfSaZLdXpz6iyNg&cH?pI$ZDN_clGT}nU$kNY% zl}}lvgV;dQh9{q2HB^vdXnUXDysdRG{~6EbUN4OHtkF=oqy9CKeA%G z6Wk^}aJ&+46NSGM)V%oYBR|OHl|Re&09jYnl=TTZ_4(A8V+F`ikVJ0;6uiQRWLePb zEF?>kB%)5Ekup>nfbJ*#KDu2ty5iRrxlY&Zyg@FGJR^3HBM%CrAQ{rD(Y5+~J}-Zw zb$@?f;USjGC1gQ**}AH>5hN)i=So!1@ClMJ#1-b8U(O@?@F7{+VtGYx>0ZnDgvkt; zZ6nQgakbjUV+Ro*d;BVX^$U;UXFvHh{Nm>x#)ltz7GJu!hi9bPm8!=6XrvQ)jiC4B zqls=CMm78k_;IK(Qh!tdGL)^_5Wpx6DneNZQHe@Mn}i?g^ln2MWO&611j72cyy=}z z_6wbOE_Dr_U#OpCgp&+^62A?s+l*7sjcBJ>R{cb{pPhHQjMIkmZU*_Cn`Ir*En~nD z@g@=Toz5bq<7wh5-Kh^&(GnwjUh8Fanh!?lD`i=};c|wZzxf6kg-?lr$DwCj2_?&+ zHxLeu_41ZO5<^I`%r!f6i7fP_NESU4Xtj}?H%l~KTO6Us+z?ACSLT8elmbUkS&>eB zEb9c}RDx7xYc%U^6cUr_$^wsII(eM7PGp~U{51UTKdX`c7b{7(U4DO#7&3RZJ~?{e z+!rnH4sM?w2)B=_-x-=d{P5oyKmNp*{#S~ULe{C!HoRY;HWcoa zEMw(xzq}Azr zd9c8|{L+1a<>3OY-y_P&BL^9nD92WTh&P7o7F0P-0ywFEh8?4te-!$kht+bD{v~cV z+Q6dT!i(Jm4_#^TvBxgsgI{|Vzxvf@@csv%$H$&Iz}F9S?LFSaa(f5e=0xw))$Y*( zxo&W%6Uw}yZW%h%a=IIOZ@!~b`xf+mUIx?G?YfB2yYQ$DfL?dV`TI(eAp<&j2RUqm zluV&Fn95>C7Efo4BCe^AFCHFZUnjy&jcFy{Co>y#NdiEi0OfHXtfLTzuT|*$J{n5k zv}`ksLijemT*i2%B9?EQ=JAy19WUUx;k1z%0qumcHadu&!&JZYAPJJtmp&}eq*2(n zhZLXYw(B$!pim!WC^ zm4zOc^^H@jo+qSb2IS~cOK9UR zCrii|!3M3-d-VBXi-Y|o4&~Gra{k4@tt?bR7H`sCKr(IgG23qOQA>&(wB;hMp16_jQ_&UCYh@qCFDaF1OprPN<%8vR3G%I8;8XpR*ixkA$p|5!5kmOQ$}y+`e3Zoj)K=Lxkl@ zR4H$sPG#+wOF8SMwCl<0tIVLf5*j;nEe3a4DJ#0mx{O1Tmag%W0sx}Y3SRX`niXD6 zp%NZ_QDdZp=NCzMZbA=9!1I%uCrX>kG73_LrZcF}uzq4uj{wx%YL;C&Gz7g43Ziy( zKs%d$bA0xm_wwEE?r`V1t$HBbR@%PCmDOV7xs9uT`ku}9jn#Y~`QQ*aEoDSxW)cx( zL~5OYs|2mw3>%cBMQCJdC}p51XOp$K2M3doK@w3;unsjME69JM(=~c4$89Ix$~kAf zM&?3%OI<%NN2r?$u@!VYO>%Y1j@Z}9{JJ(6SA{*jJ3LT2OCIECDfx^QARWL6RjO!& zPoYVs7J5Pm@PD>ThEMzzvUJL#XZS>Ge(Rp70{W1|D2@t(>I$kSjK*kH&r4l}PhD#9 zD-T`9Kl#jK_`uhn$Jh1}UaV$VZ0vxWg0AF8x?cCGzEYX7bgZ(3w*i&pMQt`>b|0xr z<;1%H?PDnU0a=b-jezT|)OV9=j8Rn~fUr@GF&3jpsGYi!&m$nm7Lge>DqY==wK33* zKp5#3A>xwWSv=nE;}TjRjG>BD9(puNavsIt_Jax}krB%V%A}+*mWm=0UD&Xd1 zX@%175sth643Z$J+#Y0~hUSRooARP+i3s{$;f8Ta_5g@ufXl>H*&!_bDw@+q$N^lH z51uQEL!fwIJp^cOB=D#`rU%g|KG&sTDvmg*u|1!76VNH08IVXop+*u3>=7b5(Of1Y zAS+%%#|4*79g5t64(XC2;dziGveCG73UiT#{*T9`tPvDZbBSt$`+(9jl(vZusD7wE z79^#IkNFIjF`!Dk9w9vpYA#A43?LG`IV?bi*M?EniSj(xJQq9Z+z1Y>zw4_cvsVAu zUX)p(dFeha04)Zw%g`AeQ5)T;MY)(%>Dp}a{vZ8If8!b4d2YKN2)CWauVIBRUHawC zX_)`ytX@Vm3$*h?wKn1Sm#_gri@GfCP$9~pl*K8-=a8iM0n(v|Hmu?(i&uh>8W{y4 zYh#d?ovP{3=`^1Fr_AKwxo|#FoQ<5GPw4&s1#MaW*7`!u-|5^^y3~Na$p{Ln4J1+Y zv+)o{b%ftdGi2C-7&dT*Yz>CTbfgwZ*WO*bltB|@U?688_lGvh0dDM~**uSn-4tJV zb`QVtz?1I?k)sXe8v_~DLKsBSpYHIt>^%n)mIIRJK6iBh( zx?dq~AIO4{QBJw+Gkmy6ej^0Gi^Itl^9LlOvtK@tIi!l=+72`E1cBtbeqqmm7Q z)}1%jvG_($5Zaq=wIwZ%kswqzb6?{uz@Q=6X;5v~CYR{p=hceE2ec zuV)xTwI#A^qd6gpp`krR-hLV}%A4>& z1j!0!KMCu33Mz(Ii=s!)nmWhm>m-u;!@8f~5wG*DghT6YB&fVlXyWNgTc|eVW4MVf zC?vH9!r<`iWL?)#-t-~v(0ye{wB$kkoXQW|pn43C*-##<$75z#mR~9Phhq+%woGHi zdc%O^2|DR?63tMc4@r=M<~^87T;!wAdL5+r7{wET5Rn-`&uY=2$8*FKlCa*?#CE&9 zNzIz4S#-1dCd_l2G@p820}yqSRcZaC+4Q%+`}^McY20~k%N_`~rPi-er5D$)d|zG1 zd$LZc^TnPFPC9C-jiQZ~mY<9#G2{#jo+QW~zEZY*kS73i8uvddYZfhHR z>AV%PPU<=^%JC48gVtdHt*+9Wd5438B@XqT-2eP6>AiW-Q6;8OejH~<-Ww2^R@EDn z&Jfb_IL5p7^(UtmL`ijZ|_8Dw2Yy4Ln7q0f(?uBWrE*qZ>;KrFv7!9HtT#~9D( z<@cAKy`mHOzhJ_2lbRW+Ps!9HcE^E zsMMaW#U*XlE^7mIsQgaKCL)WtV*BETMwM3Z?@=F_e7Q+0&FR zR3NdB4D+N_cn)+{`IbHZgFQMdHXV>A-DD+jn;{z;jh#&Du2kUA*EW6eC`JbpQbS>>Q z=)3{uakD112egb*+Qfk(O4dC=WNO(Sif2`UlGH$ZA$ z0c8$k#kI%_%^Po$i0UV*Z>vd#$?g`gwb@MXx%;2p_iz2XcmC};ZqKK;2Dhi~uPLo_ z{&Zbe%RjX-3ERzLfyH7DJBp)5mfd?s)}l5@EnRK5BXv^L(j4Afh8$2%*z#)G&V1gY z6a1_6w2+C@O}6tXzADwQgAVcpo#=M>^TP&*!oF~L(8#$1tsJ>*p`1W(9ITTKN|qoZ z$xcXsh*>HbopfqLg+}_NP$TFx8g(KshAS;mwR4QS1!~28g14+B&((0GQ~M12aT71* z4LsdU@u{b;<3nG27QghFhw%O{K7vm_bs3M(fh+a4uHVm~ttaT@C>IQ9WLIuqm{qq#;}}%E z23DaJrt*XIB9h7v$k};85{^;YEP9(%z96BKQ_qTLRo1sX!|g@s&j6}#Ny|p*8}pP+ z08~KINgmPtcOEP&l`PX$MzR}kZkwjB%N42IjXtDwUS*JqQ-X{Y%>hY}Jpc(8JQ`HR zv}`Z3qFaWVTEG~9KtL0br9BZD0oenpumln=F1R{x=A)}t!msK&%_HCT_?^2?=0m++ zcPu^M!vAbDY;I(1ZhY?C8^8ZISQe^;-uc{iJ>Yq8+bR88RQQEQpFX=Yjel}hhq{w9 zN0;OiWU95$%Ly+7kpwv)YB|S}4j^R7kUO9_@g^b3dAExO&7zY-m+6ylGX&a_x{9EFS)WP@6g|v*ZTJmC=w|G7J7jdF1gCNCe3^0rALwitaokC@w@GMxw53R8=VP;LVDhdJI6#fFN0rT*Lq+5MwNL*qLNG z@TK&+)U|kCuQ~Q(t<$*pK=>&<>Z2r+?cQ(IzmA*kd?g(en4UC#?rXzDC1KvK$k1F+ zXnImu@>GrUU3N$pU8OH6C#HP!J8uY&_$8y1I~|JA{2p)Tuh%u`^A& zQ>5~7T?I;+$N7DTeom=wWx;gA?R0s^4d=PM<3`C-J*FF`Ib283cxAPn6r=jVY4?fs z^~rqU*CTDKTc1?w&0@ofcU{g9C(SUVAXXj$r=K#Q?zxj0;Lf8`em|H)#>jX+9Tt9R!`wn@N4WzU#t2eCJ>I3wQphn)>H<{e;_9 z@zre035hlW zoeaY=s*Es?4Hk0hOBsod0Y(ZkBvDhUlF|Ze=_?*m^~?BFafHcu6O+j%MtUdSa)!ld z3zxZpCk}`&J-3hF`Nk!D@GH;YeP4J2zxw$n@!1y^c=&q8OVsuH#x`ob=c`8n6_K7~lg8O?7=ZEwefLOEJ0&BjuHMK8^#j#@)lTyON!*!47N3#ym7)A209iby4;b=? zw8MdRv(P&9V*u4j0q7*yNNeFSAB(P?+K{Ac%2bn2!Fi&0f=}d~^|JJ`TdyNTH;nPsIB%r``8m@N$q?6}9NwuJd zWkL^0*kO}gBAt!!#^$>HtCvS8r@878tvW2iA^tGNh719r~36SkT zln5R1kztmNulQJ4B)IQCs6{9J_bQ@{5i{<1Du5uGEq$$4qVxw#I zYxs??J%#su?i={^FFb-zJ$ea`UQf6}UB6dbIIL!9>It&IQH3ZcOthe`t*skWA0S20 zKS7Rjq*MBIHW6=-&IF}5BcLo$<*|Z!k3otr4|=;`fKD+R0|{pEZ+b#ic^3dw=b!O9V%Hq z%2w-hfrj>o=13V2Y7_`05j2JLef%-Mc?OE*8cB870d`q2IXiu89wf=gYD2zZXh&|i ze77nygh}$8H-wJ6Zin843~*WV9EH-`G76t$_(sTA>(^_6#IJ@zN)`=H2=!a{%p;(|J}g^c;MfE^={ox8+C6}B{M0*iJM)<200p)Io%rY14{-hJ zb?jZ+$HAV;`aOF~IdB3ws#AlYj|_B>kqa_%u}i4rfGQb3iZ(!QL*-;*l^2;&OT z^W9i{6P^A?Lcmh8$I}sZ&hB7idxjWeaWIf&%Q8&CeUt!IkBUJc+zQB$CzYi#{QfY= z0Y|;R&T_8a6ogR?iqXX@ou)}3e%@XUDi&nb2%a11m<_%5IUY2BvZOc z56c?fJ7>wzP#{UoY@Pukx}K!)7`;xGTaW;s!doRusI;PJHRmiJDr=cg|9~2MZE&;T zxZyeAI*uXv*^n~gBU$vaOr!5leQV4-9%VBjK1g1s89$XVhE_zzB&=(957~7I_tGpz-)5^lbxNk7>#~wbm8p#$oKSuaOb&Qdm!Ad ziocfho}alq9kY8wY!8s3w(*cNTycM;Pm!cpPmZ*E*4pcX6p1`@9_8{;>D=b_-GsRdWJ5x3~ScayO>MA)URO_`l@;8bk%Q4L1aBzawJ4=@7ZinS}+K3JLmU~23 z_=Y0p2&ATw|NhQ+g$(Gg!-)73!~h;6^jG3IzhK`yqz3y zE63jI9k?Ah6`UvSQhg5$t4T4I2>Xi5ZgLp`6I)4We|ZRrf@j2mjmiaUNluDvVV3NT=FyY%!}N z#-l0f$&MV>F0Q3*JasUY6JO$YA9)EMdhj`X;Gw7Sp>I5m-+t;jeDT^Lo@9mRrNi}5 zV_8qJ9E~tXz(S{68*D^iwlUE=_Yp>;3L!=?RDg^yRFp;#L17QTh7fv4N>(@p^-B;UdfAd66jvaEGGaL5j~L23 z1k6*#h$tXL%md*+F!PMAbzf9_m%4gqRRai8FKg@+mGhP@26B*7r%tj-C-1CyE}^dv|m$XKefM(`=!^4vaJcb8uQtH+xzW0gD+4C}}tsS1=! zN((`?DJ7*~D7y9`#VfBn%)z3YNLh2mY$TyTewCE&3vgc1g$bfI$ ztU~Zw*c_uix2<>Q7k=s7o4)Jk_?|nbbG!zg@ET~tZQq2ds?JqtE~JGV`oSFCqCwLA zW0Ixv37oP#F$Gk)hT|azP!6>RW&D+v1F!7RwcNG7k>r4zdD6A|A<92LlOs=h4-b_! zvaE$qdnDl}sE`ebQz0i5$mot3F$z(egbH%us5p`1oT7>|RP_w3#$dTp-^4-OzzfR} z9=_7ylaF7;Z$0<|-uK1F@EZ?4i;q42BEEWgjwcQiUg8+@*(Soy4uV5D@?1ywSKZ6p z6$8tAYRKF19eILfgSBu}KBAdhKeA9s6Q-jHf$I|AABkbUS3&jinZlA1N5 zYrcD~qMvMV=yVk$Mxv1fHTSC71oiGFxU+R-eEyz)^p=0;XRhJS^P1>^@S14FZQcki z%57bVO`+>o9TgDtax}<6%d((YDqp8-h0A`+AQIVW@lfx+`#9v_Dc*qGWpv99OI^_) z?$5DYv?cA$0c9})`u`l4)l`bmAr7jWT2xq|MiZuJ>W%UX z^~2E_yqITrQUwaboedsBC=EWtxzNm09ZehRP!g8{OFy2rF zQSbie7{yM0a4sG9(KT~KIpAtM!enEH>E;$X8KYJTbyM>?6D-QK!(|GoG8lkdL&^?&CL_uc=*Y%R3(52tneE2pR9J{61t>-AG^tvdR>Y$`5LXpfZS1;S67UT89WK9*{!Bf$Do6 zOFW&Hcu9jhS6?T|!SKjoC2gv*#!W&%vcO`c7W%vlJImx2iqE{ALqeKm_@voGe3E5{ zRnqE@WaM=oiXz=qzVNJtS@KWQ*3((MMgmnYHBWPabu&s{l1?jh871F;d9CX@#Z^Xi zWI>%-(o$DFQCf?vfp?zkbZBUVL8_rbhQlEJE$0{#(0|mgjFQDDo&fiQow)y4%(CrZ zXKn{BP1I*uz|rzk)xC_>x5tnf(v%>N;cT)`4{@kBdYFPEe&`{^Th^BK7F3bjnIzd| zyn!?v50Ybm2?mueI%H^7W;h-64Yd;ifI^aGF4o$}mUXPOH_Fx44BXvno1KmKR&TuU zQT+a&I}&b@9`MlLBC34nsu2-upp&_giAn(hu{U6fmmdasp=TfqT*XmBiZg&RQj|f} z0RwjCt*+`DIdcEg8=vL@sxV;(FaUZ;f)W~JIfK&jFDvIx1SKeq{7z9G517aZ%(jp> zw&ciXxU3DwbITfEzS!X7PafbmA9?}5_LZmbfv-J|-+BCbeEI4jo=Opyb$Z{|NqasT z;}8)^w+Pgxpi(^MfZQy!kC8){GiOd1RTbo9b;UkHaj4P}t0yZyI9K^vRMIiXa0MxE zls}vivUEo&8^jdqQ25L{8`b&lbLT(uw(tI~|M)H6_4@zc>n_~=<%#qni7bao66z?B zEHa=cs*V_ANt0Aw799473~=2V0;oxf2QVl~ASi>RLXJ!0YT*l#m2r~gB3viQ%JkB3vK2aTN000mGNklTxC0b42=^Cu)7=-1nbD0!h2~G^v5j$#dA= z`up$rslTv4$h{NZ{yh+G|0dl2EogEVyS8E0v7wR^K<*c!4H{W%p$0A32>*(kGWH=< zpeP3PWF2?x$d~gu=KBlG=S$d`Lv&e2EqD)e5BYB7m34Gorpl+l@feA!Qaz)HdR!su zq@2e!y72^!;s;fY7j@u$^!gsY{ML`R)G{_x)i=>KxO^RuTf~s0+cCXkO-H@}!%%DO+YB-M>76??{3>S| zLXz<}oSzX+ksCPy1*O@Np}f)=tFKbWsxIs5@`Dq%BR8BjpQ=C(nm~-|clUpgaKUNBJ}$YW z1fqxK8JfH}4N9j0kY0TWl#)R*oDO0zpof(Hfe0FVgHd%WZQX)sy<4v`wl4GpIGm?|5i$e}_eneE8gB*7i zLrFUhrPWbDCEFDO^nv>&C=%sRjUrEJhm+AbpV{1g?7sV6_j&QCaO!KdWZqYLg5rmo10GXD+D_d4ta7O&|~ATKt1hm6;*ljB_%sKl_WWe>J#K+ zatR@tc?O7h)6Y835HBI2IfO-;K{3=W}Sbge>&1e2r zJRK}E_cEow^PRUIIt~fWnnNXA@tICiy&4OW^l8-*MkD?v0|OvFxPFH!m=z~gRtO-X zLW3$};M8NC*5PcY9o`g@$+dQqM9@4PK&t*#eI$zxq-aOTAWc`8A>U0vovW^gABL$P?w^$s`3mQ4`EaOJ97zhPO zY>`6;s$7t`C5Ye}%dY<^Rey@`+2Ej@&LV zvJO?oqMc)Ze;Hi#Otw1;v`fbst`~`m0?6H+Y%= z{u8kNBQwt)UdO)bZ$}Xa+N1^L**SEnK}#VlvCK=EX4j#OUPt&*?@J=&EOUW>L2f6W z8ARtAobKUqGIVKz0Xa!t4$>_`lcvBlV@Nx6+8D|eIT@;(YL!CucPF$`ZmDtiFUOZk zH*&a|iSVX0hbU1It%MW@uJR5Ikzn~5(uLAVa@-FJ%7+w<8HFbKl-|L5uK<-R0K?qW!pv$0k0zp|(^*V&gm%2rx=H37` z2_y?L0fV7}0nLE{NjjxWwH@#&Ix(p&(jfO>PN-PH>xk+zW`Lp%@X6kF1+{@xMXp2h zoN7;0GHT-&C5sBF^;wHn0jR5pdOF+NzWWV-@8BJO=9?nj`2eq}J_xU=*4(CzS)Tcx zm##O}N9LHNjdSPGu){)*&kGm2j+HM%3;{ulm&*>VPT|eiw2F}nI~Jr z4xkJYWC)B=RipCim$Az-d>tbY<$>xA1*Qqe^mu5D}^hl@R4@t2)48l*KZt zDj>uN(?g8Kh>|Q}V1>v4Nr0aSB81SMIeYG*(d$QZl;^?{UH8OvI!*C2bjSUpODF`yoeFrJPv(d&iLNP4?Gsk$KWs9XpEA;un6L{uB<7-6~{TT7#0y=ArDTTL4D+S>M|_bgACBd9sv{w|SK*=d4M%|}b~JWSPB)UJZkI!sLH+Cakk^MSI?c5<7TS<`Q{m;&!gL;;(A`(A&*cZ$ zfd(ZE#|C*2W{Y4WgX7=eVww)l^Yte1k zATO@1yYK%%JKK7)K7S9I=@j$WVv(<-3j3Jk1I)BAX9pdsy#?a6CBpt8@_Yfd1k(s3 zIoU}yhX1E0(`tlitP93rgh@TcR9ESfjdK{yE}$FjVz1f23;R=i?I}C}94@vm{E4^GUVW%-3k1^6ocQP5lMyFOiQLR-GhBx_IHU?z4>vwzfAgQ)qr!ef2{u`6MxvAM~=G11c4Wayzc`Q1KUZ8hoZ{ zaY>u6w5bhOU89wg&&?9hMlR^K1398ag#Y5)Qf;-9D^5v{Gz&>C)D9|1OG6|%a0t2d zcb5|k^BQ(AMTW`^p`EScL+JQ`by_Jer_mL-4yv93S@GQWmz)>80g+-^5WDtLS^5Cp z0IWmVrJQ9Cb(S!jw?~HR5Jn(;%NDB7=I62%0D4G0*U? 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 _aiModel.generativeModel( + model: _remoteConfig.getString(hybridCloudModelKey), + ); + + // ignore: experimental_member_use + TemplateGenerativeModel get _templateGenerativeModel => + // ignore: experimental_member_use + _aiModel.templateGenerativeModel(); + + Future> findStores({ + required List ingredients, + required double latitude, + required double longitude, + required String currentTime, + required String dayOfWeek, + }) async { + try { + // ignore: experimental_member_use + final response = await _templateGenerativeModel.generateContent( + _remoteConfig.getString(findStoresKey), + inputs: { + ingredientsField: ingredients.join(', '), + dayField: dayOfWeek, + timeField: currentTime, + }, + toolConfig: TemplateToolConfig( + retrievalConfig: RetrievalConfig( + latLng: LatLng(latitude: latitude, longitude: longitude), + languageCode: language, + ), + ), + ); + + final rawText = response.text; + if (rawText == null || rawText.trim().isEmpty) { + return const []; + } + + final cleanJson = rawText + .replaceAll('```json', '') + .replaceAll('```', '') + .trim(); + + final decoded = jsonDecode(cleanJson) as Map; + return StoreFinderResult.fromJson(decoded).stores; + } catch (e) { + debugPrint('Error finding stores with these ingredients: $e'); + return const []; + } + } + + Future generateIngredients(Uint8List imageBytes) async { + final trace = FirebasePerformance.instance.newTrace('hybrid-inference'); + await trace.start(); + try { + final prompt = Content.multi([ + InlineDataPart(mimeTypeValue, imageBytes), + TextPart(_remoteConfig.getString(hybridIngredientsPromptKey)), + ]); + + final response = await _hybridGenerativeModel.generateContent([prompt]); + trace.putAttribute('inferenceSource', 'In cloud'); + return response.text ?? ''; + } finally { + await trace.stop(); + } + } + + Future generateRecipe( + String ingredients, + String notes, + ) async { + final inputs = { + ingredientsField: ingredients, + if (notes.trim().isNotEmpty) notesField: notes, + }; + + // ignore: experimental_member_use + final response = await _templateGenerativeModel.generateContent( + _remoteConfig.getString(generateRecipeKey), + inputs: inputs, + ); + + final rawText = response.text; + if (rawText == null || rawText.trim().isEmpty) { + return null; + } + + final cleanJson = rawText + .replaceAll('```json', '') + .replaceAll('```', '') + .trim(); + final decoded = jsonDecode(cleanJson) as Map; + return RecipeSchema.fromJson(decoded); + } + + Future generateRecipePhoto(String recipeTitle) async { + // ignore: experimental_member_use + final response = await _templateGenerativeModel.generateContent( + _remoteConfig.getString(generateRecipePhotoGeminiKey), + inputs: {recipeTitleField: recipeTitle}, + ); + + final parts = response.candidates.firstOrNull?.content.parts ?? const []; + for (final part in parts) { + if (part is InlineDataPart) { + return part.bytes; + } + } + return null; + } + + Future scanMeal(String imageData) async { + // ignore: experimental_member_use + final response = await _templateGenerativeModel.generateContent( + _remoteConfig.getString(scanMealKey), + inputs: { + mimeTypeField: mimeTypeValue, + imageDataField: imageData, + }, + ); + + final rawText = response.text; + if (rawText == null || rawText.trim().isEmpty) { + return null; + } + + final cleanJson = rawText + .replaceAll('```json', '') + .replaceAll('```', '') + .trim(); + final decoded = jsonDecode(cleanJson) as Map; + return MealSchema.fromJson(decoded); + } + + Future craftRecipePairing( + String dishTitle, + List ingredients, + ) async { + // ignore: experimental_member_use + final response = await _templateGenerativeModel.generateContent( + _remoteConfig.getString(recipePairingKey), + inputs: { + recipeTitleField: dishTitle, + ingredientsField: ingredients.join(', '), + }, + ); + + return (response.text ?? '').trim(); + } + + Future generateSpeech(String? text) async { + if (text == null || text.trim().isEmpty) return null; + + final model = _aiModel.generativeModel( + model: _remoteConfig.getString(ttsModelKey), + generationConfig: GenerationConfig( + responseModalities: [ResponseModalities.audio], + speechConfig: SpeechConfig( + voiceName: ttsVoice, + languageCode: ttsLanguage, + ), + ), + ); + + final voiceProfile = _remoteConfig.getString(voiceProfileKey); + final response = await model.generateContent([ + Content.text('$voiceProfile $text'), + ]); + + final part = + response.candidates.firstOrNull?.content.parts.firstOrNull; + if (part is InlineDataPart) { + return part.bytes; + } + return null; + } + + Future loadOnDeviceModel() async { + // On-device model preloading via Gemini Nano is handled by cloud fallback in Flutter. + debugPrint('AIRemoteDataSource: Cloud inference configured for hybrid model'); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart new file mode 100644 index 00000000..54e6b059 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart @@ -0,0 +1,16 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb_auth; +import '../model/user.dart'; + +class AuthRemoteDataSource { + AuthRemoteDataSource({fb_auth.FirebaseAuth? auth}) + : _auth = auth ?? fb_auth.FirebaseAuth.instance; + + final fb_auth.FirebaseAuth _auth; + + fb_auth.User? get currentUser => _auth.currentUser; + + Future createAnonymousAccount() async { + final authResult = await _auth.signInAnonymously(); + return AppUser(authId: authResult.user?.uid ?? ''); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart new file mode 100644 index 00000000..27734953 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/database_remote_datasource.dart @@ -0,0 +1,447 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; + +import '../../ui/recipe_list/filter_options.dart'; +import '../model/grocery_item.dart'; +import '../model/like.dart'; +import '../model/recipe.dart'; +import '../model/review.dart'; +import '../model/user.dart'; + +class DatabaseRemoteDataSource { + DatabaseRemoteDataSource({FirebaseFirestore? firestore}) + : _firestore = firestore ?? + FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'default', + ); + + final FirebaseFirestore _firestore; + + // Collections + static const String usersCollection = 'users'; + static const String recipesCollection = 'recipes'; + static const String likesCollection = 'likes'; + static const String reviewsSubcollection = 'reviews'; + static const String groceriesCollection = 'groceries'; + + // Fields + static const String ratingField = 'rating'; + static const String averageRatingField = 'averageRating'; + static const String authorIdField = 'authorId'; + static const String titleField = 'title'; + static const String tagsField = 'tags'; + static const String likesField = 'likes'; + static const String imageUriField = 'imageUri'; + static const String prepTimeField = 'prepTime'; + static const String cookTimeField = 'cookTime'; + static const String servingsField = 'servings'; + static const String instructionsField = 'instructions'; + static const String ingredientsField = 'ingredients'; + static const String recipeIdField = 'recipeId'; + static const String userIdField = 'userId'; + static const String checkedField = 'checked'; + + // Field aliases + static const String avgRatingAlias = 'avg_rating'; + static const String tagNameAlias = 'tagName'; + static const String tagCountAlias = 'tagCount'; + static const String likesCountAlias = 'likesCount'; + static const String scoreAlias = 'score'; + + // Field paths + static const String nameFieldPath = '__name__'; + + Future addUser(AppUser user) async { + await _firestore.collection(usersCollection).add(user.toMap()); + } + + Future addRecipe(Recipe recipe) async { + final recipeRef = await _firestore + .collection(recipesCollection) + .add(recipe.toFirestore()); + return recipeRef.id; + } + + Future getRecipe(String recipeId) async { + if (recipeId.isEmpty) { + return const Recipe(); + } + + final resultsFuture = _firestore + .pipeline() + .collection(recipesCollection) + .where( + Expression.field(nameFieldPath).documentId().equalValue(recipeId), + ) + .execute(); + final avgRatingFuture = _firestore + .pipeline() + .collection('$recipesCollection/$recipeId/$reviewsSubcollection') + .aggregate(Expression.field(ratingField).average().as(avgRatingAlias)) + .execute(); + + final docSnapshot = await resultsFuture; + final docResults = docSnapshot.result; + + if (docResults.isEmpty) { + return const Recipe(); + } + + final itemData = Map.from(docResults.first.data() ?? {}); + try { + final ratingSnapshot = await avgRatingFuture; + final ratingResults = ratingSnapshot.result; + if (ratingResults.isNotEmpty) { + final ratingData = ratingResults.first.data(); + final computedAvg = (ratingData?[avgRatingAlias] as num?)?.toDouble(); + if (computedAvg != null) { + itemData[averageRatingField] = computedAvg; + } + } + } catch (e) { + debugPrint('Error aggregating recipe rating: $e'); + } + + return _mapToRecipe(itemData); + } + + Future> getAllRecipes() async { + final recipesFuture = + _firestore.pipeline().collection(recipesCollection).execute(); + final ratingsFuture = _getAverageRatingsByRecipeId(); + + final results = await Future.wait([recipesFuture, ratingsFuture]); + final pipelineResults = (results[0] as PipelineSnapshot).result; + final ratingsMap = results[1] as Map; + + return _toRecipeListItems(pipelineResults, ratingsMap: ratingsMap); + } + + Future> getPopularTags() async { + final snapshot = await _firestore + .pipeline() + .collection(recipesCollection) + .unnest(Expression.field(tagsField).as(tagNameAlias)) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [CountAll().as(tagCountAlias)], + groups: [Expression.field(tagNameAlias)], + ), + ) + .sort(Expression.field(tagCountAlias).descending()) + .limit(10) + .execute(); + + return snapshot.result + .map((r) => r.data()?[tagNameAlias]) + .whereType() + .where((tag) => tag.isNotEmpty) + .toList(); + } + + /// Enforces a "one review per user" constraint using deterministic document ID "${recipeId}_${userId}". + Future setReview(Review review) async { + final reviewRef = _firestore + .collection(recipesCollection) + .doc(review.recipeId) + .collection(reviewsSubcollection) + .doc('${review.recipeId}_${review.userId}'); + + await reviewRef.set(review.toMap()); + } + + Future getRating(String userId, String recipeId) async { + if (userId.isEmpty || recipeId.isEmpty) return 0; + final reviewId = '${recipeId}_$userId'; + + final snapshot = await _firestore + .pipeline() + .collection('$recipesCollection/$recipeId/$reviewsSubcollection') + .where( + Expression.field(nameFieldPath).documentId().equalValue(reviewId), + ) + .execute(); + final results = snapshot.result; + if (results.isEmpty) return 0; + + final reviewData = results.first.data(); + if (reviewData == null) return 0; + return (reviewData[ratingField] as num?)?.toInt() ?? 0; + } + + /// Enforces a "one favorite per user" constraint using deterministic document ID "${recipeId}_${userId}". + Future setFavorite(Like like) async { + final likeRef = _firestore + .collection(likesCollection) + .doc('${like.recipeId}_${like.userId}'); + + await likeRef.set(like.toMap()); + } + + Future removeFavorite(Like like) async { + await _firestore + .collection(likesCollection) + .doc('${like.recipeId}_${like.userId}') + .delete(); + } + + Future getFavorite(String userId, String recipeId) async { + if (userId.isEmpty || recipeId.isEmpty) return false; + final favoriteId = '${recipeId}_$userId'; + + final snapshot = await _firestore + .pipeline() + .collection(likesCollection) + .where( + Expression.field(nameFieldPath).documentId().equalValue(favoriteId), + ) + .execute(); + return snapshot.result.any((r) => r.data() != null); + } + + Future> getFilteredRecipes( + FilterOptions filterOptions, + String userId, + ) async { + var pipeline = _firestore.pipeline().collection(recipesCollection); + + if (filterOptions.searchQuery.trim().isNotEmpty) { + final searchStage = SearchStage.withQuery(filterOptions.searchQuery); + pipeline = pipeline.search(searchStage); + } else if (filterOptions.recipeTitle.trim().isNotEmpty) { + pipeline = pipeline.where( + Expression.field(titleField) + .toLowerCase() + .stringIndexOf(filterOptions.recipeTitle.toLowerCase()) + .greaterThanOrEqualValue(0), + ); + } + + if (filterOptions.filterByMine) { + pipeline = pipeline.where( + Expression.field(authorIdField).equalValue(userId), + ); + } + + if (filterOptions.selectedTags.isNotEmpty) { + pipeline = pipeline.where( + Expression.field(tagsField).arrayContainsAny(filterOptions.selectedTags), + ); + } + + if (filterOptions.sortBy == SortByFilter.alphabetical) { + pipeline = pipeline.sort(Expression.field(titleField).ascending()); + } + + final recipesFuture = pipeline.execute(); + final ratingsFuture = _getAverageRatingsByRecipeId(); + final likesFuture = filterOptions.sortBy == SortByFilter.popularity + ? _getLikesCountByRecipeId() + : Future.value({}); + + final results = await Future.wait([ + recipesFuture, + ratingsFuture, + likesFuture, + ]); + + final pipelineResults = (results[0] as PipelineSnapshot).result; + final ratingsMap = results[1] as Map; + final likesMap = results[2] as Map; + + var items = _toRecipeListItems(pipelineResults, ratingsMap: ratingsMap); + + if (filterOptions.rating > 0) { + items = items + .where((item) => item.averageRating >= filterOptions.rating) + .toList(); + } + + switch (filterOptions.sortBy) { + case SortByFilter.defaultSort: + case SortByFilter.alphabetical: + break; + case SortByFilter.rating: + items.sort((a, b) => b.averageRating.compareTo(a.averageRating)); + break; + case SortByFilter.popularity: + items.sort( + (a, b) => (likesMap[b.id] ?? 0).compareTo(likesMap[a.id] ?? 0), + ); + break; + } + + return items; + } + + Future> _getAverageRatingsByRecipeId() async { + try { + final snapshot = await _firestore + .pipeline() + .collectionGroup(reviewsSubcollection) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field(ratingField).average().as(avgRatingAlias), + ], + groups: [ + Expression.field(recipeIdField), + ], + ), + ) + .execute(); + + final map = {}; + for (final r in snapshot.result) { + final data = r.data(); + if (data == null) continue; + final recipeId = data[recipeIdField] as String?; + final avgRating = (data[avgRatingAlias] as num?)?.toDouble(); + if (recipeId != null && avgRating != null) { + map[recipeId] = avgRating; + } + } + return map; + } catch (e) { + debugPrint('Error aggregating ratings across recipes: $e'); + return {}; + } + } + + Future> _getLikesCountByRecipeId() async { + try { + final snapshot = await _firestore + .pipeline() + .collectionGroup(likesCollection) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [CountAll().as(likesCountAlias)], + groups: [Expression.field(recipeIdField)], + ), + ) + .execute(); + + final map = {}; + for (final r in snapshot.result) { + final data = r.data(); + if (data == null) continue; + final recipeId = data[recipeIdField] as String?; + final count = (data[likesCountAlias] as num?)?.toInt(); + if (recipeId != null && count != null) { + map[recipeId] = count; + } + } + return map; + } catch (e) { + debugPrint('Error aggregating likes across recipes: $e'); + return {}; + } + } + + Recipe _mapToRecipe(Map itemData) { + return Recipe( + title: itemData[titleField] as String? ?? '', + instructions: itemData[instructionsField] as String? ?? '', + ingredients: (itemData[ingredientsField] as List?) + ?.whereType() + .toList() ?? + const [], + authorId: itemData[authorIdField] as String? ?? '', + tags: (itemData[tagsField] as List?) + ?.whereType() + .toList() ?? + const [], + averageRating: (itemData[averageRatingField] as num?)?.toDouble() ?? 0.0, + prepTime: itemData[prepTimeField] as String? ?? '', + cookTime: itemData[cookTimeField] as String? ?? '', + servings: itemData[servingsField] as String? ?? '', + imageUri: itemData[imageUriField] as String?, + ); + } + + List _toRecipeListItems( + List results, { + required Map ratingsMap, + }) { + final list = []; + for (final result in results) { + final id = result.document?.id; + final itemData = result.data(); + if (id == null || id.isEmpty || itemData == null) { + debugPrint('DatabaseRemoteDataSource: Empty recipe ID or data'); + continue; + } + + final avgRating = ratingsMap[id] ?? + (itemData[averageRatingField] as num?)?.toDouble() ?? + 0.0; + + list.add( + RecipeListItem( + id: id, + title: itemData[titleField] as String? ?? '', + averageRating: avgRating, + imageUri: itemData[imageUriField] as String?, + ), + ); + } + return list; + } + + Stream> getGroceriesStream(String userId) { + if (userId.isEmpty) { + return Stream.value(const []); + } + + return _firestore + .collection(groceriesCollection) + .where(userIdField, isEqualTo: userId) + .snapshots() + .map((snapshot) { + return snapshot.docs.map((doc) { + return GroceryItem.fromMap(doc.data(), docId: doc.id); + }).toList(); + }); + } + + Future addGroceryItem(GroceryItem item) async { + final docRef = _firestore.collection(groceriesCollection).doc(); + final itemWithId = item.copyWith(id: docRef.id); + await docRef.set(itemWithId.toMap()); + } + + Future updateGroceryItemChecked(String itemId, bool checked) async { + await _firestore + .collection(groceriesCollection) + .doc(itemId) + .update({checkedField: checked}); + } + + Future deleteGroceryItem(String itemId) async { + await _firestore.collection(groceriesCollection).doc(itemId).delete(); + } + + Future addIngredientsToGroceries( + String userId, + List ingredients, + ) async { + if (userId.isEmpty || ingredients.isEmpty) return; + + final batch = _firestore.batch(); + final collection = _firestore.collection(groceriesCollection); + + for (final ingredient in ingredients) { + final docRef = collection.doc(); + final item = GroceryItem( + id: docRef.id, + userId: userId, + name: ingredient, + checked: false, + ); + batch.set(docRef, item.toMap()); + } + await batch.commit(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart new file mode 100644 index 00000000..c99e16a2 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/live_ai_remote_datasource.dart @@ -0,0 +1,77 @@ +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/foundation.dart'; + +import '../model/recipe.dart'; +import 'remote_config_service.dart'; + +class LiveAIRemoteDataSource { + LiveAIRemoteDataSource({ + FirebaseAI? aiModel, + required RemoteConfigService remoteConfig, + }) : _aiModel = aiModel ?? FirebaseAI.googleAI(), + _remoteConfig = remoteConfig; + + final FirebaseAI _aiModel; + final RemoteConfigService _remoteConfig; + + // Live Model Config + static const String liveModelVoice = 'CHARON'; + + // Tools config + static const String addIngredientsToolName = 'addIngredientToGroceryList'; + static const String addIngredientsToolDescription = + "Adds a specified ingredient to the user's grocery list in the database."; + static const String ingredientFieldName = 'ingredient'; + static const String ingredientFieldDescription = + 'The name of the ingredient to add.'; + + // Remote Config Keys + static const String liveModelNameKey = 'live_model_name'; + static const String liveModelPromptKey = 'live_model_prompt'; + + static final Tool _groceryListTool = Tool.functionDeclarations([ + FunctionDeclaration( + addIngredientsToolName, + addIngredientsToolDescription, + parameters: { + ingredientFieldName: Schema.string( + description: ingredientFieldDescription, + ), + }, + ), + ]); + + Future setupLiveSession(Recipe recipe) async { + final liveGenerationConfig = LiveGenerationConfig( + speechConfig: SpeechConfig(voiceName: liveModelVoice), + responseModalities: [ResponseModalities.audio], + ); + + final promptTemplate = _remoteConfig.getString(liveModelPromptKey); + final instructionText = _formatInstructionPrompt(promptTemplate, recipe); + + final liveModel = _aiModel.liveGenerativeModel( + model: _remoteConfig.getString(liveModelNameKey), + liveGenerationConfig: liveGenerationConfig, + systemInstruction: Content.text(instructionText), + tools: [_groceryListTool], + ); + + try { + return await liveModel.connect(); + } catch (e) { + debugPrint('Failed to connect LiveSession: $e'); + return null; + } + } + + String _formatInstructionPrompt(String template, Recipe recipe) { + return template + .replaceAll('{{title}}', recipe.title) + .replaceAll('{{prepTime}}', recipe.prepTime) + .replaceAll('{{cookTime}}', recipe.cookTime) + .replaceAll('{{servings}}', recipe.servings) + .replaceAll('{{ingredients}}', recipe.ingredients.join('\n')) + .replaceAll('{{instructions}}', recipe.instructions); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart new file mode 100644 index 00000000..c2a651cd --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/remote_config_service.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:firebase_remote_config/firebase_remote_config.dart'; +import 'package:flutter/foundation.dart'; + +class RemoteConfigService { + RemoteConfigService({FirebaseRemoteConfig? remoteConfig}) + : _remoteConfig = remoteConfig ?? FirebaseRemoteConfig.instance; + + final FirebaseRemoteConfig _remoteConfig; + StreamSubscription? _configUpdateSub; + + static const Map defaultParameters = { + 'find_stores': 'find-stores-template-v1-0-0', + 'recipe_pairing': 'recipe-pairing-v1-0-0', + 'recipe_pairing_voice_profile': + "[Audio Profile: A French chef with a charming French accent][Scene: An elegant Parisian restaurant during a masterclass on food and wine pairing][Director's Notes: Speak as if you're a French chef who is an expert in pairing dishes with wines. Speak with warm enthusiasm and culinary sophistication]", + 'tts_model': 'gemini-3.1-flash-tts-preview', + 'hybrid_cloud_model': 'gemini-3.1-flash-lite', + 'hybrid_ingredients_prompt': + 'Please analyze this image and list all visible food ingredients. Output ONLY a comma-separated list of ingredients. Do not include any introductory text, headers, or concluding remarks. Provide the raw list only. Be specific with measurements where possible.', + 'scan_meal': 'scan-meal-template-v1-0-0', + 'generate_recipe_photo_gemini': + 'generate-recipe-photo-gemini-template-v1-0-0', + 'generate_recipe': 'generate-recipe-template-v1-0-0', + 'live_model_name': 'gemini-2.5-flash-native-audio-preview-12-2025', + 'live_model_prompt': '''You are a helpful live cooking assistant. The user is currently preparing the following recipe: +Title: {{title}} +Prep time: {{prepTime}}, Cook time: {{cookTime}}, Servings: {{servings}} + +Ingredients: +{{ingredients}} + +Instructions: +{{instructions}} + +The user will stream real-time video of their cooking and ask questions like "Is this the expected texture of the recipe?". +Confirm or deny accurately based on the recipe context and the video content. Be concise and helpful. +If the user asks you to add an ingredient or item to their grocery list or shopping list, call the addIngredientToGroceryList function.''', + }; + + Future initialize() async { + try { + await _remoteConfig.setConfigSettings( + RemoteConfigSettings( + fetchTimeout: const Duration(minutes: 1), + minimumFetchInterval: const Duration(seconds: 1800), + ), + ); + await _remoteConfig.setDefaults(defaultParameters); + await _remoteConfig.fetchAndActivate(); + + if (!kIsWeb) { + _configUpdateSub = _remoteConfig.onConfigUpdated.listen( + (_) async { + try { + await _remoteConfig.activate(); + } catch (e) { + debugPrint('Error activating updated Remote Config: $e'); + } + }, + onError: (Object error) { + debugPrint('Remote Config real-time listener error: $error'); + }, + ); + } + } catch (e) { + debugPrint('Remote Config initialization error: $e'); + } + } + + String getString(String key) { + final value = _remoteConfig.getString(key); + if (value.isNotEmpty) { + return value; + } + return defaultParameters[key]?.toString() ?? ''; + } + + void dispose() { + _configUpdateSub?.cancel(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart new file mode 100644 index 00000000..5097267e --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:uuid/uuid.dart'; + +class StorageRemoteDataSource { + StorageRemoteDataSource({Reference? storageRef}) + : _storageRef = storageRef ?? FirebaseStorage.instance.ref(); + + static const int _maxImageLength = 800; + final Reference _storageRef; + static const Uuid _uuid = Uuid(); + + Future addImage(Uint8List imageBytes) async { + final processedBytes = await compute(_scaleAndCompressImage, imageBytes); + final randomId = _uuid.v4(); + final imagesRef = _storageRef.child('images/$randomId.webp'); + await imagesRef.putData( + processedBytes, + SettableMetadata(contentType: 'image/jpeg'), + ); + return imagesRef.getDownloadURL(); + } + + static Uint8List _scaleAndCompressImage(Uint8List rawBytes) { + final decoded = img.decodeImage(rawBytes); + if (decoded == null) { + return rawBytes; + } + + final lastWidth = decoded.width; + final lastHeight = decoded.height; + final int outWidth; + final int outHeight; + + if (lastWidth > lastHeight) { + outWidth = _maxImageLength; + outHeight = (lastHeight.toDouble() / lastWidth * _maxImageLength).toInt(); + } else { + outHeight = _maxImageLength; + outWidth = (lastWidth.toDouble() / lastHeight * _maxImageLength).toInt(); + } + + final scaled = img.copyResize( + decoded, + width: outWidth, + height: outHeight, + interpolation: img.Interpolation.linear, + ); + return Uint8List.fromList(img.encodeJpg(scaled, quality: 70)); + } +} + +/// Converts raw image bytes to a JPEG-compressed Base64 string (quality 80), +/// matching Android's `Bitmap.toBase64()`. +Future imageBytesToBase64(Uint8List imageBytes) { + return compute(_encodeJpegBase64, imageBytes); +} + +String _encodeJpegBase64(Uint8List rawBytes) { + final decoded = img.decodeImage(rawBytes); + if (decoded == null) { + return base64Encode(rawBytes); + } + final jpegBytes = img.encodeJpg(decoded, quality: 80); + return base64Encode(jpegBytes); +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart new file mode 100644 index 00000000..dbefe228 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/grocery_item.dart @@ -0,0 +1,45 @@ +class GroceryItem { + final String id; + final String userId; + final String name; + final bool checked; + + const GroceryItem({ + this.id = '', + this.userId = '', + this.name = '', + this.checked = false, + }); + + GroceryItem copyWith({ + String? id, + String? userId, + String? name, + bool? checked, + }) { + return GroceryItem( + id: id ?? this.id, + userId: userId ?? this.userId, + name: name ?? this.name, + checked: checked ?? this.checked, + ); + } + + Map toMap() { + return { + 'id': id, + 'userId': userId, + 'name': name, + 'checked': checked, + }; + } + + factory GroceryItem.fromMap(Map map, {String? docId}) { + return GroceryItem( + id: docId ?? (map['id'] as String? ?? ''), + userId: map['userId'] as String? ?? '', + name: map['name'] as String? ?? '', + checked: map['checked'] as bool? ?? false, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart new file mode 100644 index 00000000..112754c0 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/like.dart @@ -0,0 +1,23 @@ +class Like { + final String recipeId; + final String userId; + + const Like({ + this.recipeId = '', + this.userId = '', + }); + + Map toMap() { + return { + 'recipeId': recipeId, + 'userId': userId, + }; + } + + factory Like.fromMap(Map map) { + return Like( + recipeId: map['recipeId'] as String? ?? '', + userId: map['userId'] as String? ?? '', + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart new file mode 100644 index 00000000..5375b921 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/recipe.dart @@ -0,0 +1,99 @@ +class Recipe { + final String title; + final String instructions; + final List ingredients; + final String authorId; + final List tags; + final double averageRating; + final String prepTime; + final String cookTime; + final String servings; + final String? imageUri; + + const Recipe({ + this.title = '', + this.instructions = '', + this.ingredients = const [], + this.authorId = '', + this.tags = const [], + this.averageRating = 0.0, + this.prepTime = '', + this.cookTime = '', + this.servings = '', + this.imageUri, + }); + + /// Matches @get:Exclude on averageRating in FriendlyMeals-Android Recipe.kt + Map toFirestore() { + return { + 'title': title, + 'instructions': instructions, + 'ingredients': ingredients, + 'authorId': authorId, + 'tags': tags, + 'prepTime': prepTime, + 'cookTime': cookTime, + 'servings': servings, + 'imageUri': imageUri, + }; + } + + factory Recipe.fromMap(Map data, {double averageRating = 0.0}) { + return Recipe( + title: data['title'] as String? ?? '', + instructions: data['instructions'] as String? ?? '', + ingredients: (data['ingredients'] as List?) + ?.whereType() + .toList() ?? + const [], + authorId: data['authorId'] as String? ?? '', + tags: (data['tags'] as List?)?.whereType().toList() ?? + const [], + averageRating: (data['averageRating'] as num?)?.toDouble() ?? averageRating, + prepTime: data['prepTime'] as String? ?? '', + cookTime: data['cookTime'] as String? ?? '', + servings: data['servings'] as String? ?? '', + imageUri: data['imageUri'] as String?, + ); + } + + Recipe copyWith({ + String? title, + String? instructions, + List? ingredients, + String? authorId, + List? tags, + double? averageRating, + String? prepTime, + String? cookTime, + String? servings, + String? imageUri, + }) { + return Recipe( + title: title ?? this.title, + instructions: instructions ?? this.instructions, + ingredients: ingredients ?? this.ingredients, + authorId: authorId ?? this.authorId, + tags: tags ?? this.tags, + averageRating: averageRating ?? this.averageRating, + prepTime: prepTime ?? this.prepTime, + cookTime: cookTime ?? this.cookTime, + servings: servings ?? this.servings, + imageUri: imageUri ?? this.imageUri, + ); + } +} + +class RecipeListItem { + final String id; + final String title; + final double averageRating; + final String? imageUri; + + const RecipeListItem({ + this.id = '', + this.title = '', + this.averageRating = 0.0, + this.imageUri, + }); +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart new file mode 100644 index 00000000..154fa71b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/review.dart @@ -0,0 +1,27 @@ +class Review { + final String userId; + final String recipeId; + final int rating; + + const Review({ + this.userId = '', + this.recipeId = '', + this.rating = 0, + }); + + Map toMap() { + return { + 'userId': userId, + 'recipeId': recipeId, + 'rating': rating, + }; + } + + factory Review.fromMap(Map map) { + return Review( + userId: map['userId'] as String? ?? '', + recipeId: map['recipeId'] as String? ?? '', + rating: (map['rating'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart b/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart new file mode 100644 index 00000000..8da3fec5 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/model/user.dart @@ -0,0 +1,13 @@ +class AppUser { + final String authId; + + const AppUser({this.authId = ''}); + + Map toMap() { + return {'authId': authId}; + } + + factory AppUser.fromMap(Map map) { + return AppUser(authId: map['authId'] as String? ?? ''); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart new file mode 100644 index 00000000..07cacf59 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/ai_repository.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; + +import '../datasource/ai_remote_datasource.dart'; +import '../schema/meal_schema.dart'; +import '../schema/recipe_schema.dart'; +import '../schema/store_schema.dart'; + +class AIRepository { + AIRepository(this._aiRemoteDataSource); + + final AIRemoteDataSource _aiRemoteDataSource; + + Future generateIngredients(Uint8List imageBytes) { + return _aiRemoteDataSource.generateIngredients(imageBytes); + } + + Future> findStores({ + required List ingredients, + required double latitude, + required double longitude, + required String currentTime, + required String dayOfWeek, + }) { + return _aiRemoteDataSource.findStores( + ingredients: ingredients, + latitude: latitude, + longitude: longitude, + currentTime: currentTime, + dayOfWeek: dayOfWeek, + ); + } + + Future generateRecipe(String ingredients, String notes) { + return _aiRemoteDataSource.generateRecipe(ingredients, notes); + } + + Future generateRecipePhoto(String recipeTitle) { + return _aiRemoteDataSource.generateRecipePhoto(recipeTitle); + } + + Future scanMeal(String imageData) { + return _aiRemoteDataSource.scanMeal(imageData); + } + + Future craftRecipePairing( + String dishTitle, + List ingredients, + ) { + return _aiRemoteDataSource.craftRecipePairing(dishTitle, ingredients); + } + + Future generateSpeech(String? text) { + return _aiRemoteDataSource.generateSpeech(text); + } + + Future loadOnDeviceModel() { + return _aiRemoteDataSource.loadOnDeviceModel(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart new file mode 100644 index 00000000..1cbd40f1 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart @@ -0,0 +1,15 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb_auth; +import '../datasource/auth_remote_datasource.dart'; +import '../model/user.dart'; + +class AuthRepository { + AuthRepository(this._authRemoteDataSource); + + final AuthRemoteDataSource _authRemoteDataSource; + + fb_auth.User? get currentUser => _authRemoteDataSource.currentUser; + + Future createAnonymousAccount() { + return _authRemoteDataSource.createAnonymousAccount(); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart new file mode 100644 index 00000000..c96b87d7 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/database_repository.dart @@ -0,0 +1,86 @@ +import '../../ui/recipe_list/filter_options.dart'; +import '../datasource/database_remote_datasource.dart'; +import '../model/grocery_item.dart'; +import '../model/like.dart'; +import '../model/recipe.dart'; +import '../model/review.dart'; +import '../model/user.dart'; + +class DatabaseRepository { + DatabaseRepository(this._databaseRemoteDataSource); + + final DatabaseRemoteDataSource _databaseRemoteDataSource; + + Future addUser(AppUser user) { + return _databaseRemoteDataSource.addUser(user); + } + + Future addRecipe(Recipe recipe) { + return _databaseRemoteDataSource.addRecipe(recipe); + } + + Future getRecipe(String recipeId) { + return _databaseRemoteDataSource.getRecipe(recipeId); + } + + Future> getAllRecipes() { + return _databaseRemoteDataSource.getAllRecipes(); + } + + Future> getPopularTags() { + return _databaseRemoteDataSource.getPopularTags(); + } + + Future setReview(Review review) { + return _databaseRemoteDataSource.setReview(review); + } + + Future getRating(String userId, String recipeId) { + return _databaseRemoteDataSource.getRating(userId, recipeId); + } + + Future setFavorite(Like like) { + return _databaseRemoteDataSource.setFavorite(like); + } + + Future removeFavorite(Like like) { + return _databaseRemoteDataSource.removeFavorite(like); + } + + Future getFavorite(String userId, String recipeId) { + return _databaseRemoteDataSource.getFavorite(userId, recipeId); + } + + Future> getFilteredRecipes( + FilterOptions filterOptions, + String userId, + ) { + return _databaseRemoteDataSource.getFilteredRecipes(filterOptions, userId); + } + + Stream> getGroceriesStream(String userId) { + return _databaseRemoteDataSource.getGroceriesStream(userId); + } + + Future addGroceryItem(GroceryItem item) { + return _databaseRemoteDataSource.addGroceryItem(item); + } + + Future updateGroceryItemChecked(String itemId, bool checked) { + return _databaseRemoteDataSource.updateGroceryItemChecked(itemId, checked); + } + + Future deleteGroceryItem(String itemId) { + return _databaseRemoteDataSource.deleteGroceryItem(itemId); + } + + Future addIngredientsToGroceries( + String userId, + List ingredients, + ) { + return _databaseRemoteDataSource.addIngredientsToGroceries( + userId, + ingredients, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart new file mode 100644 index 00000000..74075fe5 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/live_ai_repository.dart @@ -0,0 +1,14 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +import '../datasource/live_ai_remote_datasource.dart'; +import '../model/recipe.dart'; + +class LiveAIRepository { + LiveAIRepository(this._liveAiRemoteDataSource); + + final LiveAIRemoteDataSource _liveAiRemoteDataSource; + + Future setupLiveSession(Recipe recipe) { + return _liveAiRemoteDataSource.setupLiveSession(recipe); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart new file mode 100644 index 00000000..cf909c14 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/storage_repository.dart @@ -0,0 +1,12 @@ +import 'dart:typed_data'; +import '../datasource/storage_remote_datasource.dart'; + +class StorageRepository { + StorageRepository(this._storageRemoteDataSource); + + final StorageRemoteDataSource _storageRemoteDataSource; + + Future addImage(Uint8List imageBytes) { + return _storageRemoteDataSource.addImage(imageBytes); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart new file mode 100644 index 00000000..f0c94331 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/meal_schema.dart @@ -0,0 +1,28 @@ +class MealSchema { + final String protein; + final String fat; + final String carbs; + final String sugar; + final List ingredients; + + const MealSchema({ + this.protein = '0g', + this.fat = '0g', + this.carbs = '0g', + this.sugar = '0g', + this.ingredients = const [], + }); + + factory MealSchema.fromJson(Map json) { + return MealSchema( + protein: json['protein'] as String? ?? '0g', + fat: json['fat'] as String? ?? '0g', + carbs: json['carbs'] as String? ?? '0g', + sugar: json['sugar'] as String? ?? '0g', + ingredients: (json['ingredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart new file mode 100644 index 00000000..da046763 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart @@ -0,0 +1,53 @@ +import 'package:myapp/data/model/recipe.dart'; + +class RecipeSchema { + final String title; + final String instructions; + final List ingredients; + final String prepTime; + final String cookTime; + final String servings; + final List tags; + + const RecipeSchema({ + required this.title, + required this.instructions, + required this.ingredients, + required this.prepTime, + required this.cookTime, + required this.servings, + required this.tags, + }); + + factory RecipeSchema.fromJson(Map json) { + return RecipeSchema( + title: json['title'] as String? ?? '', + instructions: json['instructions'] as String? ?? '', + ingredients: (json['ingredients'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + prepTime: json['prepTime'] as String? ?? '', + cookTime: json['cookTime'] as String? ?? '', + servings: json['servings'] as String? ?? '', + tags: (json['tags'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], + ); + } + + Recipe toRecipe({required String authorId, String? imageUri}) { + return Recipe( + title: title, + instructions: instructions, + ingredients: ingredients, + authorId: authorId, + tags: tags, + prepTime: prepTime, + cookTime: cookTime, + servings: servings, + imageUri: imageUri, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart new file mode 100644 index 00000000..8307017a --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/store_schema.dart @@ -0,0 +1,50 @@ +class StoreSchema { + final String name; + final String address; + final String distance; + final bool openNow; + final bool closingSoon; + final bool hasParking; + final String parkingDetails; + final String mapUrl; + + const StoreSchema({ + this.name = '', + this.address = '', + this.distance = '', + this.openNow = false, + this.closingSoon = false, + this.hasParking = false, + this.parkingDetails = '', + this.mapUrl = '', + }); + + factory StoreSchema.fromJson(Map json) { + return StoreSchema( + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + distance: json['distance'] as String? ?? '', + openNow: json['openNow'] as bool? ?? false, + closingSoon: json['closingSoon'] as bool? ?? false, + hasParking: json['hasParking'] as bool? ?? false, + parkingDetails: json['parkingDetails'] as String? ?? '', + mapUrl: json['mapUrl'] as String? ?? '', + ); + } +} + +class StoreFinderResult { + final List stores; + + const StoreFinderResult({this.stores = const []}); + + factory StoreFinderResult.fromJson(Map json) { + return StoreFinderResult( + stores: (json['stores'] as List?) + ?.whereType>() + .map(StoreSchema.fromJson) + .toList() ?? + const [], + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/main.dart b/firebase-ai-friendly-meals/flutter/lib/main.dart index aa71254e..4fa9fb89 100644 --- a/firebase-ai-friendly-meals/flutter/lib/main.dart +++ b/firebase-ai-friendly-meals/flutter/lib/main.dart @@ -1,26 +1,221 @@ +import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:myapp/firebase_options.dart'; -import 'package:myapp/recipe_generator_screen.dart'; -void main() async { +import 'data/app_repositories.dart'; +import 'data/datasource/remote_config_service.dart'; +import 'firebase_options.dart'; +import 'ui/generate/generate_screen.dart'; +import 'ui/grocery_list/grocery_list_screen.dart'; +import 'ui/live/live_assistant_screen.dart'; +import 'ui/recipe/recipe_screen.dart'; +import 'ui/recipe_list/filter_screen.dart'; +import 'ui/recipe_list/recipe_list_controller.dart'; +import 'ui/recipe_list/recipe_list_screen.dart'; +import 'ui/scan_meal/scan_meal_screen.dart'; +import 'ui/shared/bottom_nav_bar.dart'; +import 'ui/theme/app_theme.dart'; + +Future main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); - runApp(const MyApp()); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + await _initializeAppCheck(); + + final remoteConfigService = RemoteConfigService(); + await remoteConfigService.initialize(); + + final repositories = AppRepositories.create(remoteConfigService); + + runApp(FriendlyMealsApp(repositories: repositories)); +} + +Future _initializeAppCheck() async { + try { + await FirebaseAppCheck.instance.activate( + providerAndroid: kDebugMode + ? const AndroidDebugProvider() + : const AndroidPlayIntegrityProvider(), + providerApple: kDebugMode + ? const AppleDebugProvider() + : const AppleDeviceCheckProvider(), + ); + } catch (e) { + debugPrint('Error activating Firebase App Check: $e'); + } } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +sealed class _SubRoute { + const _SubRoute(); +} + +class _FilterSubRoute extends _SubRoute { + const _FilterSubRoute(); +} + +class _RecipeSubRoute extends _SubRoute { + const _RecipeSubRoute(this.recipeId); + final String recipeId; +} + +class _LiveAssistantSubRoute extends _SubRoute { + const _LiveAssistantSubRoute(this.recipeId); + final String recipeId; +} + +class FriendlyMealsApp extends StatefulWidget { + const FriendlyMealsApp({ + super.key, + required this.repositories, + }); + + final AppRepositories repositories; + + @override + State createState() => _FriendlyMealsAppState(); +} + +class _FriendlyMealsAppState extends State { + final GlobalKey _scaffoldMessengerKey = + GlobalKey(); + + BottomNavTab _selectedTab = BottomNavTab.generate; + final List<_SubRoute> _subRouteStack = []; + late final RecipeListController _recipeListController; + + @override + void initState() { + super.initState(); + _recipeListController = RecipeListController(widget.repositories); + } + + @override + void dispose() { + _recipeListController.dispose(); + widget.repositories.remoteConfigService.dispose(); + super.dispose(); + } + + void _showError([String message = 'Sorry, something went wrong :(']) { + _scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar(content: Text(message)), + ); + } + + void _onTabSelected(BottomNavTab tab) { + setState(() { + _selectedTab = tab; + _subRouteStack.clear(); + }); + } + + void _openRecipeScreen(String recipeId) { + setState(() { + _subRouteStack.add(_RecipeSubRoute(recipeId)); + }); + } + + void _openFilterScreen() { + setState(() { + _subRouteStack.add(const _FilterSubRoute()); + }); + } + + void _openLiveAssistantScreen(String recipeId) { + setState(() { + _subRouteStack.add(_LiveAssistantSubRoute(recipeId)); + }); + } + + void _popSubRoute() { + if (_subRouteStack.isNotEmpty) { + setState(() { + _subRouteStack.removeLast(); + }); + } + } @override Widget build(BuildContext context) { return MaterialApp( title: 'Friendly Meals', - theme: ThemeData( - primaryColor: const Color(0xFFFFC400), - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFFC400)), + debugShowCheckedModeBanner: false, + scaffoldMessengerKey: _scaffoldMessengerKey, + theme: FriendlyMealsTheme.lightTheme, + home: PopScope( + canPop: _subRouteStack.isEmpty && _selectedTab == BottomNavTab.generate, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (_subRouteStack.isNotEmpty) { + _popSubRoute(); + } else if (_selectedTab != BottomNavTab.generate) { + setState(() { + _selectedTab = BottomNavTab.generate; + }); + } + }, + child: Scaffold( + resizeToAvoidBottomInset: false, + bottomNavigationBar: BottomNavBar( + selectedTab: _selectedTab, + onTabSelected: _onTabSelected, + ), + body: _buildBody(), + ), ), - home: const RecipeGeneratorScreen(), + ); + } + + Widget _buildBody() { + if (_subRouteStack.isNotEmpty) { + final topRoute = _subRouteStack.last; + return switch (topRoute) { + _FilterSubRoute() => FilterScreen( + controller: _recipeListController, + navigateBack: _popSubRoute, + ), + _RecipeSubRoute(:final recipeId) => RecipeScreen( + key: ValueKey('recipe_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: _popSubRoute, + navigateToLiveAssistant: _openLiveAssistantScreen, + ), + _LiveAssistantSubRoute(:final recipeId) => LiveAssistantScreen( + key: ValueKey('live_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: _popSubRoute, + showError: () => _showError('Error loading camera'), + ), + }; + } + + return IndexedStack( + index: _selectedTab.index, + children: [ + ScanMealScreen( + repositories: widget.repositories, + showError: _showError, + ), + GenerateScreen( + repositories: widget.repositories, + openRecipeScreen: _openRecipeScreen, + showError: _showError, + ), + RecipeListScreen( + controller: _recipeListController, + openRecipeScreen: _openRecipeScreen, + openFilterScreen: _openFilterScreen, + ), + GroceryListScreen( + repositories: widget.repositories, + showError: _showError, + ), + ], ); } } diff --git a/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart b/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart deleted file mode 100644 index 34c4d60d..00000000 --- a/firebase-ai-friendly-meals/flutter/lib/recipe_generator_screen.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:firebase_ai/firebase_ai.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; - -class RecipeGeneratorScreen extends StatefulWidget { - const RecipeGeneratorScreen({super.key}); - - @override - State createState() => _RecipeGeneratorScreenState(); -} - -class _RecipeGeneratorScreenState extends State { - final _ingredientsController = TextEditingController(); - final _notesController = TextEditingController(); - bool _showRecipeCard = false; - String _generatedRecipe = ""; - bool _isLoading = false; - - void _handleGenerateRecipe() async { - if (_ingredientsController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Please enter some ingredients.'), - backgroundColor: Colors.red, - ), - ); - return; - } - setState(() { - _isLoading = true; - _showRecipeCard = false; - }); - - try { - final model = FirebaseAI.googleAI().generativeModel( - model: 'gemini-2.5-flash-lite', - ); - - final ingredients = _ingredientsController.text; - final notes = _notesController.text; - String prompt = - "Based on this list of ingredients: $ingredients, please give me a recipe. "; - if (notes.isNotEmpty) { - prompt += "Please also take in consideration these notes: $notes"; - } - final response = await model.generateContent([Content.text(prompt)]); - - setState(() { - _generatedRecipe = response.text ?? 'No recipe generated.'; - _showRecipeCard = true; - }); - } catch (e) { - setState(() { - _generatedRecipe = 'Error generating recipe: $e'; - _showRecipeCard = true; - }); - } finally { - setState(() { - _isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Friendly Meals')), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 16.0), - TextField( - controller: _ingredientsController, - decoration: const InputDecoration( - labelText: 'Enter ingredients (comma separated)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16.0), - TextField( - controller: _notesController, - decoration: const InputDecoration( - labelText: 'Notes or preferred cuisines', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 32.0), - TextButton( - onPressed: _isLoading ? null : _handleGenerateRecipe, - style: TextButton.styleFrom( - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16.0), - textStyle: const TextStyle(fontSize: 18.0), - ), - child: _isLoading - ? const CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ) - : const Text('Generate Recipe'), - ), - const SizedBox(height: 24.0), - Visibility( - visible: _showRecipeCard, - child: Card( - elevation: 2.0, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [MarkdownBody(data: _generatedRecipe)], - ), - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart new file mode 100644 index 00000000..9a1a45aa --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart @@ -0,0 +1,370 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/recipe.dart'; +import '../../data/schema/recipe_schema.dart'; +import '../shared/camera_component.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; + +class GenerateViewState { + const GenerateViewState({ + this.ingredients = '', + this.notes = '', + this.ingredientsLoading = false, + this.recipeLoading = false, + }); + + final String ingredients; + final String notes; + final bool ingredientsLoading; + final bool recipeLoading; + + GenerateViewState copyWith({ + String? ingredients, + String? notes, + bool? ingredientsLoading, + bool? recipeLoading, + }) { + return GenerateViewState( + ingredients: ingredients ?? this.ingredients, + notes: notes ?? this.notes, + ingredientsLoading: ingredientsLoading ?? this.ingredientsLoading, + recipeLoading: recipeLoading ?? this.recipeLoading, + ); + } +} + +class GenerateScreen extends StatefulWidget { + const GenerateScreen({ + super.key, + required this.repositories, + required this.openRecipeScreen, + required this.showError, + }); + + final AppRepositories repositories; + final ValueChanged openRecipeScreen; + final VoidCallback showError; + + @override + State createState() => _GenerateScreenState(); +} + +class _GenerateScreenState extends State { + GenerateViewState _viewState = const GenerateViewState(); + late final TextEditingController _ingredientsController; + late final TextEditingController _notesController; + + @override + void initState() { + super.initState(); + _ingredientsController = TextEditingController(); + _notesController = TextEditingController(); + _loadCurrentUser(); + _loadOnDeviceModel(); + } + + @override + void dispose() { + _ingredientsController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + Future _loadCurrentUser() async { + try { + if (widget.repositories.authRepository.currentUser == null) { + final user = + await widget.repositories.authRepository.createAnonymousAccount(); + await widget.repositories.databaseRepository.addUser(user); + } + } catch (e) { + debugPrint('Error loading current user: $e'); + } + } + + Future _loadOnDeviceModel() async { + try { + await widget.repositories.aiRepository.loadOnDeviceModel(); + } catch (e) { + debugPrint('Error loading on-device model: $e'); + } + } + + void _onIngredientsUpdated(String ingredients) { + setState(() { + _viewState = _viewState.copyWith(ingredients: ingredients); + }); + } + + void _onNotesUpdated(String notes) { + setState(() { + _viewState = _viewState.copyWith(notes: notes); + }); + } + + Future _onImageTaken( + Uint8List? imageBytes, + VoidCallback showError, + ) async { + if (imageBytes == null) { + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith(ingredientsLoading: true); + }); + + try { + final ingredients = await widget.repositories.aiRepository + .generateIngredients(imageBytes); + if (!mounted) return; + _ingredientsController.text = ingredients; + setState(() { + _viewState = _viewState.copyWith( + ingredientsLoading: false, + ingredients: ingredients, + ); + }); + } catch (e) { + debugPrint('Error generating ingredients from image: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(ingredientsLoading: false); + }); + showError(); + } + } + + Future _generateRecipe( + ValueChanged openRecipeScreen, + VoidCallback showError, + ) async { + setState(() { + _viewState = _viewState.copyWith(recipeLoading: true); + }); + + try { + final generatedRecipe = await widget.repositories.aiRepository + .generateRecipe(_viewState.ingredients, _viewState.notes); + + if (generatedRecipe == null) { + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + showError(); + return; + } + + Uint8List? recipeImage; + try { + recipeImage = await widget.repositories.aiRepository + .generateRecipePhoto(generatedRecipe.title); + } catch (e) { + debugPrint('Error generating recipe photo: $e'); + } + + String? recipeImageUri; + if (recipeImage != null) { + try { + recipeImageUri = + await widget.repositories.storageRepository.addImage(recipeImage); + } catch (e) { + debugPrint('Error uploading recipe image: $e'); + } + } + + final storedRecipeId = + await widget.repositories.databaseRepository.addRecipe( + _toRecipe( + generatedRecipe, + authorId: + widget.repositories.authRepository.currentUser?.uid ?? '', + imageUri: recipeImageUri, + ), + ); + + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + + openRecipeScreen(storedRecipeId); + } catch (e) { + debugPrint('Error generating recipe: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(recipeLoading: false); + }); + showError(); + } + } + + Recipe _toRecipe( + RecipeSchema schema, { + required String authorId, + required String? imageUri, + }) { + return Recipe( + title: schema.title, + instructions: schema.instructions, + ingredients: schema.ingredients, + authorId: authorId, + tags: schema.tags, + prepTime: schema.prepTime, + cookTime: schema.cookTime, + servings: schema.servings, + imageUri: imageUri, + ); + } + + @override + Widget build(BuildContext context) { + final ingredientsHint = _viewState.ingredientsLoading + ? 'Loading…' + : 'e.g., pasta, tomato, garlic, bacon, eggs'; + + final generateButtonEnabled = _viewState.ingredients.trim().isNotEmpty && + !_viewState.recipeLoading && + !_viewState.ingredientsLoading; + + final borderSide = BorderSide(color: Colors.grey.shade400); + final outlineBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: borderSide, + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: const Text( + 'New recipe', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'List your ingredients', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 120, + child: TextField( + controller: _ingredientsController, + onChanged: _onIngredientsUpdated, + enabled: !_viewState.ingredientsLoading, + expands: true, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: ingredientsHint, + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + disabledBorder: outlineBorder, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + const SizedBox(height: 24), + CameraComponent( + onImageTaken: _onImageTaken, + showError: widget.showError, + isTopBarIcon: false, + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Any special notes or cuisines?', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + SizedBox( + height: 120, + child: TextField( + controller: _notesController, + onChanged: _onNotesUpdated, + expands: true, + maxLines: null, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: 'e.g., vegetarian, gluten-free, Italian', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + const SizedBox(height: 48), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: generateButtonEnabled + ? () => _generateRecipe( + widget.openRecipeScreen, + widget.showError, + ) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.shade300, + disabledForegroundColor: Colors.grey, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + ), + child: const Text( + 'Generate Recipe', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 24), + if (_viewState.recipeLoading || _viewState.ingredientsLoading) ...[ + const SizedBox(height: 24), + const LoadingIndicator(), + ], + ], + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart new file mode 100644 index 00000000..42ab53f4 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart @@ -0,0 +1,847 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:intl/intl.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/grocery_item.dart'; +import '../../data/schema/store_schema.dart'; +import '../theme/app_theme.dart'; + +sealed class StoreFinderUiState { + const StoreFinderUiState(); +} + +class StoreFinderIdle extends StoreFinderUiState { + const StoreFinderIdle(); +} + +class StoreFinderLoading extends StoreFinderUiState { + const StoreFinderLoading(); +} + +class StoreFinderSuccess extends StoreFinderUiState { + const StoreFinderSuccess(this.stores); + final List stores; +} + +class StoreFinderError extends StoreFinderUiState { + const StoreFinderError(this.message); + final String message; +} + +class GroceryListScreen extends StatefulWidget { + const GroceryListScreen({ + super.key, + required this.repositories, + required this.showError, + }); + + final AppRepositories repositories; + final VoidCallback showError; + + @override + State createState() => _GroceryListScreenState(); +} + +class _GroceryListScreenState extends State { + static const String _emptyItemsError = + 'Your grocery list is empty or all items are checked.'; + static const String _emptyStoreError = 'Failed to fetch local stores.'; + + final TextEditingController _inputController = TextEditingController(); + final ValueNotifier _storeFinderState = + ValueNotifier(const StoreFinderIdle()); + + List _groceries = const []; + StreamSubscription>? _groceriesSub; + + String get _userId => + widget.repositories.authRepository.currentUser?.uid ?? ''; + + @override + void initState() { + super.initState(); + _loadGroceries(); + } + + @override + void dispose() { + _groceriesSub?.cancel(); + _inputController.dispose(); + _storeFinderState.dispose(); + super.dispose(); + } + + void _loadGroceries() { + final uid = _userId; + if (uid.isEmpty) return; + + _groceriesSub = widget.repositories.databaseRepository + .getGroceriesStream(uid) + .listen( + (items) { + if (!mounted) return; + setState(() { + _groceries = items; + }); + }, + onError: (Object e) { + debugPrint('Error streaming groceries: $e'); + }, + ); + } + + Future _handleAdd() async { + final text = _inputController.text.trim(); + if (text.isEmpty || _userId.isEmpty) return; + _inputController.clear(); + + try { + final item = GroceryItem( + userId: _userId, + name: text, + checked: false, + ); + await widget.repositories.databaseRepository.addGroceryItem(item); + } catch (e) { + debugPrint('Error adding grocery item: $e'); + } + } + + Future _toggleItem(GroceryItem item) async { + try { + await widget.repositories.databaseRepository + .updateGroceryItemChecked(item.id, !item.checked); + } catch (e) { + debugPrint('Error toggling grocery item: $e'); + } + } + + Future _deleteItem(GroceryItem item) async { + try { + await widget.repositories.databaseRepository.deleteGroceryItem(item.id); + } catch (e) { + debugPrint('Error deleting grocery item: $e'); + } + } + + Future _startStoreFinder() async { + if (!kIsWeb) { + final statuses = await [ + Permission.locationWhenInUse, + Permission.location, + ].request(); + final granted = + (statuses[Permission.locationWhenInUse]?.isGranted ?? false) || + (statuses[Permission.location]?.isGranted ?? false); + if (!granted) { + return; + } + } + + if (!mounted) return; + _storeFinderState.value = const StoreFinderIdle(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (sheetContext) { + return _StoreFinderBottomSheet( + uiStateenable: _storeFinderState, + onRetry: _fetchLocationAndStores, + ); + }, + ).whenComplete(() { + _storeFinderState.value = const StoreFinderIdle(); + }); + + await _fetchLocationAndStores(); + } + + Future _fetchLocationAndStores() async { + _storeFinderState.value = const StoreFinderIdle(); + try { + final position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + await _findStores(position.latitude, position.longitude); + } catch (e) { + debugPrint('Error getting current location: $e'); + if (mounted) { + Navigator.of(context).maybePop(); + widget.showError(); + } + } + } + + Future _findStores(double latitude, double longitude) async { + final uncheckedIngredients = _groceries + .where((item) => !item.checked) + .map((item) => item.name) + .toList(); + + if (uncheckedIngredients.isEmpty) { + _storeFinderState.value = const StoreFinderError(_emptyItemsError); + return; + } + + _storeFinderState.value = const StoreFinderLoading(); + + try { + final now = DateTime.now(); + final dayOfWeek = DateFormat('EEEE', 'en_US').format(now); + final currentTime = + '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + + final stores = await widget.repositories.aiRepository.findStores( + ingredients: uncheckedIngredients, + latitude: latitude, + longitude: longitude, + currentTime: currentTime, + dayOfWeek: dayOfWeek, + ); + + if (stores.isEmpty) { + _storeFinderState.value = const StoreFinderError(_emptyStoreError); + } else { + _storeFinderState.value = StoreFinderSuccess(stores); + } + } catch (e) { + debugPrint('Error finding stores: $e'); + _storeFinderState.value = const StoreFinderError(_emptyStoreError); + } + } + + @override + Widget build(BuildContext context) { + final unfocusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.borderColor), + ); + final focusedBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide(color: AppColors.teal, width: 1.5), + ); + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + children: [ + const Text( + 'Grocery List', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + SizedBox( + width: 48, + height: 48, + child: IconButton( + onPressed: _startStoreFinder, + tooltip: 'Store Finder', + icon: const Icon( + Icons.location_on_outlined, + color: AppColors.teal, + size: 28, + ), + ), + ), + ], + ), + ), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 54, + child: TextField( + controller: _inputController, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _handleAdd(), + decoration: InputDecoration( + hintText: 'Add an ingredient…', + hintStyle: const TextStyle(color: Colors.grey), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: unfocusedBorder, + enabledBorder: unfocusedBorder, + focusedBorder: focusedBorder, + ), + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: 54, + height: 54, + child: ElevatedButton( + onPressed: _handleAdd, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + padding: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Icon( + Icons.add, + color: Colors.white, + size: 24, + semanticLabel: 'Add', + ), + ), + ), + ], + ), + ), + Expanded( + child: _groceries.isEmpty + ? const Center( + child: Text( + 'Your grocery list is empty', + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + ) + : ListView.separated( + padding: const EdgeInsets.only(bottom: 24), + itemCount: _groceries.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final item = _groceries[index]; + return _GroceryCard( + key: ValueKey(item.id), + item: item, + onToggle: () => _toggleItem(item), + onDelete: () => _deleteItem(item), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _GroceryCard extends StatelessWidget { + const _GroceryCard({ + super.key, + required this.item, + required this.onToggle, + required this.onDelete, + }); + + final GroceryItem item; + final VoidCallback onToggle; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: Colors.white, + elevation: item.checked ? 0 : 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: item.checked + ? AppColors.teal + : AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: item.checked + ? const Icon( + Icons.check, + color: Colors.white, + size: 16, + ) + : Container( + width: 14, + height: 14, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: item.checked + ? Colors.grey + : AppColors.textColor, + decoration: item.checked + ? TextDecoration.lineThrough + : TextDecoration.none, + height: 22 / 16, + ), + ), + ), + ], + ), + ), + SizedBox( + width: 36, + height: 36, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onDelete, + tooltip: 'Delete', + icon: const Icon( + Icons.delete_outline, + color: Colors.grey, + size: 20, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _StoreFinderBottomSheet extends StatelessWidget { + const _StoreFinderBottomSheet({ + required this.uiStateenable, + required this.onRetry, + }); + + final ValueListenable uiStateenable; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: uiStateenable, + builder: (context, uiState, _) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Ingredient-to-Store Finder', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ), + const Padding( + padding: EdgeInsets.only(bottom: 20), + child: Text( + 'Find stores nearby with the ingredients you need.', + style: TextStyle(fontSize: 14), + ), + ), + Flexible( + child: switch (uiState) { + StoreFinderIdle() => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + color: AppColors.teal, + ), + ), + SizedBox(height: 16), + Text( + 'Determining your location…', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + StoreFinderLoading() => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 48, + height: 48, + child: CircularProgressIndicator( + color: AppColors.teal, + ), + ), + SizedBox(height: 16), + Text( + 'Locating nearest stores with Google Maps…', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + StoreFinderError(:final message) => Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.red, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + ), + child: const Text('Retry'), + ), + ], + ), + ), + ), + StoreFinderSuccess(:final stores) => stores.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text( + 'No stores found matching your ingredients nearby.', + ), + ), + ) + : ListView.separated( + shrinkWrap: true, + itemCount: stores.length, + separatorBuilder: (_, _) => + const SizedBox(height: 16), + itemBuilder: (context, index) { + return _StoreCard(store: stores[index]); + }, + ), + }, + ), + ], + ), + ), + ); + }, + ); + } +} + +class _StoreCard extends StatelessWidget { + const _StoreCard({required this.store}); + + final StoreSchema store; + + Future _openMapUrl() async { + final rawUrl = store.mapUrl.trim(); + if (rawUrl.isEmpty) return; + + final uri = Uri.tryParse(rawUrl); + if (uri == null) return; + if (uri.scheme != 'https' && uri.scheme != 'http' && uri.scheme != 'geo') { + return; + } + + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (e) { + debugPrint('Could not launch store map URL: $e'); + } + } + + @override + Widget build(BuildContext context) { + final parkingColor = + store.hasParking ? const Color(0xFFE3F2FD) : const Color(0xFFECEFF1); + final parkingTextColor = + store.hasParking ? const Color(0xFF1565C0) : const Color(0xFF455A64); + final parkingLabel = + store.hasParking ? '🅿️ Parking Available' : 'No Parking'; + + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: Colors.white, + elevation: 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: store.mapUrl.trim().isNotEmpty ? _openMapUrl : null, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + store.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + ), + ), + const SizedBox(height: 4), + Text( + store.address, + style: const TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.location_on_outlined, + size: 14, + color: Color(0xFF2E7D32), + ), + const SizedBox(width: 4), + Text( + store.distance, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF2E7D32), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (store.openNow) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFE8F5E9), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFF4CAF50), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Open Now', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF2E7D32), + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFEBEE), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFFE57373), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Closed', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFC62828), + ), + ), + ], + ), + ), + if (store.closingSoon) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFFFF9800), + shape: BoxShape.circle, + ), + child: SizedBox(width: 8, height: 8), + ), + SizedBox(width: 6), + Text( + 'Closing Soon', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFEF6C00), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: parkingColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + parkingLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: parkingTextColor, + ), + ), + ), + ], + ), + if (store.parkingDetails.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + store.parkingDetails, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + height: 16 / 12, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart new file mode 100644 index 00000000..cddaae60 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart @@ -0,0 +1,482 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:record/record.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/datasource/live_ai_remote_datasource.dart'; +import '../../data/model/grocery_item.dart'; +import '../../data/model/recipe.dart'; +import '../shared/audio_component.dart'; +import '../theme/app_theme.dart'; + +sealed class LiveAssistantUiState { + const LiveAssistantUiState(); +} + +class LiveAssistantLoading extends LiveAssistantUiState { + const LiveAssistantLoading(); +} + +class LiveAssistantSuccess extends LiveAssistantUiState { + const LiveAssistantSuccess(this.recipe); + final Recipe recipe; +} + +class LiveAssistantError extends LiveAssistantUiState { + const LiveAssistantError(this.message); + final String message; +} + +class LiveAssistantScreen extends StatefulWidget { + const LiveAssistantScreen({ + super.key, + required this.recipeId, + required this.repositories, + required this.navigateBack, + required this.showError, + }); + + final String recipeId; + final AppRepositories repositories; + final VoidCallback navigateBack; + final VoidCallback showError; + + @override + State createState() => _LiveAssistantScreenState(); +} + +class _LiveAssistantScreenState extends State { + static const String _mimeType = 'image/jpeg'; + static const String _recipeError = 'Failed to load recipe'; + static const String _connectionError = 'Failed to connect to live assistant'; + + LiveAssistantUiState _uiState = const LiveAssistantLoading(); + + LiveSession? _liveSession; + bool _isConnected = false; + int _lastFrameTimeMs = 0; + bool _encodingFrame = false; + + CameraController? _cameraController; + final AudioRecorder _audioRecorder = AudioRecorder(); + StreamSubscription? _micSubscription; + StreamSubscription? _serverSubscription; + + final BytesBuilder _incomingAudioBuffer = BytesBuilder(copy: false); + final List _playbackQueue = []; + bool _isPlayingAudio = false; + final AudioComponent _audioComponent = AudioComponent(); + + @override + void initState() { + super.initState(); + _loadRecipeAndConnect(); + } + + @override + void dispose() { + _endConversation(); + super.dispose(); + } + + Future _loadRecipeAndConnect() async { + try { + final recipe = await widget.repositories.databaseRepository + .getRecipe(widget.recipeId); + + if (!mounted) return; + if (recipe.title.trim().isEmpty) { + setState(() { + _uiState = const LiveAssistantError(_recipeError); + }); + return; + } + + setState(() { + _uiState = LiveAssistantSuccess(recipe); + }); + await _initializeCamera(); + await _setupLiveSession(recipe); + } catch (e) { + debugPrint('Error in _loadRecipeAndConnect: $e'); + if (!mounted) return; + setState(() { + _uiState = const LiveAssistantError(_recipeError); + }); + } + } + + Future _initializeCamera() async { + try { + final cameras = await availableCameras(); + if (cameras.isEmpty) { + widget.showError(); + return; + } + + final backCamera = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first, + ); + + final controller = CameraController( + backCamera, + ResolutionPreset.medium, + enableAudio: false, + imageFormatGroup: ImageFormatGroup.yuv420, + ); + + await controller.initialize(); + if (!mounted) { + await controller.dispose(); + return; + } + + setState(() { + _cameraController = controller; + }); + + await controller.startImageStream(_onCameraFrame); + } catch (e) { + debugPrint('Error initializing camera for Live Assistant: $e'); + widget.showError(); + } + } + + void _onCameraFrame(CameraImage cameraImage) { + if (!_isConnected || _liveSession == null || _encodingFrame) return; + + final nowMs = DateTime.now().millisecondsSinceEpoch; + // Limit sending frames to once per second to conserve bandwidth and processing + if (nowMs - _lastFrameTimeMs < 1000) return; + _lastFrameTimeMs = nowMs; + _encodingFrame = true; + + _convertCameraImageToJpeg(cameraImage).then((jpegBytes) { + if (jpegBytes != null && _isConnected && _liveSession != null) { + _liveSession!.sendVideoRealtime(InlineDataPart(_mimeType, jpegBytes)); + } + }).catchError((Object e) { + debugPrint('Error sending video frame: $e'); + }).whenComplete(() { + _encodingFrame = false; + }); + } + + Future _convertCameraImageToJpeg(CameraImage image) async { + try { + if (image.planes.isEmpty) return null; + final width = image.width; + final height = image.height; + final yPlane = image.planes[0]; + final yBytes = Uint8List.fromList(yPlane.bytes); + final yRowStride = yPlane.bytesPerRow; + + return compute( + _encodeGrayscaleOrYuvFrame, + _FrameData( + width: width, + height: height, + yBytes: yBytes, + yRowStride: yRowStride, + ), + ); + } catch (e) { + debugPrint('Error converting camera frame: $e'); + return null; + } + } + + static Uint8List _encodeGrayscaleOrYuvFrame(_FrameData data) { + final buffer = img.Image(width: data.width, height: data.height); + for (int y = 0; y < data.height; y++) { + final rowOffset = y * data.yRowStride; + for (int x = 0; x < data.width; x++) { + final index = rowOffset + x; + final lum = index < data.yBytes.length ? data.yBytes[index] : 0; + buffer.setPixelRgb(x, y, lum, lum, lum); + } + } + return Uint8List.fromList(img.encodeJpg(buffer, quality: 80)); + } + + Future _setupLiveSession(Recipe recipe) async { + final session = + await widget.repositories.liveAiRepository.setupLiveSession(recipe); + + if (!mounted) { + await session?.close(); + return; + } + + if (session == null) { + setState(() { + _uiState = const LiveAssistantError(_connectionError); + }); + } else { + _liveSession = session; + _isConnected = true; + await _startConversation(); + } + } + + Future _startConversation() async { + final session = _liveSession; + if (session == null) return; + + _serverSubscription = session.receive().listen( + (response) async { + final message = response.message; + if (message is LiveServerContent) { + final parts = message.modelTurn?.parts ?? const []; + for (final part in parts) { + if (part is InlineDataPart && + part.mimeType.startsWith('audio/pcm')) { + _incomingAudioBuffer.add(part.bytes); + } + } + if (message.turnComplete == true || + _incomingAudioBuffer.length >= 48000) { + final bytes = _incomingAudioBuffer.takeBytes(); + if (bytes.isNotEmpty) { + _enqueueAudioPlayback(bytes); + } + } + } else if (message is LiveServerToolCall) { + final calls = message.functionCalls ?? const []; + if (calls.isNotEmpty) { + final responses = []; + for (final call in calls) { + responses.add(await _handleFunctionCall(call)); + } + await session.sendToolResponse(responses); + } + } + }, + onError: (Object error) { + debugPrint('LiveSession receive error: $error'); + }, + ); + + try { + final hasPermission = await _audioRecorder.hasPermission(); + if (hasPermission) { + final micStream = await _audioRecorder.startStream( + const RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: 16000, + numChannels: 1, + ), + ); + _micSubscription = micStream.listen((chunk) { + if (_isConnected && _liveSession != null && chunk.isNotEmpty) { + _liveSession!.sendAudioRealtime( + InlineDataPart('audio/pcm;rate=16000', chunk), + ); + } + }); + } + } catch (e) { + debugPrint('Error starting microphone stream: $e'); + } + } + + void _enqueueAudioPlayback(Uint8List pcmBytes) { + _playbackQueue.add(pcmBytes); + if (!_isPlayingAudio) { + _playNextAudioChunk(); + } + } + + Future _playNextAudioChunk() async { + if (_playbackQueue.isEmpty || !mounted) { + _isPlayingAudio = false; + return; + } + _isPlayingAudio = true; + final nextChunk = _playbackQueue.removeAt(0); + await _audioComponent.play(nextChunk, () { + _playNextAudioChunk(); + }); + } + + Future _handleFunctionCall(FunctionCall functionCall) async { + if (functionCall.name == LiveAIRemoteDataSource.addIngredientsToolName) { + final rawArg = + functionCall.args[LiveAIRemoteDataSource.ingredientFieldName]; + final ingredientName = + rawArg?.toString().trim().replaceAll('"', '') ?? ''; + + if (ingredientName.isNotEmpty) { + final userId = + widget.repositories.authRepository.currentUser?.uid ?? ''; + if (userId.isNotEmpty) { + final item = GroceryItem( + userId: userId, + name: ingredientName, + checked: false, + ); + await widget.repositories.databaseRepository.addGroceryItem(item); + } + } + + return FunctionResponse( + functionCall.name, + {'result': 'Successfully added $ingredientName to grocery list'}, + id: functionCall.id, + ); + } + + return FunctionResponse( + functionCall.name, + const {}, + id: functionCall.id, + ); + } + + Future _endConversation() async { + _isConnected = false; + await _micSubscription?.cancel(); + _micSubscription = null; + try { + await _audioRecorder.stop(); + await _audioRecorder.dispose(); + } catch (_) {} + await _serverSubscription?.cancel(); + _serverSubscription = null; + await _audioComponent.release(); + final cam = _cameraController; + _cameraController = null; + if (cam != null) { + try { + if (cam.value.isStreamingImages) { + await cam.stopImageStream(); + } + await cam.dispose(); + } catch (_) {} + } + final session = _liveSession; + _liveSession = null; + try { + await session?.close(); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: const Icon( + Icons.arrow_back, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 16), + const Text( + 'Live Cooking Assistant', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + Expanded( + child: switch (_uiState) { + LiveAssistantLoading() => const Center( + child: CircularProgressIndicator(color: AppColors.teal), + ), + LiveAssistantError(:final message) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + message, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ), + ), + LiveAssistantSuccess() => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + fit: StackFit.expand, + children: [ + if (_cameraController != null && + _cameraController!.value.isInitialized) + CameraPreview(_cameraController!) + else + const ColoredBox(color: Colors.black), + Align( + alignment: Alignment.bottomCenter, + child: Container( + width: double.infinity, + color: Colors.black.withValues(alpha: 0.6), + padding: const EdgeInsets.all(16), + child: const Text( + 'Point your camera at your cooking and ask questions', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + ), + ), + }, + ), + ], + ), + ), + ); + } +} + +class _FrameData { + const _FrameData({ + required this.width, + required this.height, + required this.yBytes, + required this.yRowStride, + }); + + final int width; + final int height; + final Uint8List yBytes; + final int yRowStride; +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart new file mode 100644 index 00000000..c5086114 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe/recipe_screen.dart @@ -0,0 +1,859 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/like.dart'; +import '../../data/model/recipe.dart'; +import '../../data/model/review.dart'; +import '../shared/audio_component.dart'; +import '../shared/rating_button.dart'; +import '../theme/app_theme.dart'; + +sealed class RecipeAudioState { + const RecipeAudioState(); +} + +class RecipeAudioIdle extends RecipeAudioState { + const RecipeAudioIdle(); +} + +class RecipeAudioLoading extends RecipeAudioState { + const RecipeAudioLoading(); +} + +class RecipeAudioPlaying extends RecipeAudioState { + const RecipeAudioPlaying(); +} + +class RecipeAudioPaused extends RecipeAudioState { + const RecipeAudioPaused(); +} + +class RecipeAudioError extends RecipeAudioState { + const RecipeAudioError(this.message); + final String message; +} + +class RecipeViewState { + const RecipeViewState({ + this.recipeId = '', + this.recipe = const Recipe(), + this.favorite = false, + this.rating = 0, + this.audioState = const RecipeAudioIdle(), + }); + + final String recipeId; + final Recipe recipe; + final bool favorite; + final int rating; + final RecipeAudioState audioState; + + RecipeViewState copyWith({ + String? recipeId, + Recipe? recipe, + bool? favorite, + int? rating, + RecipeAudioState? audioState, + }) { + return RecipeViewState( + recipeId: recipeId ?? this.recipeId, + recipe: recipe ?? this.recipe, + favorite: favorite ?? this.favorite, + rating: rating ?? this.rating, + audioState: audioState ?? this.audioState, + ); + } +} + +class RecipeScreen extends StatefulWidget { + const RecipeScreen({ + super.key, + required this.recipeId, + required this.repositories, + required this.navigateBack, + required this.navigateToLiveAssistant, + }); + + final String recipeId; + final AppRepositories repositories; + final VoidCallback navigateBack; + final ValueChanged navigateToLiveAssistant; + + @override + State createState() => _RecipeScreenState(); +} + +class _RecipeScreenState extends State { + RecipeViewState _recipeViewState = const RecipeViewState(); + final AudioComponent _audioComponent = AudioComponent(); + Uint8List? _cachedAudioData; + String? _cachedPairingText; + + String get _userId => + widget.repositories.authRepository.currentUser?.uid ?? ''; + + @override + void initState() { + super.initState(); + _loadRecipe(); + } + + @override + void dispose() { + _audioComponent.release(); + super.dispose(); + } + + Future _loadRecipe() async { + try { + final results = await Future.wait([ + widget.repositories.databaseRepository.getRecipe(widget.recipeId), + widget.repositories.databaseRepository + .getFavorite(_userId, widget.recipeId), + widget.repositories.databaseRepository + .getRating(_userId, widget.recipeId), + ]); + + if (!mounted) return; + setState(() { + _recipeViewState = RecipeViewState( + recipeId: widget.recipeId, + recipe: results[0] as Recipe, + favorite: results[1] as bool, + rating: results[2] as int, + audioState: const RecipeAudioIdle(), + ); + }); + } catch (e) { + debugPrint('Error loading recipe: $e'); + } + } + + Future _onPairingClick() async { + switch (_recipeViewState.audioState) { + case RecipeAudioPlaying(): + await _audioComponent.pause(); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPaused(), + ); + }); + case RecipeAudioPaused(): + await _audioComponent.resume(); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + case RecipeAudioLoading(): + break; + case RecipeAudioIdle(): + case RecipeAudioError(): + if (_cachedAudioData != null) { + await _playCachedAudio(); + } else { + await _generatePairingAndAudio(); + } + } + } + + Future _playCachedAudio() async { + final audioData = _cachedAudioData; + if (audioData == null) return; + + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + + await _audioComponent.play(audioData, () { + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioIdle(), + ); + }); + }); + } + + Future _generatePairingAndAudio() async { + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioLoading(), + ); + }); + + try { + if (_cachedPairingText == null || _cachedPairingText!.trim().isEmpty) { + final recipe = _recipeViewState.recipe; + final generated = await widget.repositories.aiRepository + .craftRecipePairing(recipe.title, recipe.ingredients); + _cachedPairingText = generated; + } + + final audio = await widget.repositories.aiRepository + .generateSpeech(_cachedPairingText); + + if (!mounted) return; + if (audio != null && audio.isNotEmpty) { + _cachedAudioData = audio; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioPlaying(), + ); + }); + await _audioComponent.play(audio, () { + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioIdle(), + ); + }); + }); + } else { + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioError('Could not generate audio'), + ); + }); + } + } catch (e) { + debugPrint('Error generating pairing audio: $e'); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith( + audioState: const RecipeAudioError('Could not generate audio'), + ); + }); + } + } + + Future _toggleFavorite() async { + final like = Like(recipeId: widget.recipeId, userId: _userId); + try { + if (_recipeViewState.favorite) { + await widget.repositories.databaseRepository.removeFavorite(like); + } else { + await widget.repositories.databaseRepository.setFavorite(like); + } + + final favorite = await widget.repositories.databaseRepository + .getFavorite(_userId, widget.recipeId); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith(favorite: favorite); + }); + } catch (e) { + debugPrint('Error toggling favorite: $e'); + } + } + + Future _leaveReview(int rating) async { + try { + await widget.repositories.databaseRepository.setReview( + Review( + userId: _userId, + recipeId: widget.recipeId, + rating: rating, + ), + ); + + final updatedRating = await widget.repositories.databaseRepository + .getRating(_userId, widget.recipeId); + if (!mounted) return; + setState(() { + _recipeViewState = _recipeViewState.copyWith(rating: updatedRating); + }); + } catch (e) { + debugPrint('Error leaving review: $e'); + } + } + + Future _onAddIngredientsToGrocery() async { + final ingredients = _recipeViewState.recipe.ingredients; + if (_userId.isEmpty || ingredients.isEmpty) return; + + try { + await widget.repositories.databaseRepository + .addIngredientsToGroceries(_userId, ingredients); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Added to Grocery List!'), + duration: Duration(seconds: 2), + ), + ); + } catch (e) { + debugPrint('Error adding ingredients to grocery list: $e'); + } + } + + Future _onLiveAssistantPressed() async { + if (!kIsWeb) { + final statuses = await [ + Permission.camera, + Permission.microphone, + ].request(); + final cameraGranted = statuses[Permission.camera]?.isGranted ?? false; + final audioGranted = statuses[Permission.microphone]?.isGranted ?? false; + + if (!cameraGranted || !audioGranted) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Camera and Microphone permissions are required to use the Live Assistant.', + ), + ), + ); + return; + } + } + + widget.navigateToLiveAssistant(_recipeViewState.recipeId); + } + + @override + Widget build(BuildContext context) { + final recipe = _recipeViewState.recipe; + final imageUri = recipe.imageUri; + + return Scaffold( + body: SafeArea( + top: false, + child: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + SizedBox( + width: double.infinity, + height: 300, + child: Stack( + fit: StackFit.expand, + children: [ + Container( + color: Colors.grey.shade400, + child: imageUri != null && imageUri.isNotEmpty + ? Image.network( + imageUri, + fit: BoxFit.cover, + semanticLabel: 'Recipe image', + errorBuilder: (_, _, _) => const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ) + : const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ), + SafeArea( + bottom: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only( + top: 16, + left: 16, + right: 16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _FloatingHeaderIconButton( + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: Icons.arrow_back, + ), + _FloatingHeaderIconButton( + onPressed: _toggleFavorite, + tooltip: 'Favorite', + icon: _recipeViewState.favorite + ? Icons.favorite + : Icons.favorite_border, + ), + ], + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + Text( + recipe.title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + height: 34 / 28, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _onLiveAssistantPressed, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.soup_kitchen_outlined, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + 'Live Cooking Assistant', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + _PairingGuideButton( + audioState: _recipeViewState.audioState, + onClick: _onPairingClick, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: _InfoCard( + icon: Icons.timer_outlined, + label: 'Prep Time', + value: recipe.prepTime, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _InfoCard( + icon: Icons.soup_kitchen_outlined, + label: 'Cook Time', + value: recipe.cookTime, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _InfoCard( + icon: Icons.people_outline, + label: 'Servings', + value: recipe.servings, + ), + ), + ], + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ingredients', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + ...recipe.ingredients + .map((item) => _IngredientRow(text: item)), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _onAddIngredientsToGrocery, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightTeal, + foregroundColor: AppColors.teal, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check, + color: AppColors.teal, + size: 18, + ), + SizedBox(width: 8), + Text( + 'Add ingredients to Grocery List', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Instructions', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + MarkdownBody( + data: recipe.instructions, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context), + ).copyWith( + p: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + height: 1.5, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rate this recipe', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (int index = 0; index < 5; index++) + RatingButton( + rating: index + 1, + isSelected: + (index + 1) == _recipeViewState.rating, + isFilled: + (index + 1) < _recipeViewState.rating, + onClick: () => _leaveReview(index + 1), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _FloatingHeaderIconButton extends StatelessWidget { + const _FloatingHeaderIconButton({ + required this.onPressed, + required this.tooltip, + required this.icon, + }); + + final VoidCallback onPressed; + final String tooltip; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + onPressed: onPressed, + tooltip: tooltip, + icon: Icon( + icon, + color: AppColors.textColor, + size: 22, + ), + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({ + required this.icon, + required this.label, + required this.value, + }); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: BoxDecoration( + color: AppColors.lightTeal, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon( + icon, + color: AppColors.teal, + size: 20, + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 2), + Text( + value, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + ), + ), + ], + ), + ); + } +} + +class _IngredientRow extends StatelessWidget { + const _IngredientRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon( + Icons.check, + size: 14, + color: AppColors.teal, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + ), + ), + ), + ], + ), + ); + } +} + +class _PairingGuideButton extends StatelessWidget { + const _PairingGuideButton({ + required this.audioState, + required this.onClick, + }); + + final RecipeAudioState audioState; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final Color containerColor = switch (audioState) { + RecipeAudioPlaying() || RecipeAudioPaused() => AppColors.teal, + RecipeAudioLoading() || RecipeAudioIdle() => AppColors.lightTeal, + RecipeAudioError() => const Color(0xFFFFEBEE), + }; + + final Color contentColor = switch (audioState) { + RecipeAudioPlaying() || RecipeAudioPaused() => Colors.white, + RecipeAudioError() => Colors.red, + RecipeAudioLoading() || RecipeAudioIdle() => AppColors.teal, + }; + + return SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: onClick, + style: ElevatedButton.styleFrom( + backgroundColor: containerColor, + foregroundColor: contentColor, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: switch (audioState) { + RecipeAudioLoading() => const [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: AppColors.teal, + strokeWidth: 2, + ), + ), + SizedBox(width: 10), + Text( + 'Loading audio…', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + ], + RecipeAudioPlaying() => const [ + Icon(Icons.pause, color: Colors.white, size: 20), + SizedBox(width: 10), + Text( + 'Playing pairing guide • Tap to pause', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + RecipeAudioPaused() => const [ + Icon(Icons.play_arrow, color: Colors.white, size: 20), + SizedBox(width: 10), + Text( + 'Paused • Tap to resume', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + RecipeAudioError() => const [ + Text( + 'Error playing audio • Tap to retry', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + RecipeAudioIdle() => const [ + Icon(Icons.wine_bar_outlined, color: AppColors.teal, size: 20), + SizedBox(width: 10), + Text( + 'Pair this dish with French wine', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + ], + }, + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart new file mode 100644 index 00000000..9aa7132b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_options.dart @@ -0,0 +1,45 @@ +enum SortByFilter { + defaultSort('Default'), + rating('Rating'), + alphabetical('Alphabetical'), + popularity('Popularity'); + + const SortByFilter(this.title); + final String title; +} + +class FilterOptions { + const FilterOptions({ + this.recipeTitle = '', + this.searchQuery = '', + this.filterByMine = false, + this.rating = 0, + this.selectedTags = const [], + this.sortBy = SortByFilter.defaultSort, + }); + + final String recipeTitle; + final String searchQuery; + final bool filterByMine; + final int rating; + final List selectedTags; + final SortByFilter sortBy; + + FilterOptions copyWith({ + String? recipeTitle, + String? searchQuery, + bool? filterByMine, + int? rating, + List? selectedTags, + SortByFilter? sortBy, + }) { + return FilterOptions( + recipeTitle: recipeTitle ?? this.recipeTitle, + searchQuery: searchQuery ?? this.searchQuery, + filterByMine: filterByMine ?? this.filterByMine, + rating: rating ?? this.rating, + selectedTags: selectedTags ?? this.selectedTags, + sortBy: sortBy ?? this.sortBy, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart new file mode 100644 index 00000000..8169f21d --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart @@ -0,0 +1,356 @@ +import 'package:flutter/material.dart'; + +import '../shared/rating_button.dart'; +import '../theme/app_theme.dart'; +import 'filter_options.dart'; +import 'recipe_list_controller.dart'; + +class FilterScreen extends StatefulWidget { + const FilterScreen({ + super.key, + required this.controller, + required this.navigateBack, + }); + + final RecipeListController controller; + final VoidCallback navigateBack; + + @override + State createState() => _FilterScreenState(); +} + +class _FilterScreenState extends State { + late final TextEditingController _searchController; + late final TextEditingController _recipeTitleController; + + @override + void initState() { + super.initState(); + final filter = widget.controller.filterOptions; + _searchController = TextEditingController(text: filter.searchQuery); + _recipeTitleController = TextEditingController(text: filter.recipeTitle); + } + + @override + void dispose() { + _searchController.dispose(); + _recipeTitleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final outlineBorder = OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: AppColors.borderColor), + ); + + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final filterOptions = widget.controller.filterOptions; + final tags = widget.controller.tags; + + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text( + 'Filter Recipes', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + leading: IconButton( + onPressed: widget.navigateBack, + tooltip: 'Back', + icon: const Icon(Icons.arrow_back), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Search', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + TextField( + controller: _searchController, + onChanged: widget.controller.updateSearchQuery, + decoration: InputDecoration( + hintText: 'Search recipes…', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Recipe Title', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + TextField( + controller: _recipeTitleController, + onChanged: widget.controller.updateRecipeTitle, + decoration: InputDecoration( + hintText: 'e.g. Arrabbiata sauce', + hintStyle: const TextStyle(color: Colors.grey), + border: outlineBorder, + enabledBorder: outlineBorder, + focusedBorder: outlineBorder, + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'My recipes only', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Switch( + value: filterOptions.filterByMine, + onChanged: (_) => + widget.controller.updateFilterByMine(), + activeThumbColor: Colors.white, + activeTrackColor: AppColors.teal, + inactiveThumbColor: AppColors.charcoal, + inactiveTrackColor: AppColors.lightTeal, + trackOutlineColor: + WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return Colors.transparent; + } + return AppColors.teal; + }), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Rating', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Row( + children: [ + for (int index = 0; index < 5; index++) ...[ + if (index > 0) const SizedBox(width: 12), + RatingButton( + rating: index + 1, + isSelected: (index + 1) == filterOptions.rating, + isFilled: (index + 1) < filterOptions.rating, + onClick: () => + widget.controller.updateRating(index + 1), + ), + ], + ], + ), + if (tags.isNotEmpty) ...[ + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Tags', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: tags.map((tagName) { + final isSelected = filterOptions.selectedTags + .contains(tagName); + return _TagFilterChip( + text: tagName, + isSelected: isSelected, + onClick: () { + if (isSelected) { + widget.controller.removeTag(tagName); + } else { + widget.controller.addTag(tagName); + } + }, + ); + }).toList(), + ), + ], + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 8), + child: Text( + 'Sort By', + style: TextStyle(fontWeight: FontWeight.w500), + ), + ), + RadioGroup( + groupValue: filterOptions.sortBy, + onChanged: (SortByFilter? value) { + if (value != null) { + widget.controller.updateSortBy(value); + } + }, + child: Column( + children: [ + Row( + children: [ + Radio( + value: SortByFilter.defaultSort, + activeColor: AppColors.teal, + ), + Text(SortByFilter.defaultSort.title), + const SizedBox(width: 24), + Radio( + value: SortByFilter.rating, + activeColor: AppColors.teal, + ), + Text(SortByFilter.rating.title), + ], + ), + Row( + children: [ + Radio( + value: SortByFilter.alphabetical, + activeColor: AppColors.teal, + ), + Text(SortByFilter.alphabetical.title), + const SizedBox(width: 24), + Radio( + value: SortByFilter.popularity, + activeColor: AppColors.teal, + ), + Text(SortByFilter.popularity.title), + ], + ), + ], + ), + ), + const Spacer(), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: SizedBox( + height: 56, + child: OutlinedButton( + onPressed: () { + _searchController.clear(); + _recipeTitleController.clear(); + widget.navigateBack(); + widget.controller.resetFilters(); + }, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.teal, + side: const BorderSide( + color: AppColors.teal, + width: 1, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Reset', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 56, + child: ElevatedButton( + onPressed: () { + widget.navigateBack(); + widget.controller.applyFilters(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.teal, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Apply Filters', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + }, + ), + ); + }, + ); + } +} + +class _TagFilterChip extends StatelessWidget { + const _TagFilterChip({ + required this.text, + required this.isSelected, + required this.onClick, + }); + + final String text; + final bool isSelected; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onClick, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppColors.lightTeal : Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + width: 1, + color: isSelected ? AppColors.lightTeal : AppColors.borderColor, + ), + ), + child: Text( + text, + style: TextStyle( + color: isSelected ? AppColors.teal : AppColors.textColor, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart new file mode 100644 index 00000000..45d2b33b --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_controller.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/model/recipe.dart'; +import 'filter_options.dart'; + +class RecipeListController extends ChangeNotifier { + RecipeListController(this._repositories); + + final AppRepositories _repositories; + + FilterOptions _filterOptions = const FilterOptions(); + FilterOptions get filterOptions => _filterOptions; + + List _tags = const []; + List get tags => _tags; + + List _recipes = const []; + List get recipes => _recipes; + + bool _loading = false; + bool get loading => _loading; + + Future initialize() async { + _loading = true; + notifyListeners(); + try { + final results = await Future.wait([ + _repositories.databaseRepository.getAllRecipes(), + _repositories.databaseRepository.getPopularTags(), + ]); + _recipes = results[0] as List; + _tags = results[1] as List; + } catch (e) { + debugPrint('Error loading recipes and popular tags: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } + + void updateRecipeTitle(String recipeName) { + _filterOptions = _filterOptions.copyWith(recipeTitle: recipeName); + notifyListeners(); + } + + void updateSearchQuery(String query) { + _filterOptions = _filterOptions.copyWith(searchQuery: query); + notifyListeners(); + } + + void updateFilterByMine() { + _filterOptions = _filterOptions.copyWith( + filterByMine: !_filterOptions.filterByMine, + ); + notifyListeners(); + } + + void updateRating(int rating) { + _filterOptions = _filterOptions.copyWith(rating: rating); + notifyListeners(); + } + + void removeTag(String tag) { + _filterOptions = _filterOptions.copyWith( + selectedTags: + _filterOptions.selectedTags.where((item) => item != tag).toList(), + ); + notifyListeners(); + } + + void addTag(String tag) { + _filterOptions = _filterOptions.copyWith( + selectedTags: [..._filterOptions.selectedTags, tag], + ); + notifyListeners(); + } + + void updateSortBy(SortByFilter sortByFilter) { + _filterOptions = _filterOptions.copyWith(sortBy: sortByFilter); + notifyListeners(); + } + + Future resetFilters() async { + _filterOptions = const FilterOptions(); + _loading = true; + notifyListeners(); + try { + _recipes = await _repositories.databaseRepository.getAllRecipes(); + } catch (e) { + debugPrint('Error resetting recipe filters: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } + + Future applyFilters() async { + final filter = _filterOptions; + final userId = _repositories.authRepository.currentUser?.uid ?? ''; + _loading = true; + notifyListeners(); + try { + _recipes = await _repositories.databaseRepository.getFilteredRecipes( + filter, + userId, + ); + } catch (e) { + debugPrint('Error applying recipe filters: $e'); + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart new file mode 100644 index 00000000..626d4b0c --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/recipe_list_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; + +import '../../data/model/recipe.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; +import 'recipe_list_controller.dart'; + +class RecipeListScreen extends StatefulWidget { + const RecipeListScreen({ + super.key, + required this.controller, + required this.openRecipeScreen, + required this.openFilterScreen, + }); + + final RecipeListController controller; + final ValueChanged openRecipeScreen; + final VoidCallback openFilterScreen; + + @override + State createState() => _RecipeListScreenState(); +} + +class _RecipeListScreenState extends State { + @override + void initState() { + super.initState(); + if (widget.controller.recipes.isEmpty && !widget.controller.loading) { + widget.controller.initialize(); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final recipes = widget.controller.recipes; + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'All Recipes', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + IconButton( + onPressed: widget.openFilterScreen, + tooltip: 'Filter', + icon: const Icon(Icons.filter_list), + ), + ], + ), + ), + ), + ), + body: widget.controller.loading && recipes.isEmpty + ? const Center(child: LoadingIndicator()) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + itemCount: recipes.length, + separatorBuilder: (_, _) => const SizedBox(height: 16), + itemBuilder: (context, index) { + final recipe = recipes[index]; + return RecipeCard( + key: ValueKey(recipe.id), + openRecipeScreen: widget.openRecipeScreen, + recipe: recipe, + ); + }, + ), + ); + }, + ); + } +} + +class RecipeCard extends StatelessWidget { + const RecipeCard({ + super.key, + required this.openRecipeScreen, + required this.recipe, + }); + + final ValueChanged openRecipeScreen; + final RecipeListItem recipe; + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + color: Colors.white, + elevation: 2, + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => openRecipeScreen(recipe.id), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + width: 80, + height: 80, + color: AppColors.lightTeal, + child: recipe.imageUri != null && recipe.imageUri!.isNotEmpty + ? Image.network( + recipe.imageUri!, + fit: BoxFit.cover, + width: 80, + height: 80, + semanticLabel: 'Recipe image', + errorBuilder: (_, _, _) => Image.asset( + 'assets/images/ic_launcher_no_bg.png', + fit: BoxFit.cover, + semanticLabel: 'App icon', + ), + ) + : Image.asset( + 'assets/images/ic_launcher_no_bg.png', + fit: BoxFit.cover, + semanticLabel: 'App icon', + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.textColor, + height: 20 / 16, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + for (int index = 1; index <= 5; index++) + _AverageRatingIcon( + index: index, + rating: recipe.averageRating, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AverageRatingIcon extends StatelessWidget { + const _AverageRatingIcon({ + required this.index, + required this.rating, + }); + + final int index; + final double rating; + + @override + Widget build(BuildContext context) { + final IconData iconData; + final Color iconTint; + + if (rating >= index) { + iconData = Icons.star; + iconTint = AppColors.selectedStarColor; + } else if (rating > index - 1) { + iconData = Icons.star_half; + iconTint = AppColors.selectedStarColor; + } else { + iconData = Icons.star; + iconTint = AppColors.unselectedStarColor; + } + + return Icon( + iconData, + size: 16, + color: iconTint, + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart new file mode 100644 index 00000000..2b3f3154 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/scan_meal/scan_meal_screen.dart @@ -0,0 +1,329 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../data/app_repositories.dart'; +import '../../data/datasource/storage_remote_datasource.dart'; +import '../../data/schema/meal_schema.dart'; +import '../shared/camera_component.dart'; +import '../shared/loading_indicator.dart'; +import '../theme/app_theme.dart'; + +class ScanMealViewState { + const ScanMealViewState({ + this.imageBytes, + this.mealSchema = const MealSchema(), + this.scanLoading = false, + }); + + final Uint8List? imageBytes; + final MealSchema mealSchema; + final bool scanLoading; + + ScanMealViewState copyWith({ + Uint8List? imageBytes, + MealSchema? mealSchema, + bool? scanLoading, + }) { + return ScanMealViewState( + imageBytes: imageBytes ?? this.imageBytes, + mealSchema: mealSchema ?? this.mealSchema, + scanLoading: scanLoading ?? this.scanLoading, + ); + } +} + +class ScanMealScreen extends StatefulWidget { + const ScanMealScreen({ + super.key, + required this.repositories, + required this.showError, + }); + + final AppRepositories repositories; + final VoidCallback showError; + + @override + State createState() => _ScanMealScreenState(); +} + +class _ScanMealScreenState extends State { + ScanMealViewState _viewState = const ScanMealViewState(); + + Future _onImageTaken( + Uint8List? imageBytes, + VoidCallback showError, + ) async { + if (imageBytes == null) { + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith( + scanLoading: true, + imageBytes: imageBytes, + ); + }); + + try { + final base64Image = await imageBytesToBase64(imageBytes); + final mealSchema = + await widget.repositories.aiRepository.scanMeal(base64Image); + + if (!mounted) return; + if (mealSchema == null) { + setState(() { + _viewState = _viewState.copyWith(scanLoading: false); + }); + showError(); + return; + } + + setState(() { + _viewState = _viewState.copyWith( + scanLoading: false, + mealSchema: mealSchema, + ); + }); + } catch (e) { + debugPrint('Error scanning meal: $e'); + if (!mounted) return; + setState(() { + _viewState = _viewState.copyWith(scanLoading: false); + }); + showError(); + } + } + + @override + Widget build(BuildContext context) { + final imageBytes = _viewState.imageBytes; + + return Scaffold( + appBar: PreferredSize( + preferredSize: const Size.fromHeight(68), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Scan Meal', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + CameraComponent( + onImageTaken: _onImageTaken, + showError: widget.showError, + isTopBarIcon: true, + ), + ], + ), + ), + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Container( + width: double.infinity, + height: 275, + color: Colors.grey.shade400, + child: imageBytes != null + ? Image.memory( + imageBytes, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + semanticLabel: 'Recipe image', + ) + : const Center( + child: Text( + 'Could not load image', + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: Text( + 'Nutritional Facts', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + Row( + children: [ + Expanded( + child: _NutrientCard( + label: 'Protein', + value: _viewState.mealSchema.protein, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _NutrientCard( + label: 'Fat', + value: _viewState.mealSchema.fat, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _NutrientCard( + label: 'Carbs', + value: _viewState.mealSchema.carbs, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _NutrientCard( + label: 'Sugar', + value: _viewState.mealSchema.sugar, + ), + ), + ], + ), + const SizedBox(height: 24), + if (_viewState.scanLoading) ...[ + const SizedBox(height: 48), + const LoadingIndicator(), + ], + if (_viewState.mealSchema.ingredients.isNotEmpty) + _IngredientsCard(ingredients: _viewState.mealSchema.ingredients), + ], + ), + ); + } +} + +class _NutrientCard extends StatelessWidget { + const _NutrientCard({ + required this.label, + required this.value, + }); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + decoration: BoxDecoration( + color: AppColors.lightTeal, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Text( + label, + style: const TextStyle( + color: AppColors.teal, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + color: AppColors.textColor, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} + +class _IngredientsCard extends StatelessWidget { + const _IngredientsCard({required this.ingredients}); + + final List ingredients; + + @override + Widget build(BuildContext context) { + return Card( + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Identified Ingredients', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.teal, + ), + ), + const SizedBox(height: 12), + ...ingredients.map((item) => _IngredientRow(text: item)), + ], + ), + ), + ); + } +} + +class _IngredientRow extends StatelessWidget { + const _IngredientRow({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + color: AppColors.lightTeal, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const Icon( + Icons.check, + size: 14, + color: AppColors.teal, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + fontSize: 15, + color: AppColors.textColor, + ), + ), + ), + ], + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart new file mode 100644 index 00000000..3d13c32d --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/audio_component.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; + +class AudioComponent { + AudioPlayer? _player; + StreamSubscription? _completionSub; + + Future play(Uint8List pcmData, VoidCallback onCompletion) async { + await stop(); + + if (pcmData.isEmpty) { + onCompletion(); + return; + } + + try { + final wavBytes = pcmToWav( + pcmData, + sampleRate: 24000, + numChannels: 1, + bitsPerSample: 16, + ); + + final player = AudioPlayer(); + _player = player; + _completionSub = player.onPlayerComplete.listen((_) { + onCompletion(); + }); + + await player.play(BytesSource(wavBytes, mimeType: 'audio/wav')); + } catch (e) { + debugPrint('Failed to initialize or play AudioComponent: $e'); + onCompletion(); + } + } + + Future pause() async { + try { + await _player?.pause(); + } catch (e) { + debugPrint('Error pausing AudioComponent: $e'); + } + } + + Future resume() async { + try { + await _player?.resume(); + } catch (e) { + debugPrint('Error resuming AudioComponent: $e'); + } + } + + Future stop() async { + try { + await _completionSub?.cancel(); + _completionSub = null; + final player = _player; + _player = null; + if (player != null) { + await player.stop(); + await player.dispose(); + } + } catch (e) { + debugPrint('Error stopping AudioComponent: $e'); + } + } + + Future release() async { + await stop(); + } + + /// Wraps raw PCM 16-bit little-endian samples in a standard 44-byte RIFF/WAVE header. + static Uint8List pcmToWav( + Uint8List pcmBytes, { + int sampleRate = 24000, + int numChannels = 1, + int bitsPerSample = 16, + }) { + final byteRate = sampleRate * numChannels * (bitsPerSample ~/ 8); + final blockAlign = numChannels * (bitsPerSample ~/ 8); + final dataLength = pcmBytes.length; + final totalLength = 36 + dataLength; + + final header = ByteData(44); + // "RIFF" + header.setUint8(0, 0x52); + header.setUint8(1, 0x49); + header.setUint8(2, 0x46); + header.setUint8(3, 0x46); + header.setUint32(4, totalLength, Endian.little); + // "WAVE" + header.setUint8(8, 0x57); + header.setUint8(9, 0x41); + header.setUint8(10, 0x56); + header.setUint8(11, 0x45); + // "fmt " + header.setUint8(12, 0x66); + header.setUint8(13, 0x6D); + header.setUint8(14, 0x74); + header.setUint8(15, 0x20); + header.setUint32(16, 16, Endian.little); // Subchunk1Size for PCM + header.setUint16(20, 1, Endian.little); // AudioFormat = 1 (PCM) + header.setUint16(22, numChannels, Endian.little); + header.setUint32(24, sampleRate, Endian.little); + header.setUint32(28, byteRate, Endian.little); + header.setUint16(32, blockAlign, Endian.little); + header.setUint16(34, bitsPerSample, Endian.little); + // "data" + header.setUint8(36, 0x64); + header.setUint8(37, 0x61); + header.setUint8(38, 0x74); + header.setUint8(39, 0x61); + header.setUint32(40, dataLength, Endian.little); + + final wavBytes = Uint8List(44 + dataLength); + wavBytes.setRange(0, 44, header.buffer.asUint8List()); + wavBytes.setRange(44, 44 + dataLength, pcmBytes); + return wavBytes; + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart new file mode 100644 index 00000000..b707a774 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/bottom_nav_bar.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +enum BottomNavTab { + scanMeal('Scan Meal', Icons.photo_camera_outlined), + generate('Generate', Icons.auto_awesome_outlined), + recipeList('Recipes', Icons.restaurant_outlined), + groceryList('Grocery List', Icons.fact_check_outlined); + + const BottomNavTab(this.label, this.icon); + final String label; + final IconData icon; +} + +class BottomNavBar extends StatelessWidget { + const BottomNavBar({ + super.key, + required this.selectedTab, + required this.onTabSelected, + }); + + final BottomNavTab selectedTab; + final ValueChanged onTabSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedTab.index, + indicatorColor: Colors.transparent, + onDestinationSelected: (index) { + onTabSelected(BottomNavTab.values[index]); + }, + destinations: BottomNavTab.values.map((tab) { + final isSelected = tab == selectedTab; + return NavigationDestination( + icon: Icon( + tab.icon, + color: isSelected ? AppColors.teal : null, + ), + selectedIcon: Icon( + tab.icon, + color: AppColors.teal, + ), + label: tab.label, + ); + }).toList(), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart new file mode 100644 index 00000000..35956fb4 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/camera_component.dart @@ -0,0 +1,158 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image/image.dart' as img; +import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../theme/app_theme.dart'; + +class CameraComponent extends StatelessWidget { + const CameraComponent({ + super.key, + required this.onImageTaken, + required this.showError, + this.isTopBarIcon = true, + }); + + final void Function(Uint8List? imageBytes, VoidCallback showError) + onImageTaken; + final VoidCallback showError; + final bool isTopBarIcon; + + static final ImagePicker _picker = ImagePicker(); + + Future _takePicture() async { + try { + if (!kIsWeb) { + final status = await Permission.camera.request(); + if (!status.isGranted) { + return; + } + } + + final XFile? photo = await _picker.pickImage( + source: ImageSource.camera, + preferredCameraDevice: CameraDevice.rear, + ); + if (photo == null) { + return; + } + + final rawBytes = await photo.readAsBytes(); + final bakedBytes = await compute(_bakeOrientation, rawBytes); + onImageTaken(bakedBytes, showError); + } catch (e) { + debugPrint('Error taking picture: $e'); + showError(); + } + } + + static Uint8List _bakeOrientation(Uint8List bytes) { + final decoded = img.decodeImage(bytes); + if (decoded == null) return bytes; + final oriented = img.bakeOrientation(decoded); + return Uint8List.fromList(img.encodeJpg(oriented, quality: 90)); + } + + @override + Widget build(BuildContext context) { + if (isTopBarIcon) { + return IconButton( + onPressed: _takePicture, + tooltip: 'Camera', + icon: const Icon(Icons.photo_camera_outlined), + ); + } + + return CustomPaint( + painter: _DashedRoundedRectPainter( + color: Colors.grey.shade400, + strokeWidth: 2, + radius: 16, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: _takePicture, + child: const SizedBox( + width: double.infinity, + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.photo_camera_outlined, + color: AppColors.teal, + size: 24, + ), + SizedBox(width: 8), + Text( + 'Take a picture of ingredients', + style: TextStyle( + color: AppColors.teal, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DashedRoundedRectPainter extends CustomPainter { + _DashedRoundedRectPainter({ + required this.color, + required this.strokeWidth, + required this.radius, + }); + + final Color color; + final double strokeWidth; + final double radius; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final rrect = RRect.fromRectAndRadius( + Offset.zero & size, + Radius.circular(radius), + ); + final path = Path()..addRRect(rrect); + final dashedPath = _createDashedPath(path, dashWidth: 10, dashSpace: 8); + canvas.drawPath(dashedPath, paint); + } + + Path _createDashedPath( + Path source, { + required double dashWidth, + required double dashSpace, + }) { + final dest = Path(); + for (final ui.PathMetric metric in source.computeMetrics()) { + double distance = 0; + while (distance < metric.length) { + final next = (distance + dashWidth).clamp(0.0, metric.length); + dest.addPath(metric.extractPath(distance, next), Offset.zero); + distance += dashWidth + dashSpace; + } + } + return dest; + } + + @override + bool shouldRepaint(covariant _DashedRoundedRectPainter oldDelegate) { + return oldDelegate.color != color || + oldDelegate.strokeWidth != strokeWidth || + oldDelegate.radius != radius; + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart new file mode 100644 index 00000000..2e4e6b81 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/loading_indicator.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class LoadingIndicator extends StatelessWidget { + const LoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.only(bottom: 24), + child: SizedBox( + width: double.infinity, + child: Center( + child: SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + color: AppColors.teal, + backgroundColor: AppColors.lightTeal, + ), + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart b/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart new file mode 100644 index 00000000..58ee82c4 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/shared/rating_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +class RatingButton extends StatelessWidget { + const RatingButton({ + super.key, + required this.rating, + required this.isSelected, + required this.isFilled, + required this.onClick, + }); + + final int rating; + final bool isSelected; + final bool isFilled; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final Color iconTint; + if (isSelected) { + iconTint = Colors.white; + } else if (isFilled) { + iconTint = AppColors.selectedStarColor; + } else { + iconTint = AppColors.unselectedStarColor; + } + + return Semantics( + label: '$rating Stars', + button: true, + child: InkWell( + onTap: onClick, + customBorder: const CircleBorder(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? AppColors.teal : Colors.white, + border: Border.all( + width: 1, + color: isSelected ? AppColors.teal : AppColors.borderColor, + ), + ), + alignment: Alignment.center, + child: Icon( + Icons.star, + size: 24, + color: iconTint, + ), + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart b/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart new file mode 100644 index 00000000..607736d1 --- /dev/null +++ b/firebase-ai-friendly-meals/flutter/lib/ui/theme/app_theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color teal = Color(0xFF1EB980); + static const Color lightTeal = Color(0xFFE0F2F1); + static const Color lightGray = Color(0xFFF3F4F6); + static const Color charcoal = Color(0xFF36454F); + static const Color textColor = Color(0xFF1F2937); + static const Color lightTextColor = Color(0xFFF8F9FA); + static const Color selectedStarColor = Color(0xFFFFC107); + static const Color unselectedStarColor = Color(0xFF9CA3AF); + static const Color borderColor = Color(0xFFE5E7EB); +} + +class FriendlyMealsTheme { + static ThemeData get lightTheme { + const colorScheme = ColorScheme.light( + primary: AppColors.teal, + secondary: AppColors.lightTeal, + surface: AppColors.lightGray, + onPrimary: Colors.white, + onSecondary: Colors.black, + onSurface: AppColors.textColor, + ); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + primaryColor: AppColors.teal, + scaffoldBackgroundColor: AppColors.lightGray, + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.lightGray, + foregroundColor: AppColors.textColor, + elevation: 0, + scrolledUnderElevation: 0, + ), + textTheme: const TextTheme( + bodyLarge: TextStyle( + fontSize: 16, + height: 24 / 16, + letterSpacing: 0.5, + color: AppColors.textColor, + ), + bodyMedium: TextStyle( + color: AppColors.textColor, + ), + ), + ); + } +} diff --git a/firebase-ai-friendly-meals/flutter/pubspec.lock b/firebase-ai-friendly-meals/flutter/pubspec.lock index ee427427..fbdf3ff0 100644 --- a/firebase-ai-friendly-meals/flutter/pubspec.lock +++ b/firebase-ai-friendly-meals/flutter/pubspec.lock @@ -5,10 +5,18 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: bb84ee51e527053dd8e25ecc9f97a6abfdc19130fb4d883e4e8585e23e7e6dd8 + sha256: f6966125633a34f82d9be2ee895b755cff3554087b1d00a9eb884e5947f4bca9 url: "https://pub.dev" source: hosted - version: "1.3.60" + version: "1.3.77" + archive: + dependency: transitive + description: + name: archive + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" + url: "https://pub.dev" + source: hosted + version: "4.5.2" args: dependency: transitive description: @@ -25,6 +33,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -33,14 +97,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: b3083b183ef1daa408d5f0c98c8c7ac0325b490695fb4557ff4210fcf04c5984 + url: "https://pub.dev" + source: hosted + version: "2.15.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "5aecf1ac4dd4a949c5a7820e590020acb2bc35425384ab87a030e1739bfe12e2" + url: "https://pub.dev" + source: hosted + version: "0.3.5+6" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -49,6 +153,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: db785e4d0a995654781835b78a30b8bb203d967e123843bb918df0cdb6a7ec69 + url: "https://pub.dev" + source: hosted + version: "6.10.0" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: ef985a8239846187de0eccce2092a940eaf281a1357c6325e1739ec0b6c230e2 + url: "https://pub.dev" + source: hosted + version: "8.0.7" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: b30b3b44937345d50b195f85ef328f1d71c2a79965cb1645a479d029d45f636a + url: "https://pub.dev" + source: hosted + version: "5.7.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -57,6 +193,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -73,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" fake_async: dependency: transitive description: @@ -81,86 +233,214 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" firebase_ai: dependency: "direct main" description: name: firebase_ai - sha256: b32d38bc0c335d6161baa7240bac70073891e9434839f9a7f8d95fc06225b1ad + sha256: ac2105dc4fd48f4978006cf7b99477e51b36b4d53a84bce588a5817550368e43 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.15.0" firebase_app_check: - dependency: transitive + dependency: "direct main" description: name: firebase_app_check - sha256: dae47c346b1b8725292da6effcdf15036e9fb7e30a50b14b4493c5c6b30d5275 + sha256: cb69814b369e421e249bd0095705c843c7607502b3f2a41ac99b9addc8b04ffc url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.8" firebase_app_check_platform_interface: dependency: transitive description: name: firebase_app_check_platform_interface - sha256: "2ef7b3e309d9de4c62884f421773a95a55324c7ddde4f057f2ed31e808439d8c" + sha256: "87909de3a8952e69ab0d8b65b89311f3aebf3adb89acbfb0e65c00e57f78eb7e" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.4.2+1" firebase_app_check_web: dependency: transitive description: name: firebase_app_check_web - sha256: "2c6d9bb52a53a6e43980954f4174a3add692cde4f6fff380070bd3debf6e205c" + sha256: e1072f46b66c1530820ec617909576924a685e2cf7b78e62acd6af82b50609c4 url: "https://pub.dev" source: hosted - version: "0.2.0+15" + version: "0.2.6+1" firebase_auth: - dependency: transitive + dependency: "direct main" description: name: firebase_auth - sha256: abd15b7287285390c4ac144fed7825a1877496366e0d15ffc0ddfa9774deb3f3 + sha256: "144fa22926b629e797ecc757db5d31b145aeedf6018160296dae945d98a98fb5" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.7.0" firebase_auth_platform_interface: dependency: transitive description: name: firebase_auth_platform_interface - sha256: c1d8a2f40980fbae191598e80431dd1228ddaeba17f96f4fe14babab721bdf3d + sha256: "9959a6e7338df4f725707a71fd9f727d8dfafd7c46cbaf59fcf7c8e94fb92d11" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "9.1.0" firebase_auth_web: dependency: transitive description: name: firebase_auth_web - sha256: "337e01f3bb30de85b74634c33712c57f76cc15f7be9d35b24aab9e95e451a4e2" + sha256: "11861819c667e9f47269fa1e7fca8d0e5229f80d4d3008d44b2108b8c93bfd94" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.3.0" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "6b343e6f7b72a4f32d7ce8df8c9a28d8f54b4ac20d7c6500f3e8b3969afca457" + sha256: "5fbbaa2aeb08a52feee559f92a62f5a6b04c9bdff81c8f608636771cf0d7b680" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.15.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "5dbc900677dcbe5873d22ad7fbd64b047750124f1f9b7ebe2a33b9ddccc838eb" + sha256: "9bfbc85faca09346471ac56fbcee00757ebcfd85887c6f602764eff0001902d8" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "8.1.1" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "5d28b14dd32282fb7ce2b22b897362453755b6b8541d491127dc72b755bb7b16" + sha256: "8ed84f5001dbd7a0390e2818d46bc858ceebb970edededdfc8d6552c76fc2f6e" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.12.0" + firebase_performance: + dependency: "direct main" + description: + name: firebase_performance + sha256: fec5be59b101c17c091f5beb8c849b5999d5e84c254c31472a3058af0eb3f192 + url: "https://pub.dev" + source: hosted + version: "0.11.6" + firebase_performance_platform_interface: + dependency: transitive + description: + name: firebase_performance_platform_interface + sha256: b598cea7051fb9f3abd3eddbcabc7dd9d2c6a7ab06e264c8c2d2c935a20c12aa + url: "https://pub.dev" + source: hosted + version: "0.2.0+7" + firebase_performance_web: + dependency: transitive + description: + name: firebase_performance_web + sha256: "424d695ea235b4aad3902cabe66f7c6197b4a9e66f0f151c14850100fb4fa45d" + url: "https://pub.dev" + source: hosted + version: "0.1.8+13" + firebase_remote_config: + dependency: "direct main" + description: + name: firebase_remote_config + sha256: fa518445638fa3903e359ac544bd3243e72ada025728c8dbfa4b28ae939e8b38 + url: "https://pub.dev" + source: hosted + version: "6.7.0" + firebase_remote_config_platform_interface: + dependency: transitive + description: + name: firebase_remote_config_platform_interface + sha256: dc5a9713031f2ca744cb266d96a58609e929db19f3d91a1dd6bb8f8a0d77acad + url: "https://pub.dev" + source: hosted + version: "3.0.7" + firebase_remote_config_web: + dependency: transitive + description: + name: firebase_remote_config_web + sha256: "500abf695c370c76a486ad7480a6df7caf55d2d35ccc5c66b9fc9c1c47f2bc14" + url: "https://pub.dev" + source: hosted + version: "1.10.14" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: f936f99ea1f49f644d5e83a495ed71bd89b040358e548f9dee14f10458d49ea0 + url: "https://pub.dev" + source: hosted + version: "13.6.0" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: "249a8cfa965f0bfb5ed6b6e14fa9b63428ba3c71ffe73b0e360da8d2fde2ef92" + url: "https://pub.dev" + source: hosted + version: "6.0.7" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: b8a2e1f8a013d192f7b6e09ad40ec50e13d0bc97d2a3462369b2ba3f368d9c78 + url: "https://pub.dev" + source: hosted + version: "3.11.13" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -182,6 +462,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7+1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -192,6 +480,86 @@ packages: description: flutter source: sdk version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "1be90ea8aaaf9db8e7816b4fe0387f082ee8b5cf03355912dad90fb0a75717fa" + url: "https://pub.dev" + source: hosted + version: "5.1.1+1" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "3b3189beba8b59b2a0eee970b466e991e4ac1564a9034f82a825a3c45c4be1fa" + url: "https://pub.dev" + source: hosted + version: "2.3.15" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + url: "https://pub.dev" + source: hosted + version: "0.2.4" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: b640b6b687b14c66f51fe1130b1c2e8babbd408e0158052b92006b6675ca77fc + url: "https://pub.dev" + source: hosted + version: "4.4.0" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: transitive description: @@ -208,30 +576,134 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" + url: "https://pub.dev" + source: hosted + version: "0.8.13+25" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" + url: "https://pub.dev" + source: hosted + version: "1.0.4+1" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" leak_tracker: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.9" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -240,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" markdown: dependency: transitive description: @@ -252,26 +732,66 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "2.1.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -280,6 +800,118 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: "066bbbc60031b63b916a3871e7bb4b6a8c5e7cec8146dafec53d1da304eff0a5" + url: "https://pub.dev" + source: hosted + version: "9.6.2" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" + source: hosted + version: "4.4.1" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec + url: "https://pub.dev" + source: hosted + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -288,6 +920,94 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + record: + dependency: "direct main" + description: + name: record + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" + url: "https://pub.dev" + source: hosted + version: "6.2.1" + record_android: + dependency: transitive + description: + name: record_android + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 + url: "https://pub.dev" + source: hosted + version: "1.2.2" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" sky_engine: dependency: transitive description: flutter @@ -317,6 +1037,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" string_scanner: dependency: transitive description: @@ -325,6 +1053,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5 + url: "https://pub.dev" + source: hosted + version: "3.5.0" term_glyph: dependency: transitive description: @@ -337,10 +1073,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.11" typed_data: dependency: transitive description: @@ -349,14 +1085,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda + url: "https://pub.dev" + source: hosted + version: "6.3.3" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -389,6 +1197,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: - dart: ">=3.8.0-278.1.beta <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/firebase-ai-friendly-meals/flutter/pubspec.yaml b/firebase-ai-friendly-meals/flutter/pubspec.yaml index 3d2b9741..d1554ee2 100644 --- a/firebase-ai-friendly-meals/flutter/pubspec.yaml +++ b/firebase-ai-friendly-meals/flutter/pubspec.yaml @@ -1,92 +1,43 @@ name: myapp -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +description: "FriendlyMeals Flutter sample app." +publish_to: 'none' -# 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. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: ^3.8.0-278.1.beta -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_markdown: ^0.7.7+1 firebase_core: ^4.0.0 - firebase_ai: ^3.1.0 + firebase_ai: ^3.13.1 + firebase_auth: ^6.0.0 + cloud_firestore: ^6.0.0 + firebase_storage: ^13.0.0 + firebase_remote_config: ^6.0.0 + firebase_app_check: ^0.4.0 + firebase_performance: ^0.11.0 + image_picker: ^1.2.0 + camera: ^0.11.0 + record: ^6.0.0 + audioplayers: ^6.0.0 + geolocator: ^14.0.0 + permission_handler: ^12.0.0 + url_launcher: ^6.3.0 + uuid: ^4.5.0 + image: ^4.5.0 + intl: ^0.20.0 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. 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 - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # 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.dev/to/font-from-package + assets: + - assets/images/ic_launcher_no_bg.png diff --git a/firebase-ai-friendly-meals/flutter/test/widget_test.dart b/firebase-ai-friendly-meals/flutter/test/widget_test.dart index 38decd37..f0a6af8a 100644 --- a/firebase-ai-friendly-meals/flutter/test/widget_test.dart +++ b/firebase-ai-friendly-meals/flutter/test/widget_test.dart @@ -1,30 +1,112 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; - -import 'package:myapp/main.dart'; +import 'package:myapp/data/model/grocery_item.dart'; +import 'package:myapp/data/model/recipe.dart'; +import 'package:myapp/data/schema/meal_schema.dart'; +import 'package:myapp/data/schema/recipe_schema.dart'; +import 'package:myapp/data/schema/store_schema.dart'; +import 'package:myapp/ui/shared/bottom_nav_bar.dart'; +import 'package:myapp/ui/shared/rating_button.dart'; +import 'package:myapp/ui/theme/app_theme.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('BottomNavBar renders all 4 FriendlyMeals tabs and switches tabs', + (WidgetTester tester) async { + BottomNavTab currentTab = BottomNavTab.generate; + + await tester.pumpWidget( + MaterialApp( + theme: FriendlyMealsTheme.lightTheme, + home: StatefulBuilder( + builder: (context, setState) { + return Scaffold( + bottomNavigationBar: BottomNavBar( + selectedTab: currentTab, + onTabSelected: (tab) => setState(() => currentTab = tab), + ), + ); + }, + ), + ), + ); + + expect(find.text('Scan Meal'), findsOneWidget); + expect(find.text('Generate'), findsOneWidget); + expect(find.text('Recipes'), findsOneWidget); + expect(find.text('Grocery List'), findsOneWidget); + + await tester.tap(find.text('Grocery List')); + await tester.pump(); + expect(currentTab, BottomNavTab.groceryList); + }); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); + testWidgets('RatingButton renders and triggers callback', + (WidgetTester tester) async { + int? tappedRating; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: RatingButton( + rating: 4, + isSelected: true, + isFilled: false, + onClick: () => tappedRating = 4, + ), + ), + ), + ); - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); + await tester.tap(find.byIcon(Icons.star)); await tester.pump(); + expect(tappedRating, 4); + }); + + test('Schemas and models serialize and deserialize accurately', () { + final recipeSchema = RecipeSchema.fromJson({ + 'title': 'Pasta Carbonara', + 'prepTime': '10 mins', + 'cookTime': '15 mins', + 'servings': '2', + 'ingredients': ['Pasta', 'Eggs', 'Pecorino'], + 'instructions': 'Boil pasta and mix.', + 'tags': ['Italian', 'Quick'], + }); + expect(recipeSchema.title, 'Pasta Carbonara'); + expect(recipeSchema.tags, ['Italian', 'Quick']); + + final mealSchema = MealSchema.fromJson({ + 'ingredients': ['Chicken', 'Rice'], + 'fat': '12g', + 'carbs': '45g', + 'sugar': '2g', + 'protein': '35g', + }); + expect(mealSchema.protein, '35g'); + + final storeResult = StoreFinderResult.fromJson({ + 'stores': [ + { + 'name': 'Fresh Market', + 'address': '123 Main St', + 'distance': '0.5 mi', + 'openNow': true, + 'closingSoon': false, + 'hasParking': true, + 'parkingDetails': 'Free lot', + 'mapUrl': 'https://maps.google.com', + } + ], + }); + expect(storeResult.stores.single.name, 'Fresh Market'); + + final recipe = Recipe.fromMap({'title': recipeSchema.title}); + expect(recipe.title, 'Pasta Carbonara'); - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + final grocery = GroceryItem.fromMap( + {'userId': 'u1', 'name': 'Tomatoes', 'checked': true}, + docId: 'g1', + ); + expect(grocery.id, 'g1'); + expect(grocery.checked, true); }); } From fecfad942275ecda1eb179920d61a1547b11381e Mon Sep 17 00:00:00 2001 From: Rosario Fernandes Date: Wed, 14 Oct 2026 17:42:48 +0100 Subject: [PATCH 2/2] address offline reviewer comments --- .../flutter/android/gradle.properties | 1 - .../data/datasource/ai_remote_datasource.dart | 44 +++-- .../datasource/auth_remote_datasource.dart | 2 + .../datasource/storage_remote_datasource.dart | 2 +- .../lib/data/repository/auth_repository.dart | 3 + .../lib/data/schema/recipe_schema.dart | 2 +- .../flutter/lib/main.dart | 159 ++++++++---------- .../lib/ui/generate/generate_screen.dart | 23 +-- .../ui/grocery_list/grocery_list_screen.dart | 35 +++- .../lib/ui/live/live_assistant_screen.dart | 77 ++++++++- .../lib/ui/recipe_list/filter_screen.dart | 89 ++++++++-- .../flutter/pubspec.yaml | 4 +- .../flutter/test/widget_test.dart | 16 +- 13 files changed, 272 insertions(+), 185 deletions(-) diff --git a/firebase-ai-friendly-meals/flutter/android/gradle.properties b/firebase-ai-friendly-meals/flutter/android/gradle.properties index f0e71c02..1152c863 100644 --- a/firebase-ai-friendly-meals/flutter/android/gradle.properties +++ b/firebase-ai-friendly-meals/flutter/android/gradle.properties @@ -1,5 +1,4 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -org.gradle.java.home=/Users/rosariopf/Library/Java/JavaVirtualMachines/corretto-17.0.18/Contents/Home android.useAndroidX=true android.enableJetifier=true org.gradle.caching=true diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart index 7885aa59..7dcbdc85 100644 --- a/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/ai_remote_datasource.dart @@ -83,17 +83,10 @@ class AIRemoteDataSource { ), ); - final rawText = response.text; - if (rawText == null || rawText.trim().isEmpty) { + final decoded = _extractJsonObject(response.text); + if (decoded == null) { return const []; } - - final cleanJson = rawText - .replaceAll('```json', '') - .replaceAll('```', '') - .trim(); - - final decoded = jsonDecode(cleanJson) as Map; return StoreFinderResult.fromJson(decoded).stores; } catch (e) { debugPrint('Error finding stores with these ingredients: $e'); @@ -133,16 +126,10 @@ class AIRemoteDataSource { inputs: inputs, ); - final rawText = response.text; - if (rawText == null || rawText.trim().isEmpty) { + final decoded = _extractJsonObject(response.text); + if (decoded == null) { return null; } - - final cleanJson = rawText - .replaceAll('```json', '') - .replaceAll('```', '') - .trim(); - final decoded = jsonDecode(cleanJson) as Map; return RecipeSchema.fromJson(decoded); } @@ -172,19 +159,26 @@ class AIRemoteDataSource { }, ); - final rawText = response.text; - if (rawText == null || rawText.trim().isEmpty) { + final decoded = _extractJsonObject(response.text); + if (decoded == null) { return null; } - - final cleanJson = rawText - .replaceAll('```json', '') - .replaceAll('```', '') - .trim(); - final decoded = jsonDecode(cleanJson) as Map; return MealSchema.fromJson(decoded); } + Map? _extractJsonObject(String? rawText) { + if (rawText == null || rawText.trim().isEmpty) { + return null; + } + final start = rawText.indexOf('{'); + final end = rawText.lastIndexOf('}'); + if (start == -1 || end == -1 || end < start) { + return null; + } + final cleanJson = rawText.substring(start, end + 1); + return jsonDecode(cleanJson) as Map; + } + Future craftRecipePairing( String dishTitle, List ingredients, diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart index 54e6b059..1f119c3a 100644 --- a/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/auth_remote_datasource.dart @@ -9,6 +9,8 @@ class AuthRemoteDataSource { fb_auth.User? get currentUser => _auth.currentUser; + Stream get authStateChanges => _auth.authStateChanges(); + Future createAnonymousAccount() async { final authResult = await _auth.signInAnonymously(); return AppUser(authId: authResult.user?.uid ?? ''); diff --git a/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart index 5097267e..9a3c79ab 100644 --- a/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart +++ b/firebase-ai-friendly-meals/flutter/lib/data/datasource/storage_remote_datasource.dart @@ -15,7 +15,7 @@ class StorageRemoteDataSource { Future addImage(Uint8List imageBytes) async { final processedBytes = await compute(_scaleAndCompressImage, imageBytes); final randomId = _uuid.v4(); - final imagesRef = _storageRef.child('images/$randomId.webp'); + final imagesRef = _storageRef.child('images/$randomId.jpg'); await imagesRef.putData( processedBytes, SettableMetadata(contentType: 'image/jpeg'), diff --git a/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart index 1cbd40f1..335e8fca 100644 --- a/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart +++ b/firebase-ai-friendly-meals/flutter/lib/data/repository/auth_repository.dart @@ -9,6 +9,9 @@ class AuthRepository { fb_auth.User? get currentUser => _authRemoteDataSource.currentUser; + Stream get authStateChanges => + _authRemoteDataSource.authStateChanges; + Future createAnonymousAccount() { return _authRemoteDataSource.createAnonymousAccount(); } diff --git a/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart index da046763..8bde88a3 100644 --- a/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart +++ b/firebase-ai-friendly-meals/flutter/lib/data/schema/recipe_schema.dart @@ -1,4 +1,4 @@ -import 'package:myapp/data/model/recipe.dart'; +import '../model/recipe.dart'; class RecipeSchema { final String title; diff --git a/firebase-ai-friendly-meals/flutter/lib/main.dart b/firebase-ai-friendly-meals/flutter/lib/main.dart index 4fa9fb89..b7fe4294 100644 --- a/firebase-ai-friendly-meals/flutter/lib/main.dart +++ b/firebase-ai-friendly-meals/flutter/lib/main.dart @@ -30,6 +30,15 @@ Future main() async { final repositories = AppRepositories.create(remoteConfigService); + if (repositories.authRepository.currentUser == null) { + try { + final user = await repositories.authRepository.createAnonymousAccount(); + await repositories.databaseRepository.addUser(user); + } catch (e) { + debugPrint('Error creating anonymous guest user on startup: $e'); + } + } + runApp(FriendlyMealsApp(repositories: repositories)); } @@ -48,24 +57,6 @@ Future _initializeAppCheck() async { } } -sealed class _SubRoute { - const _SubRoute(); -} - -class _FilterSubRoute extends _SubRoute { - const _FilterSubRoute(); -} - -class _RecipeSubRoute extends _SubRoute { - const _RecipeSubRoute(this.recipeId); - final String recipeId; -} - -class _LiveAssistantSubRoute extends _SubRoute { - const _LiveAssistantSubRoute(this.recipeId); - final String recipeId; -} - class FriendlyMealsApp extends StatefulWidget { const FriendlyMealsApp({ super.key, @@ -81,9 +72,9 @@ class FriendlyMealsApp extends StatefulWidget { class _FriendlyMealsAppState extends State { final GlobalKey _scaffoldMessengerKey = GlobalKey(); + final GlobalKey _navigatorKey = GlobalKey(); BottomNavTab _selectedTab = BottomNavTab.generate; - final List<_SubRoute> _subRouteStack = []; late final RecipeListController _recipeListController; @override @@ -106,36 +97,49 @@ class _FriendlyMealsAppState extends State { } void _onTabSelected(BottomNavTab tab) { + _navigatorKey.currentState?.popUntil((route) => route.isFirst); setState(() { _selectedTab = tab; - _subRouteStack.clear(); }); } void _openRecipeScreen(String recipeId) { - setState(() { - _subRouteStack.add(_RecipeSubRoute(recipeId)); - }); + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => RecipeScreen( + key: ValueKey('recipe_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + navigateToLiveAssistant: _openLiveAssistantScreen, + ), + ), + ); } void _openFilterScreen() { - setState(() { - _subRouteStack.add(const _FilterSubRoute()); - }); + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => FilterScreen( + controller: _recipeListController, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + ), + ), + ); } void _openLiveAssistantScreen(String recipeId) { - setState(() { - _subRouteStack.add(_LiveAssistantSubRoute(recipeId)); - }); - } - - void _popSubRoute() { - if (_subRouteStack.isNotEmpty) { - setState(() { - _subRouteStack.removeLast(); - }); - } + _navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (_) => LiveAssistantScreen( + key: ValueKey('live_$recipeId'), + recipeId: recipeId, + repositories: widget.repositories, + navigateBack: () => _navigatorKey.currentState?.maybePop(), + showError: () => _showError('Error loading camera'), + ), + ), + ); } @override @@ -144,14 +148,13 @@ class _FriendlyMealsAppState extends State { title: 'Friendly Meals', debugShowCheckedModeBanner: false, scaffoldMessengerKey: _scaffoldMessengerKey, + navigatorKey: _navigatorKey, theme: FriendlyMealsTheme.lightTheme, home: PopScope( - canPop: _subRouteStack.isEmpty && _selectedTab == BottomNavTab.generate, + canPop: _selectedTab == BottomNavTab.generate, onPopInvokedWithResult: (didPop, _) { if (didPop) return; - if (_subRouteStack.isNotEmpty) { - _popSubRoute(); - } else if (_selectedTab != BottomNavTab.generate) { + if (_selectedTab != BottomNavTab.generate) { setState(() { _selectedTab = BottomNavTab.generate; }); @@ -163,59 +166,31 @@ class _FriendlyMealsAppState extends State { selectedTab: _selectedTab, onTabSelected: _onTabSelected, ), - body: _buildBody(), - ), - ), - ); - } - - Widget _buildBody() { - if (_subRouteStack.isNotEmpty) { - final topRoute = _subRouteStack.last; - return switch (topRoute) { - _FilterSubRoute() => FilterScreen( - controller: _recipeListController, - navigateBack: _popSubRoute, - ), - _RecipeSubRoute(:final recipeId) => RecipeScreen( - key: ValueKey('recipe_$recipeId'), - recipeId: recipeId, - repositories: widget.repositories, - navigateBack: _popSubRoute, - navigateToLiveAssistant: _openLiveAssistantScreen, - ), - _LiveAssistantSubRoute(:final recipeId) => LiveAssistantScreen( - key: ValueKey('live_$recipeId'), - recipeId: recipeId, - repositories: widget.repositories, - navigateBack: _popSubRoute, - showError: () => _showError('Error loading camera'), + body: IndexedStack( + index: _selectedTab.index, + children: [ + ScanMealScreen( + repositories: widget.repositories, + showError: _showError, + ), + GenerateScreen( + repositories: widget.repositories, + openRecipeScreen: _openRecipeScreen, + showError: _showError, + ), + RecipeListScreen( + controller: _recipeListController, + openRecipeScreen: _openRecipeScreen, + openFilterScreen: _openFilterScreen, + ), + GroceryListScreen( + repositories: widget.repositories, + showError: _showError, + ), + ], ), - }; - } - - return IndexedStack( - index: _selectedTab.index, - children: [ - ScanMealScreen( - repositories: widget.repositories, - showError: _showError, - ), - GenerateScreen( - repositories: widget.repositories, - openRecipeScreen: _openRecipeScreen, - showError: _showError, - ), - RecipeListScreen( - controller: _recipeListController, - openRecipeScreen: _openRecipeScreen, - openFilterScreen: _openFilterScreen, ), - GroceryListScreen( - repositories: widget.repositories, - showError: _showError, - ), - ], + ), ); } } diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart index 9a1a45aa..0274219d 100644 --- a/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart +++ b/firebase-ai-friendly-meals/flutter/lib/ui/generate/generate_screen.dart @@ -3,8 +3,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import '../../data/app_repositories.dart'; -import '../../data/model/recipe.dart'; -import '../../data/schema/recipe_schema.dart'; import '../shared/camera_component.dart'; import '../shared/loading_indicator.dart'; import '../theme/app_theme.dart'; @@ -181,8 +179,7 @@ class _GenerateScreenState extends State { final storedRecipeId = await widget.repositories.databaseRepository.addRecipe( - _toRecipe( - generatedRecipe, + generatedRecipe.toRecipe( authorId: widget.repositories.authRepository.currentUser?.uid ?? '', imageUri: recipeImageUri, @@ -205,24 +202,6 @@ class _GenerateScreenState extends State { } } - Recipe _toRecipe( - RecipeSchema schema, { - required String authorId, - required String? imageUri, - }) { - return Recipe( - title: schema.title, - instructions: schema.instructions, - ingredients: schema.ingredients, - authorId: authorId, - tags: schema.tags, - prepTime: schema.prepTime, - cookTime: schema.cookTime, - servings: schema.servings, - imageUri: imageUri, - ); - } - @override Widget build(BuildContext context) { final ingredientsHint = _viewState.ingredientsLoading diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart index 42ab53f4..0d53a5c2 100644 --- a/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart +++ b/firebase-ai-friendly-meals/flutter/lib/ui/grocery_list/grocery_list_screen.dart @@ -59,6 +59,8 @@ class _GroceryListScreenState extends State { List _groceries = const []; StreamSubscription>? _groceriesSub; + StreamSubscription? _authSub; + String _subscribedUid = ''; String get _userId => widget.repositories.authRepository.currentUser?.uid ?? ''; @@ -66,20 +68,37 @@ class _GroceryListScreenState extends State { @override void initState() { super.initState(); - _loadGroceries(); + _loadGroceries(_userId); + _authSub = widget.repositories.authRepository.authStateChanges.listen( + (user) { + final uid = user?.uid ?? ''; + if (uid != _subscribedUid) { + _loadGroceries(uid); + } + }, + ); } @override void dispose() { + _authSub?.cancel(); _groceriesSub?.cancel(); _inputController.dispose(); _storeFinderState.dispose(); super.dispose(); } - void _loadGroceries() { - final uid = _userId; - if (uid.isEmpty) return; + void _loadGroceries(String uid) { + _groceriesSub?.cancel(); + _subscribedUid = uid; + if (uid.isEmpty) { + if (mounted) { + setState(() { + _groceries = const []; + }); + } + return; + } _groceriesSub = widget.repositories.databaseRepository .getGroceriesStream(uid) @@ -155,7 +174,7 @@ class _GroceryListScreenState extends State { ), builder: (sheetContext) { return _StoreFinderBottomSheet( - uiStateenable: _storeFinderState, + uiStateListenable: _storeFinderState, onRetry: _fetchLocationAndStores, ); }, @@ -455,17 +474,17 @@ class _GroceryCard extends StatelessWidget { class _StoreFinderBottomSheet extends StatelessWidget { const _StoreFinderBottomSheet({ - required this.uiStateenable, + required this.uiStateListenable, required this.onRetry, }); - final ValueListenable uiStateenable; + final ValueListenable uiStateListenable; final VoidCallback onRetry; @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: uiStateenable, + valueListenable: uiStateListenable, builder: (context, uiState, _) { return ConstrainedBox( constraints: BoxConstraints( diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart index cddaae60..474491b6 100644 --- a/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart +++ b/firebase-ai-friendly-meals/flutter/lib/ui/live/live_assistant_screen.dart @@ -178,13 +178,32 @@ class _LiveAssistantScreenState extends State { final yBytes = Uint8List.fromList(yPlane.bytes); final yRowStride = yPlane.bytesPerRow; + Uint8List? uBytes; + Uint8List? vBytes; + int uvRowStride = 0; + int uvPixelStride = 1; + if (image.planes.length >= 3) { + uBytes = Uint8List.fromList(image.planes[1].bytes); + vBytes = Uint8List.fromList(image.planes[2].bytes); + uvRowStride = image.planes[1].bytesPerRow; + uvPixelStride = image.planes[1].bytesPerPixel ?? 1; + } + + final sensorOrientation = + _cameraController?.description.sensorOrientation ?? 0; + return compute( - _encodeGrayscaleOrYuvFrame, + _encodeYuvFrame, _FrameData( width: width, height: height, yBytes: yBytes, yRowStride: yRowStride, + uBytes: uBytes, + vBytes: vBytes, + uvRowStride: uvRowStride, + uvPixelStride: uvPixelStride, + rotationDegrees: sensorOrientation, ), ); } catch (e) { @@ -193,16 +212,41 @@ class _LiveAssistantScreenState extends State { } } - static Uint8List _encodeGrayscaleOrYuvFrame(_FrameData data) { - final buffer = img.Image(width: data.width, height: data.height); + static Uint8List _encodeYuvFrame(_FrameData data) { + img.Image buffer = img.Image(width: data.width, height: data.height); + final hasChroma = data.uBytes != null && data.vBytes != null; + for (int y = 0; y < data.height; y++) { - final rowOffset = y * data.yRowStride; + final yRowOffset = y * data.yRowStride; + final uvRowOffset = (y >> 1) * data.uvRowStride; for (int x = 0; x < data.width; x++) { - final index = rowOffset + x; - final lum = index < data.yBytes.length ? data.yBytes[index] : 0; - buffer.setPixelRgb(x, y, lum, lum, lum); + final yIndex = yRowOffset + x; + final yVal = yIndex < data.yBytes.length ? data.yBytes[yIndex] : 0; + + if (!hasChroma) { + buffer.setPixelRgb(x, y, yVal, yVal, yVal); + } else { + final uvIndex = uvRowOffset + (x >> 1) * data.uvPixelStride; + final uVal = + (uvIndex < data.uBytes!.length ? data.uBytes![uvIndex] : 128) - + 128; + final vVal = + (uvIndex < data.vBytes!.length ? data.vBytes![uvIndex] : 128) - + 128; + + final r = (yVal + 1.370705 * vVal).round().clamp(0, 255); + final g = + (yVal - 0.337633 * uVal - 0.698001 * vVal).round().clamp(0, 255); + final b = (yVal + 1.732446 * uVal).round().clamp(0, 255); + buffer.setPixelRgb(x, y, r, g, b); + } } } + + if (data.rotationDegrees != 0 && data.rotationDegrees % 90 == 0) { + buffer = img.copyRotate(buffer, angle: data.rotationDegrees); + } + return Uint8List.fromList(img.encodeJpg(buffer, quality: 80)); } @@ -234,6 +278,13 @@ class _LiveAssistantScreenState extends State { (response) async { final message = response.message; if (message is LiveServerContent) { + if (message.interrupted == true) { + _incomingAudioBuffer.clear(); + _playbackQueue.clear(); + await _audioComponent.stop(); + _isPlayingAudio = false; + return; + } final parts = message.modelTurn?.parts ?? const []; for (final part in parts) { if (part is InlineDataPart && @@ -272,6 +323,8 @@ class _LiveAssistantScreenState extends State { encoder: AudioEncoder.pcm16bits, sampleRate: 16000, numChannels: 1, + echoCancel: true, + autoGain: true, ), ); _micSubscription = micStream.listen((chunk) { @@ -473,10 +526,20 @@ class _FrameData { required this.height, required this.yBytes, required this.yRowStride, + required this.uBytes, + required this.vBytes, + required this.uvRowStride, + required this.uvPixelStride, + required this.rotationDegrees, }); final int width; final int height; final Uint8List yBytes; final int yRowStride; + final Uint8List? uBytes; + final Uint8List? vBytes; + final int uvRowStride; + final int uvPixelStride; + final int rotationDegrees; } diff --git a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart index 8169f21d..3974e7f7 100644 --- a/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart +++ b/firebase-ai-friendly-meals/flutter/lib/ui/recipe_list/filter_screen.dart @@ -209,32 +209,85 @@ class _FilterScreenState extends State { children: [ Row( children: [ - Radio( - value: SortByFilter.defaultSort, - activeColor: AppColors.teal, + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.defaultSort), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.defaultSort, + activeColor: AppColors.teal, + ), + Text(SortByFilter.defaultSort.title), + ], + ), + ), ), - Text(SortByFilter.defaultSort.title), - const SizedBox(width: 24), - Radio( - value: SortByFilter.rating, - activeColor: AppColors.teal, + const SizedBox(width: 16), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.rating), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.rating, + activeColor: AppColors.teal, + ), + Text(SortByFilter.rating.title), + ], + ), + ), ), - Text(SortByFilter.rating.title), ], ), Row( children: [ - Radio( - value: SortByFilter.alphabetical, - activeColor: AppColors.teal, + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller.updateSortBy( + SortByFilter.alphabetical, + ), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.alphabetical, + activeColor: AppColors.teal, + ), + Text(SortByFilter.alphabetical.title), + ], + ), + ), ), - Text(SortByFilter.alphabetical.title), - const SizedBox(width: 24), - Radio( - value: SortByFilter.popularity, - activeColor: AppColors.teal, + const SizedBox(width: 16), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.controller + .updateSortBy(SortByFilter.popularity), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Radio( + value: SortByFilter.popularity, + activeColor: AppColors.teal, + ), + Text(SortByFilter.popularity.title), + ], + ), + ), ), - Text(SortByFilter.popularity.title), ], ), ], diff --git a/firebase-ai-friendly-meals/flutter/pubspec.yaml b/firebase-ai-friendly-meals/flutter/pubspec.yaml index d1554ee2..f363aa76 100644 --- a/firebase-ai-friendly-meals/flutter/pubspec.yaml +++ b/firebase-ai-friendly-meals/flutter/pubspec.yaml @@ -1,11 +1,11 @@ -name: myapp +name: friendly_meals description: "FriendlyMeals Flutter sample app." publish_to: 'none' version: 1.0.0+1 environment: - sdk: ^3.8.0-278.1.beta + sdk: ^3.8.0 dependencies: flutter: diff --git a/firebase-ai-friendly-meals/flutter/test/widget_test.dart b/firebase-ai-friendly-meals/flutter/test/widget_test.dart index f0a6af8a..12191fbe 100644 --- a/firebase-ai-friendly-meals/flutter/test/widget_test.dart +++ b/firebase-ai-friendly-meals/flutter/test/widget_test.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:myapp/data/model/grocery_item.dart'; -import 'package:myapp/data/model/recipe.dart'; -import 'package:myapp/data/schema/meal_schema.dart'; -import 'package:myapp/data/schema/recipe_schema.dart'; -import 'package:myapp/data/schema/store_schema.dart'; -import 'package:myapp/ui/shared/bottom_nav_bar.dart'; -import 'package:myapp/ui/shared/rating_button.dart'; -import 'package:myapp/ui/theme/app_theme.dart'; +import 'package:friendly_meals/data/model/grocery_item.dart'; +import 'package:friendly_meals/data/model/recipe.dart'; +import 'package:friendly_meals/data/schema/meal_schema.dart'; +import 'package:friendly_meals/data/schema/recipe_schema.dart'; +import 'package:friendly_meals/data/schema/store_schema.dart'; +import 'package:friendly_meals/ui/shared/bottom_nav_bar.dart'; +import 'package:friendly_meals/ui/shared/rating_button.dart'; +import 'package:friendly_meals/ui/theme/app_theme.dart'; void main() { testWidgets('BottomNavBar renders all 4 FriendlyMeals tabs and switches tabs',