From 672f285e183f3227edd4bc85dbab01f98d49f65b Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sun, 14 Jul 2024 10:54:02 +0200 Subject: [PATCH 1/5] feat(nextcloud): improve cookbook documentation Signed-off-by: Nikolas Rimikis --- .../src/api/cookbook/cookbook.openapi.dart | 10 +++++++ .../src/api/cookbook/cookbook.openapi.json | 15 +++++++---- .../api/cookbook/patches/3-documentation.json | 27 +++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 packages/nextcloud/lib/src/api/cookbook/patches/3-documentation.json diff --git a/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.dart b/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.dart index 8498d6a51fc..23c297b4577 100644 --- a/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.dart +++ b/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.dart @@ -2067,6 +2067,7 @@ abstract class Url implements $UrlInterface, Built { } } +/// Nutritional information about the recipe. @BuiltValue(instantiable: false) sealed class $NutritionInterface { static final _$type = _$jsonSerializers.deserialize( @@ -2131,6 +2132,7 @@ sealed class $NutritionInterface { static void _validate($NutritionInterfaceBuilder b) {} } +/// Nutritional information about the recipe. abstract class Nutrition implements $NutritionInterface, Built { /// Creates a new Nutrition object using the builder pattern. factory Nutrition([void Function(NutritionBuilder)? b]) = _$Nutrition; @@ -2230,9 +2232,17 @@ sealed class $RecipeInterface implements $RecipeStubInformationInterface { /// The category of the recipe. String get recipeCategory; + + /// A list of objects used (but not consumed) when performing instructions or a direction. BuiltList get tool; + + /// A list of ingredients used in the recipe. BuiltList get recipeIngredient; + + /// An ordered list with steps in making the recipe. BuiltList get recipeInstructions; + + /// Nutritional information about the recipe. Nutrition get nutrition; /// Rebuilds the instance. diff --git a/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.json b/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.json index dd476f97e0c..a595e12566d 100644 --- a/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.json +++ b/packages/nextcloud/lib/src/api/cookbook/cookbook.openapi.json @@ -333,7 +333,8 @@ }, "required": [ "@type" - ] + ], + "description": "Nutritional information about the recipe." }, "Recipe": { "description": "A recipe according to [schema.org](http://schema.org/Recipe)", @@ -408,22 +409,26 @@ "type": "array", "items": { "$ref": "#/components/schemas/Tool" - } + }, + "description": "A list of objects used (but not consumed) when performing instructions or a direction." }, "recipeIngredient": { "type": "array", "items": { "$ref": "#/components/schemas/Ingredient" - } + }, + "description": "A list of ingredients used in the recipe." }, "recipeInstructions": { "type": "array", "items": { "$ref": "#/components/schemas/Instruction" - } + }, + "description": "An ordered list with steps in making the recipe." }, "nutrition": { - "$ref": "#/components/schemas/Nutrition" + "$ref": "#/components/schemas/Nutrition", + "description": "Nutritional information about the recipe." } }, "required": [ diff --git a/packages/nextcloud/lib/src/api/cookbook/patches/3-documentation.json b/packages/nextcloud/lib/src/api/cookbook/patches/3-documentation.json new file mode 100644 index 00000000000..d0670300a1b --- /dev/null +++ b/packages/nextcloud/lib/src/api/cookbook/patches/3-documentation.json @@ -0,0 +1,27 @@ +[ + { + "op": "add", + "path": "/components/schemas/Recipe/allOf/1/properties/nutrition/description", + "value": "Nutritional information about the recipe." + }, + { + "op": "add", + "path": "/components/schemas/Recipe/allOf/1/properties/recipeInstructions/description", + "value": "An ordered list with steps in making the recipe." + }, + { + "op": "add", + "path": "/components/schemas/Recipe/allOf/1/properties/recipeIngredient/description", + "value": "A list of ingredients used in the recipe." + }, + { + "op": "add", + "path": "/components/schemas/Recipe/allOf/1/properties/tool/description", + "value": "A list of objects used (but not consumed) when performing instructions or a direction." + }, + { + "op": "add", + "path": "/components/schemas/Nutrition/description", + "value": "Nutritional information about the recipe." + } +] From c3795e2b838faa691b1afb64b4cc50ee5edf7fdc Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sun, 14 Jul 2024 10:54:29 +0200 Subject: [PATCH 2/5] test(nextcloud): test special cookbook categories Signed-off-by: Nikolas Rimikis --- .../nextcloud/test/api/cookbook/cookbook_test.dart | 10 +++++++++- .../cookbook/categories/recipesincategory.regexp | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/nextcloud/test/api/cookbook/cookbook_test.dart b/packages/nextcloud/test/api/cookbook/cookbook_test.dart index 5d517771c7c..08d0bd154c4 100644 --- a/packages/nextcloud/test/api/cookbook/cookbook_test.dart +++ b/packages/nextcloud/test/api/cookbook/cookbook_test.dart @@ -22,9 +22,17 @@ void main() { }); test('recipesInCategory', () async { - final response = await tester.client.cookbook.categories.recipesInCategory(category: 'Soup'); + var response = await tester.client.cookbook.categories.recipesInCategory(category: 'Soup'); expect(response.body, hasLength(2)); + + // Uncategorized + response = await tester.client.cookbook.categories.recipesInCategory(category: '_'); + expect(response.body, hasLength(10)); + + // All Recipes can not be queried + response = await tester.client.cookbook.categories.recipesInCategory(category: '*'); + expect(response.body, hasLength(0)); }); test('renameCategory', () async { diff --git a/packages/nextcloud/test/fixtures/cookbook/categories/recipesincategory.regexp b/packages/nextcloud/test/fixtures/cookbook/categories/recipesincategory.regexp index 89c27139678..6a4d4b7e76e 100644 --- a/packages/nextcloud/test/fixtures/cookbook/categories/recipesincategory.regexp +++ b/packages/nextcloud/test/fixtures/cookbook/categories/recipesincategory.regexp @@ -1,3 +1,9 @@ GET http://localhost/index\.php/apps/cookbook/api/v1/category/Soup accept: application/json -authorization: Basic mock \ No newline at end of file +authorization: Basic mock +GET http://localhost/index\.php/apps/cookbook/api/v1/category/_ +accept: application/json +authorization: Basic mock +GET http://localhost/index\.php/apps/cookbook/api/v1/category/%2A +accept: application/json +authorization: Basic mock From 061a79f66e7aafe1196b2fdb70ee609738ca0b38 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sun, 14 Jul 2024 11:33:46 +0200 Subject: [PATCH 3/5] feat(cookbook_app): add initial scaffolding Signed-off-by: Nikolas Rimikis --- commitlint.yaml | 1 + .../drawable-hdpi/cookbook_app_outline.png | Bin 0 -> 884 bytes .../drawable-mdpi/cookbook_app_outline.png | Bin 0 -> 631 bytes .../drawable-xhdpi/cookbook_app_outline.png | Bin 0 -> 1170 bytes .../drawable-xxhdpi/cookbook_app_outline.png | Bin 0 -> 1690 bytes .../drawable-xxxhdpi/cookbook_app_outline.png | Bin 0 -> 2226 bytes .../res/mipmap-anydpi-v26/cookbook_app.xml | 10 ++ .../src/main/res/mipmap-hdpi/cookbook_app.png | Bin 0 -> 1223 bytes .../src/main/res/mipmap-mdpi/cookbook_app.png | Bin 0 -> 862 bytes .../main/res/mipmap-xhdpi/cookbook_app.png | Bin 0 -> 1548 bytes .../main/res/mipmap-xxhdpi/cookbook_app.png | Bin 0 -> 2252 bytes .../main/res/mipmap-xxxhdpi/cookbook_app.png | Bin 0 -> 2945 bytes packages/neon_framework/example/lib/apps.dart | 2 + packages/neon_framework/example/pubspec.lock | 67 ++++----- packages/neon_framework/example/pubspec.yaml | 4 + .../example/pubspec_overrides.yaml | 4 +- packages/neon_framework/lib/l10n/en.arb | 2 +- .../lib/l10n/localizations.dart | 2 +- .../lib/l10n/localizations_en.dart | 1 + .../packages/cookbook_app/LICENSE | 1 + .../cookbook_app/analysis_options.yaml | 10 ++ .../packages/cookbook_app/assets/app.svg.vec | Bin 0 -> 1152 bytes .../packages/cookbook_app/build.yaml | 0 .../packages/cookbook_app/l10n.yaml | 7 + .../cookbook_app/lib/l10n/arb/cookbook_en.arb | 3 + .../lib/l10n/cookbook_localizations.dart | 127 ++++++++++++++++++ .../lib/l10n/cookbook_localizations_en.dart | 10 ++ .../packages/cookbook_app/lib/l10n/l10n.dart | 13 ++ .../cookbook_app/lib/neon_cookbook.dart | 38 ++++++ .../cookbook_app/lib/src/neon/bloc.dart | 11 ++ .../cookbook_app/lib/src/neon/neon.dart | 3 + .../cookbook_app/lib/src/neon/options.dart | 10 ++ .../cookbook_app/lib/src/neon/routes.dart | 20 +++ .../cookbook_app/lib/src/neon/routes.g.dart | 33 +++++ .../packages/cookbook_app/pubspec.yaml | 44 ++++++ .../cookbook_app/pubspec_overrides.yaml | 20 +++ .../neon_storage/pubspec_overrides.yaml | 2 +- tool/generate-assets.sh | 1 + 38 files changed, 412 insertions(+), 34 deletions(-) create mode 100644 packages/neon_framework/example/android/app/src/main/res/drawable-hdpi/cookbook_app_outline.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/drawable-mdpi/cookbook_app_outline.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/drawable-xhdpi/cookbook_app_outline.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/drawable-xxhdpi/cookbook_app_outline.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/drawable-xxxhdpi/cookbook_app_outline.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-anydpi-v26/cookbook_app.xml create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-hdpi/cookbook_app.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-mdpi/cookbook_app.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-xhdpi/cookbook_app.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-xxhdpi/cookbook_app.png create mode 100644 packages/neon_framework/example/android/app/src/main/res/mipmap-xxxhdpi/cookbook_app.png create mode 120000 packages/neon_framework/packages/cookbook_app/LICENSE create mode 100644 packages/neon_framework/packages/cookbook_app/analysis_options.yaml create mode 100644 packages/neon_framework/packages/cookbook_app/assets/app.svg.vec create mode 100644 packages/neon_framework/packages/cookbook_app/build.yaml create mode 100644 packages/neon_framework/packages/cookbook_app/l10n.yaml create mode 100644 packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb create mode 100644 packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations_en.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/neon/bloc.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/neon/options.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.g.dart create mode 100644 packages/neon_framework/packages/cookbook_app/pubspec.yaml create mode 100644 packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml diff --git a/commitlint.yaml b/commitlint.yaml index 6618e1c9b04..816d941a33b 100644 --- a/commitlint.yaml +++ b/commitlint.yaml @@ -9,6 +9,7 @@ rules: - always - - ci - account_repository + - cookbook_app - cookie_store - cookie_store_conformance_tests - dashboard_app diff --git a/packages/neon_framework/example/android/app/src/main/res/drawable-hdpi/cookbook_app_outline.png b/packages/neon_framework/example/android/app/src/main/res/drawable-hdpi/cookbook_app_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..07a57d59ece0ba23e72c8448deef10b608e37779 GIT binary patch literal 884 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#Xx#G2s2(`(`f=SBuiW) zN`mv#O3D+9QW?t2%k?tzvWt@w3sUv+i_&Mmvyoz8U}p4maSW-L^LEbeY>_~b_WeiL zy=q!G4hlHV&N$$7)NMk?BjzigRXt99E6{RHQC4mCky%Meh zw>=(lH%qDhjLf;jV_p4hXEF2h=IA$1&)Dw&S(aWNq1UX^b5T9h=Mul?c9pG1uatU9 zP5csk$=GwRVd>gQZK}0XzXS%=H}QORYhN;X(mSszovfi z;&^+-hu!Io6Ie^bWf=~MU5PAZ;D`>GwEOiX&d30P9SY3iDG!=>VkS6>Ij$By$GFBT zP+BFDWi5M|`?J2ISLAy4++%rj=E7#9P5+L6Vvt~nW2|mGIAa&%0@eza92Pyn6Mub! zHgUaT$y15>f9OxdlpoW#G;$jiII`)TIKV0y(e%$$mGw_<(EKYAO?Kw0tUe|y{j+o& zA0OC0$@mPTzSp~nlHM0|3U)u?>5n`zU!g%UVbzj<#s_{*x~A8n(zLAM@qzhG)#n+P zH*RNLAFbh8|IB^mTkc;W0rx!@%>Sf+|IU(~{8fB8tmmG-sg%9JV{>MwU$95@;vcjB zsDESmF|W9H*~9Wn5T_t~3UnVwhuILAH8%qbP0l+XkKz{PVL literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/drawable-mdpi/cookbook_app_outline.png b/packages/neon_framework/example/android/app/src/main/res/drawable-mdpi/cookbook_app_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..7891b155e2554db0022e9b8e36c11ca44d7f99e4 GIT binary patch literal 631 zcmV--0*L*IP)k22RZ`WdElWC;v>~bDasS^&CDkQuOFCA2j_!JT;PJ_SMGjtaYX2g&*~sV5 zok^!W?uw*ENf#ZOcG-^e^%GAuG9EP6-2L4500Y1#Q1i^&jyNBc1fmdDN!GlVJOfMt zb7@QS46u-eG_L?vU?vN3UIAVL&$1Bb6<{PQab5v@%XKh$FQci`69M`><41udz$?!f zU@S5o4{xV?EGO4A&;WLTk4|m&?zIWbr`VSn>*`P={x?ho_>f*Z-P02%@uMco0LsvP zk1s$o1@~bJj*j>O{0Q`(0Q-Rs6TSc|z*&HI1-J+}-i$B60q_>MOyFz)^~4=@NxPGH zi`JF*o6&U&ya(O^*MWVR>9h*%1D}nqLtq`a3F@dz%IiSUlmUw7$p^^nX4_#0_z4Wh z)P_8to5(9b6L{^ZeWLZBX(FEdSEvk7G-ZIIDFYNu8K7v&07cUi0j`X$q&M|DpeUiR(co5@C(uBd4y5Bm Rh4ugd002ovPDHLkV1np64&eX* literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/drawable-xhdpi/cookbook_app_outline.png b/packages/neon_framework/example/android/app/src/main/res/drawable-xhdpi/cookbook_app_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..56852fe98c9c4e892593ee3e98c8138fd677bba6 GIT binary patch literal 1170 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#X$NL2s36gi);ciBuiW) zN`mv#O3D+9QW?t2%k?tzvWt@w3sUv+i_&Mmvyoz8V3GH9aSW-L^LDOxhfJtQ+kQ^t zXNruf3;dV_Ygp&I9Q763^?|X;TBQ?UV3*{^xyLPmhx8?$v?ld|M%>5>sIZ%<0!;=ThDX8 zhn4!vXwToOzFSw^)_&QtNJ*jR&N!TSp&+97Ba&IkDwN=UVyR>|g zpLbMH`81WY>-V{@EndQMPcJj^zyW>UmA9=Iq`6+2>->t3cg1hFOV6f=TO{08dnxLf z&i3W}!KNiQw^ar;oxjSnLnYJM`EKW|UHsBnWgfc%g174{eqMTe$`a;DV%fi*J=wZ# zr`d&h(OK{AbG>;JP@e64_UwU>v&q{{uUy^8`-7uGe8(c^TCKIle(ENb924gUDvQomz?IM{vyO^J-xE?sacd5r?@$!Z_ zOE@g$VmZT{{+sL4q{#=upYcR`3U1)pb2)s%G#Qir`E#!YuCc}fxgB2XKyim{EZ>H%G0Ff63ZF}{qhyR z`F0o+lHs-RUc$5 zv#b3o{k3B4Z(q;IX%B8XnI5iiZ;P8B>?lyt6eEH+K>jTyvFnZNG zGv{Zq9IGs|9GjTcwfT#pW}MurGE237UF?OWF`i%dC(A93@_zBD?fJaLQLG=C9|-*H gciUxf>Cbzopr05^XCQ~&?~ literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/drawable-xxhdpi/cookbook_app_outline.png b/packages/neon_framework/example/android/app/src/main/res/drawable-xxhdpi/cookbook_app_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..2cb6b4906e7cf767c3612f5f70d6e66a641e2353 GIT binary patch literal 1690 zcmb`IdomtRLBcjzGfL!=aYi!ZGHxNOnnrHJj$GDEB3UbqTP`uH zaZ4^SMCDSEbQ{J-n@N_KBEvK?BH8KK_MhF({y>LzU4Atlp%wx;*z0%qZ)Qz$YV2ZzP zOQ7A?Z5fa|CliLJwV%lIP%pqKw4`5kv|H4vNR5!7!FJ3!rl&JG#vGkpyKqBT-pGWm z1L_Hix6T!oAcd>l0jH8m*OR7axwG5?J4IO&9>#Y&v49yuUviE=%yFbyL}at@aHHi`7hT-IIANKMsgNJKY5> zMY$7}Osg{>#yU+Uu8dh|G3>ANW?6yMX-LHOzJHR>3Qd3iDcgKsZ*cytlI_G&rnjD^ z+L22A#JUMmfd%6yj5ZwkT140PA_%46>kSd7dQpMuM>|sJCiPLPwf^^9;%)8MLmvKN z|8za8RF^U8viSz8-cxbh7M1s*9yb+l7yt_cp0FD!m&+-^OUc(E#VV2lD%54pHwfMvQs#aetU$t_ z$zUJtbYd?Fx7wlbxnwn*>qkbD(a8QOI zqvbE;Kr?*u6R2E`LrF+Ot@X?ko2xrlqvZ#r#Jst|QgEEkwP(?wS9tfhP*$p#85=z2 z%Lkv|0iTkCH;BA)xG4&~xw^DjJgF?E_y#fW@7Ksqwb}-YvQ?g-8ux%c>Ch$x=^wWL z9T1HeO{*S-Ra;5Ej;@+~NxcT|saVSn!jRDkBSx)(NM~_$=T%f!PwWcq)RtjZM$s); zLeWq%m#Z{>PL5;(gsV=Ynemm9ErDAwY+d7F?FD7~11y!e$_G@h zfFbRs58KSt<5ZVRCi0S+sjD@jYmqqyHv1{N8nu=s+hhn5TqW(1w46!2uc>4{!oT7C z1V7B~jzyjB`qq*EddN8`rY@d!H;513Fi;zaz*HVT8%UNPa2%vAAqvYioj50nSaXJH z4t8+!=tjR4(ud!~foQ+dV*FeVYC>9>D!R=(x~IAJG42`b_ZqN`PT{o+DX)ET5-hc4 zo7?F2O%V39R(UN>ViB?6d^$Gz)HloKB87mw8N?uoN>QwP%G9Rzk>v+N4a*)7umZ13 z^;Rwd>#mQ&GHNcU$&4dCu0KOsdf+~;H9pBri3ylN;JYf~E|0hN%vQz#7wrW{9bhxP z&4L6&9bYE_aMN`d|FMWKBFpc_EzV47B!fE*Z1N^iqgzwC2fZHa;=zQDr7Fli=FHFy;wEcTBb4@OrmXao4{H@KrOu1W$$d4aJ1I zkvbxUli8D^)L$fKsk2mTTEFbDIxwwIsEQIh>-MRgX;zRW_3EbC*M0kP>OVyMUxFez zXZYMuYsbTxw;#;64A9b+PI(g?Sj>6Tz->JMmU9suYBXJcw6^z^E;7{?@^MA@QB{0J}Q`&cB|;?}2LW zEz|FUUScW6Rre(%f{!3S%?oTg25N-;f|Q=Kv@Lu6lVOpt7n#!NA8k+&G-wP}24Cv= zi_97|+EkwvN4}_xok@)5rml@zjfZDQlabV#?lbgckxH*!qHLJNSkSAr-Q3|a_RAq2 WdS%pS>bdw|0WgjS9qRW7o&FOk%m6R| literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/drawable-xxxhdpi/cookbook_app_outline.png b/packages/neon_framework/example/android/app/src/main/res/drawable-xxxhdpi/cookbook_app_outline.png new file mode 100644 index 0000000000000000000000000000000000000000..7eb2ecf5ee215ccbe6a431d3fe202e431fefa78e GIT binary patch literal 2226 zcmb_eX;70{7X3ol4WKp%LD2?^LZl%`!X{e;6bOxIL>5s9B7v|pX}|`8kT2tcis1AZ zlMn)el86gJO+-KlexnSigk3-lOVlQsW(g3GB|s)t_rIQ{f6R~j?tS;vdAI7;IaTlS z;Y0pB}LUlW*&&hJeo$# z%!*Bq2ePuV>`6Z)XT-&l8f{PDA}Q1)B6ZxZ3*x0YpFaencZdlDn)p5XUtg(*i!ulJl@A=X!VzAZdP4?1Y@ zE5%e$T_@d9|4Qn-B>Sh+{`6*<;>4%5TBY(y{2H8P9)*i_^v-=5lb%w4849$y6L^g~czwfnWWIc-jzh)h)R{#`q5XG4HOjvbwLE-rw^2HsIy- z!6kdbgwLqz%ddF){YQ-~g*LyKOSd(s@;?eW>JsWd+Vjl16!pF0h^FR$ugWR~7 zT;d589`9~OE<=}?P3I+#SF0H?>aC_xkE8BjP?V5;-UuR!^)5(4`k&I!tf_TZV#qw9 zccUR`nr{gf@h(OUzQQ(9&VSfkHy?#FOqj3A+ae_;iZ?`XIPR_Cf zZv1FeS0qWZW1GWGF6u$U(v8-GG9*@+r@rj5@m_F^=94fu&0Vy+Gv%#YA|ut(6g8Hx2y*)4z$BeCm@-j){uNE7V(!?v48 zoy*^>3hEcse2KuBDD{>{3tPWd&DyTq->W(mmY~9pP-vBC@QEEt0QDlVql_ETgwKeI zXgN3#gxZ^_dAN1}huTboz3v3|5rD-ch4WR@ueSzwDG1w*fgeuPtms8xE0LfN8n{3N zbh`jU_P+&cc)+`}^MN9lqF-0j?}Y}#IDH=+6CQIGISgj9d&rG@HlTAFY~vk+9|r5$ zIp@fyg*R~S;06sL5%{SnyJYDR$4M)D=@K)?eL6qr?kOjYI-v1FiEJUdU16MKiB`U; zMAJ&!SzHwbfa0NWuxVh88VmUnR#Y#(U*Mhv{HuZMSrKO4Gl%8nY6}A`-DH6RDla$ptAGQLt1&w>x zxFY7hFDLX+SE%PDeb>D_a0|!8Ke0vYrl6_XU$5-_?@IlT68-~#6?}Pbok~mq7WZkd zRjHh%a#XYCa8&hE3TDAjI131vTUa52cevZ-MRbIj-@G-M_5V z+JGuSIu!Xr>s;pVja_i1ouJKdhBvd`ZZy9(YzUbX{H0brvcFWraZ}Dl0+*h?qoKwK zl7UTJ;}!2*kO}w)q5c-xD32LP13+(}ff4;d-Ag!IyS90DZFn016?~O)_LUyBEJpM# z-^y-kstc_=)49vw{ex$@Q@{``#Bl6(a%?xX=qU`?&_Ch#w!o}Gjroo2l>x1rS_1>t zG#WR*8JZjEpcO&xi93;(_q46lx&h!hC<=6fW`z8$UB0QdwBQb<`;&j%R34YXIl?3{ zUc3lpkbbydJ~SVpAeAfsZrr%(%^~^y7;BeCxzd|j#` + + + + + + + + + diff --git a/packages/neon_framework/example/android/app/src/main/res/mipmap-hdpi/cookbook_app.png b/packages/neon_framework/example/android/app/src/main/res/mipmap-hdpi/cookbook_app.png new file mode 100644 index 0000000000000000000000000000000000000000..d401a2730d3029be300deba9a417106c3e44d643 GIT binary patch literal 1223 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!oCO|{#Xx#G2s2(`(`f=SBuiW) zN`mv#O3D+9QW?t2%k?tzvWt@w3sUv+i_&Mmvyoz8UBigb-r3@gJU12yvR>ixk`m}$@sOV(>41c(VZw()8lCqfk|zk5I4+Kxxhh7) z(Cq`0kAhmp>FY`v8$~arikBTNOTYc@T}7(1_2%bu@6Nqx>3go&eEIu%pI;jnZ&Np3 zuKe(AFqg!deZ~#7djnlw-?b<)?0tJb_TZ0@z|iY&${$VUm8~$5I^f`RY}!A2LI0;A zrDmKJ`M%cMO3W2>#W#F@!SPG)PKxUVl{cjewy&0xPEx#cJD{CyKC|F_;Yoex#Y7*( z7YVg1alLf!!pI_fJtOb;gDVEyms8_^6`%$eBa;5 zA9-`G{LcN3{;I{7k56W0_2!>*@Thdw%qW(-WqbYw7{|q4WSM_?*=ysaI~1Q6Ucblq z$2d?U^03Up-6zeR`54x`<;c3WZsyf}FZlRxHR)|Smpc7}hsl91!?Rss_K$8)e{}Y$ zZs=*ohM#flyJpv%kz#+{F4z96>8r(-=FF|Hb3ade@wv=Qe`deGdD#@1{Xu*DzAZ`N zdeo|J_4?yc> zRe2j1=)O4mRB*NO2kjG?lRu;^-x|(;VB><+-qLAx-~aG$Z;CP%n;CXs?ShW}epRXM zO<&fm%Vw0jm4VoFN z4%Fo2OH4MES}ruBN$Byvsaz8n0i2@#7^*MF!m^b7T6!wes^_m-NYQG<|U>UE13B^ue_X3=%#mCuc?6NX}x*TP~r` z;mG17&?w-dz#>Qt(^P!PhXVC~W-Dw`xle`cTzUD$TK%-=n$ZD9vyZ>GieGLzd*ewa zwvsy?&o%vBgLBSzW{a{`{ePm7()`_u>DjZ5i_>36KP|JFapKVXO%ETI)xMWwZrI0d z{_)fI!^>=VOpN(I>sE;P-?!yoZ*!xAJ?hUa{n*K$UFYo8dwk&KYl|P*m%gYkO?&c_ Zzu=8UjMw6A9Kb?|!PC{xWt~$(695isEZhJ9 literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/mipmap-mdpi/cookbook_app.png b/packages/neon_framework/example/android/app/src/main/res/mipmap-mdpi/cookbook_app.png new file mode 100644 index 0000000000000000000000000000000000000000..5f76bfd21a3bc4aee90d1ca3ab7555a7dd70a00e GIT binary patch literal 862 zcmV-k1EKthP)S~H?=MQkO&cKTD~wT&A?ui7+6r$3n>XJdK>ks48nRN zzU)QxrWYlFBE5*-1X0k6@P$~dF)>1Skg}=UTKn6JU~}8E+qv7_<+#tQ?S9XF@Auj6 zx!pZXfiZg=X`E~_;>kcGmN!E<_3S%-ZiEuG(GD}l`)v5^_G93=6TVP1Q5)~G;rby9 zW*r_}m~kPNk({K#NMoeE3jK`+)EjA3>{4YNyBdgKcy6-A&-L*ZD1UXW~21ez*n(3C=uY*7SE zTD4%=q6nx2Jc_87Q6VUP0mYWNC(JbDM^1rOi#GYXTANa9F(==*rPaiO{Voc?kv%$0 zpKZb%6TsyIRVlUMwtw##e;C&eT7>B^2RQ`*V6eHIcn_mg0GHCF#HRQs@&Ars0&$k7 zu|h%=V0>ZT!R`WNaDgcBamCLo)EkV#u@>Z&!4;yw#0Lg}a4hf82|I&eB>(-l15sdk zGlbJqPW*{(|3set=E41UE4&-yO4dpIdT|}CkLK}aX@gUcFBHYa85f3LI1yo6Pbo00 zS0Tq*5Hk)Bry#d4jHmN!tZP9+ApZ_XjVvHFJ6?d(?KEHJm;gSl_)$q%#5I&q#O+)W zMS$^z(fN3RSi8|_!DN43-q}0;6p{s`Mi!77SwL!J0jZG%q^9@s7@_mr!o1atQ z%j$1V2Uz&UZCs)Aa|*ouv58l%b=EPFSR4=DyE!#d=zI#jBX@EgWY*z@p+p6%UX${t oj$kAP`?4SBr(L|3Bl+b21;DP@!SQf@0ssI207*qoM6N<$g30cRRsaA1 literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/mipmap-xhdpi/cookbook_app.png b/packages/neon_framework/example/android/app/src/main/res/mipmap-xhdpi/cookbook_app.png new file mode 100644 index 0000000000000000000000000000000000000000..1db79fead86573483f56c87d620d19a2e5704b3e GIT binary patch literal 1548 zcmb7ES5OlO5Dg%PVgf`=B1NP~4Ip?_5hR9~2!fOd(nIK73`LO^5Ta5PLXm`K1VK?L z$^p_22p+wKYNXC^AjJUE^>TRkmCw+Fy_XsL+<(kEAp)_zv%37?tTqFM$)vwW9_~qbkc@iDjS>mCnK#21&rKj$#^j4?p3=F7oAB z>FHDR+WD9l-TdKPWZ&J$XfLn+9@-{$d|Fc|g;MLU`<4#C)hWT*yX-sN05Iol!Zi>x zQ@8SsJj}#gWirv-AgUhL8*|%cIR^yEZ$1Oup%lyZdbXFX@vmGMiRVhQ@T+~1a|}}R z+Ke%ZD;uX}B+EQMp|T>ci|8OvQfZ*Vy8ieM^*aN4n)JH-XSj>0Z6ud%N~?#Cs$$GS zw_(FKr)IatdWeGI`^a8W)kKTIi>c)n3!dQzTe)j?_mh-af?fKTJuBAp!hZi#PgNSo z@X(oESA8;WJ&V5nKyNOL0)PD7E~XarT@3iZ*ssmPzteGO55Pu-f{&$#G)8IFnRX%2 z{QaM3bUTGH{BQ9O^`&Yz1X;MxLr}Ji93YLgh#n(|v)w`iZ4NWab}FWkHgV!ZtH#}u zP}z;)^4-lUoruQdnXOSJ3Qx-uT#bJRQi4-ROBnmMXCC`FR{=l%Wm(R-T|b^zxQ&~P=W4G2xY+?T5V3G+ZpxJL%SNg;rVBWd zx&)HuzW%0yS5^I{k`cAjqPoRXAbKBS%ijJ<7UqgnsU|hW`Ig%_7rJX>A|i%|7`ECO zTszqc9m^;YK9Pz5oO0?Kf^>eb%lBLogB<^HBMYJG*kk(cc0`r3;AqSk*9pB>yA4ft zp@Ugd!Zm88Ma+ANR-cbJW@P$lccR6~0l&T+oo# z#2&!nQs>XjLoXf^JdLi7>&zu*vpt#qCl!Q^yh|xM}#d(;_ z3~J0nq}MUv3u$~CnV)txTAF(*ND`W5_GQIHsA->o#5mX&CA*MgeWoQo8?7elR5Y-( zWUHj!r*TegwwqDb1*yDwOL^@9AM`a0ByYGizaBivl+d%tf}|)%^B*`QfOVhuCUkU} zv2ZJ>%I-w>%shqpDS*CO=WZ!M^r{hSn!=Nq~~1(0EtAZ`33pk zgL{S`)PnE&6#Os{0|2;OEzPbtMi$cN`~zUi=kS}wkh*jF&2Ys8h2A6-jFThV9>_Lo z4O2Sz+s)t2TSL#rjs>mmRZPs2<{HDZs{AG@=E|-#MT|CrnvDf=>oF?%k5RiERW2qL zCQ6wKnf&-umW59s(Hc$pIhlp`nD}LeUgz*1yu(q)4F91;vQhKmd))?p?x;fYhdM#) z!Jap~Sd#w5RLZ$i$17pFt;q^n>8_HSl&QmO0cniI2r_9jx3^=vok34B^421dDUB3r z_;#HS;!;kcfvAKnW%unv#v(PSaZg^GXg~0+*3$&roZ)R5v53;?^#{WUD2bjp zSFz4?$WyK1vne@nkQgqentQ2U0FNnv&I^VtjytdNgQ6>rT^D@0i+@rOh3jYSwZFso z2slWKY9dZ2{A2;7ua2@IROaaT6?ti+#Z9C7om$?O&nHI?Yhsb!$TK@_C`Lj@9PBFC zTO)+ihX1`n6X?yl_t?zUspoHv)PYATCRbRQGU2#(_1!Nmj&lc$6k#Ji7 z2X5#aqN0x-(B#R-tT$9wlPAmC*2}q!`hh7oC zYx^N^cMX2{cnurh;k+0rZ?`1_J9HV}5SrTDS{9)+&4pv%smAvlc!)b0=#GV6W8NiU z)<|cz&M#P_p;IrfwZM#^-%?!VR|gFB8ry{jl^=(na}F+7epNvI$ZSsiarbdql)fPb zv$fd!RCJ&{$IZQu`gfpz1cHGnwtO~3*C`g&UZi3 z(5>F~T%wu01~&S_#9ol&enpV9m$4Y8X2+AL8Z0+_lEaQjHp$eh)|3}+A% zn+Wu?`-aRjOls>!X`RnAE#A)qIh(KtkIWtthOe{6^VUq`v1jCZP!@^6XI$(u0vu*C zJdeyo`BT-U1U-%aop>Y-RQkD8ln+^%5}Ic~sS#!A(m;>hd#K@dGcfsyOD^KkpkL>EWPGauwJxzCzl1gj?$&Q_VhY_aMdXSmgwv} zj8oh?1}EL{AzSX0*75IYk9W~u=G)7C>G44J*BbXp6F5quqUmn3(+%|!m*c%(Pg0+o zB+m;_KTQac8}(2N?MQKvXz1dFdbnwmWX6I=4YfI>9L(uHClkcD#w?RGGn(l*(=krO|h=YyuwDJDk=KpdkkJtuGjyVqk8$j(v^(Y~S5)3x_R|8dbse!& z?t}6);b@+Yuodq4Rj@|1a!w_!0PTF5Lf*KmGEe=qps4Wby63i}8pZd+F7NvTi+v;| zIQ_a8&I&_5Is~OvZqsBan@>;Np#Rdf@mSWDZ2$VT0G*Ym8W+>dnKI3>L1>NEiVt_? z%To=h*Gwp8(iCiB)x8HKxLs|ECx#fJ9idz7YkISJgL8xx-Ss^$T0%sBmTdBI!|m@! zh@>rdw~i%6LCgPz)W2NTe?izUZ`Z!cjbN@eHvI0p!Qq#*B))q>C zs&wRp8=%2i{89;IH>a<{*XP8v>8bXERW!JZF{F^zO^Td;GY158><{yS=86-YKI3aq zhvqQGBwDRBA_Y!AB@w90bi%w~B}g>m&bVdaz2bG^LEO#5=j7P0*M{~>byaFI*o&jK zj#|^oNS}tTV2dKgp~sib{tEc4F2)=WT8&SauWmL<=FC31k@42lmukg&C-}<2v2oi$ z-WMM(4~J1w;m)ATxumR#NqqA1ycYfUH#43%k=*1F(ce5LVj#jNZfyuD=~vm5HDI+b urZ&%5woPAp#D3xD$$dEXhD-4;z|Mh@#mA`Xan?@+V0qQXtj6T_gMR@r)-B5b literal 0 HcmV?d00001 diff --git a/packages/neon_framework/example/android/app/src/main/res/mipmap-xxxhdpi/cookbook_app.png b/packages/neon_framework/example/android/app/src/main/res/mipmap-xxxhdpi/cookbook_app.png new file mode 100644 index 0000000000000000000000000000000000000000..d824d5bb6a84bfde3dc82e68dc41116b2e716325 GIT binary patch literal 2945 zcmbtWc{J4R7ypj2G%@jJCu?O(wlIj0y%Lcng+Z37VPqM@6k|(diKeVckr8ES#*!(< z9?BboY*Ti!Pld5G-tp&q&hK}A?|XiK+~=PA+R7cHO%#SQ`h0JX9- zwPR_@z8v6Wt#)HbE|!7>T3!hT01$p(Kwl3ck*rP85Hsfxdz4Q|m`9K|5Ed4uf(W=7 z?Bx;Yt%3^j&0WzK0|4$rR;I?6F?q{0Y~bY?iFUd#u`}TDvD`9<3@j)*klXA*8O}LN z34cDx{s=p%?OEzT-^);QB?;7We^*HHGQJDXR*gg3j(9$c>9cK;jfG#5F;-)jfyh80 zGJM*@H}~e%sETn*`FX@SgB|Rdy$sss4DBb)U~XyUinyEJk(g*9JYlL=Jq+&Yc39&Y z-S7L43CdQPGqy;SYq8?Ho@4ZAG(is9n)gOipjdAl8fP2a8S6i=%%F9gTw}!6r+C*6 zudO+~sU*qrdj)Yy4|`ZpwNy6-Lnkv6RG$|Q6plKZzX6G4FE>x`UgznwG`$ib+oh3b zd21*=$FBp&emW0*wSShM?eoY?%S5{ha<@nj^|T&2kI^n~ai|&|mh1aJq83!Z&b0Ty2^Og-{T+9;99%am0{xVSls8v@GWPvR4l zG_=wj#?kQKW0PlKk>icxtAkw`mCPF0O<&>oYC4$mb934wC^E>nW80n=xF_?HD%!> zYF?7TCH}DZ8r}GKFkF^8>hx{3x$+vHBICC`U4M_}CrDRN2 zCcJn0142uSJZLYG6X1kjXdO20}$F+n^%6P zPoyj%oW;PEC0i!cDnuj(1=y{M;5Z%?pRRiQh8-;q+hckHLQ|yCwOO#YWvKav&rupH zJj=;P5RP1VZ5V$p-3t)X(1t;5Qqz`#9Ye4@6xm561?4MztI^$;Wk`xWO0-gEY)KN; z0q#l=#wRIUsw~x;hr*_m@WShIP4^O_^%y4{bV7cO@_Iu{sd{LT9NicpM-S{-BEBnu~?xj^^(e-t`0y=4POl&u2S> zz_!leJ{aL~*=3f$tN9(91fl~FaLg%R-Zm3B!&#t^K;wip{_@n}EGQ{r57_Oc00NJu z$4&U5*SyUT?|F`z-7R~i(C}MOf(K{n1soFtOZSQZiS2-;Ca2MJb>MIc=z-sVaam^r zq}?BhgZIr5zsn)2c7j+uL|R@~W^$CLKTsU_aIp`4`t?+LJstv&@R)WoCKD`2A5qP{ z$q%%o&paP(9mbKl8T@8d=Gj1lt}J*fz{0dP{B~ZARBGiY5`#pdS!UpMxggDkpa-lZ z6c)y-H-+QxoZMag`iNHHJM9oQclS5Z1pJV{(#I!yw#w4Ai=9QPUDPV(Vw>fBVT^Yn zajk48XVLSob*cd>1j$rD=JUXIr-VlHW@z#ywCiWcOK zv_D7dC%EHmvlfhQ&RcrAJH8tRak7ulo% ze&7W9Nn2@Y1d(u7%yeDcL-K#qlsH*tb}>$`)XSCxQ+Zs9le8(3YOO3{}&_r zcbNR=!A`od@`IkKU5EoIloI7}cihoA1}RxA7OB}qg)&H6^~2PAh#T%Hj&<7?RppSc z+LL8roCIB{#3FVoSWRivdtOAz6G-~Wc@|UA!*Cybsca)G3WP=f0|xmg$NN{5^nWF^ zmjh7Jej5VY{ZqWGF>0}Njq+ymTltY~5P!y%L^)EOQP1pR52WX8^BPedDu(UVlKa=4 z&Fa^4g-FEBzuUB~+ zW5n-%Jyzam2LdR(-%h8csgwj8ZQM7p#t5Q`Pp0N^ub;Qq^sCz|V|shS0}@`&4gBZZByJItp=>rNT~pflPVs~A<%lxm+@ zs25-(9p~V%?!24ng4tcj7t+kZuh&kzXz4O+`=pHa`t*@4`IEH appImplementations = BuiltSet({ NotesApp(), NotificationsApp(), if (kDebugMode) TalkApp(), + if (kDebugMode) CookbookApp(), }); diff --git a/packages/neon_framework/example/pubspec.lock b/packages/neon_framework/example/pubspec.lock index 9254e0a2c09..cf4789a5692 100644 --- a/packages/neon_framework/example/pubspec.lock +++ b/packages/neon_framework/example/pubspec.lock @@ -213,6 +213,13 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + cookbook_app: + dependency: "direct main" + description: + path: "../packages/cookbook_app" + relative: true + source: path + version: "1.0.0" cookie_store: dependency: "direct overridden" description: @@ -311,10 +318,10 @@ packages: dependency: transitive description: name: dev_build - sha256: "23677f8577ecd4a438171015d2477ef61fba5d17596c3292faf23bdbba68a316" + sha256: "4683bc149176b86a8124e938ca85004cce44db0644972ddb7f5bff7831ce7fbe" url: "https://pub.dev" source: hosted - version: "1.0.0+13" + version: "1.0.0+14" dynamic_color: dependency: transitive description: @@ -334,10 +341,10 @@ packages: dependency: transitive description: name: emoji_picker_flutter - sha256: "3bf6d4cadc188215570a15c80fd7aeecec312b1cb3168ab08394e0faa4161fcb" + sha256: "08567e6f914d36c32091a96cf2f51d2558c47aa2bd47a590dc4f50e42e0965f6" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.0" equatable: dependency: transitive description: @@ -390,10 +397,10 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 + sha256: cb284e267f8e2a45a904b5c094d2ba51d0aabfc20b1538ab786d9ef7dc2bf75c url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_platform_interface: dependency: transitive description: @@ -543,10 +550,10 @@ packages: dependency: transitive description: name: flutter_markdown - sha256: a23c41ee57573e62fc2190a1f36a0480c4d90bde3a8a8d7126e5d5992fb53fb7 + sha256: e17575ca576a34b46c58c91f9948891117a1bd97815d2e661813c7f90c647a78 url: "https://pub.dev" source: hosted - version: "0.7.3+1" + version: "0.7.3+2" flutter_material_design_icons: dependency: transitive description: @@ -625,10 +632,10 @@ packages: dependency: transitive description: name: go_router - sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" + sha256: "5cf5fdcf853b0629deb35891c7af643be900c3dcaed7489009f9e7dbcfe55ab6" url: "https://pub.dev" source: hosted - version: "14.2.7" + version: "14.2.8" hotreloader: dependency: transitive description: @@ -1137,10 +1144,10 @@ packages: dependency: transitive description: name: process_run - sha256: "112a77da35be50617ed9e2230df68d0817972f225e7f97ce8336f76b4e601606" + sha256: "36166b6e33a8056e3b368f20504bd5ff70f2439d75a8da90e4926c1195e32b4a" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.0+1" provider: dependency: transitive description: @@ -1177,18 +1184,18 @@ packages: dependency: transitive description: name: quick_actions - sha256: b17da113df7a7005977f64adfa58ccc49c829d3ccc6e8e770079a8c7fbf2da9e + sha256: "2c1d9a91f3218b4e987a7e1e95ba0415b7f48a2cb3ffacc027a1e3d3c117223f" url: "https://pub.dev" source: hosted - version: "1.0.7" + version: "1.0.8" quick_actions_android: dependency: transitive description: name: quick_actions_android - sha256: "54a581491b90ff2e1be94af84a40c05e806e232184bb32afa2df57b07c4d6882" + sha256: "6932eeb970c6f7aed60708faf0ecea7068af84ee642c3bf308a0629abe90399b" url: "https://pub.dev" source: hosted - version: "1.0.15" + version: "1.0.16" quick_actions_ios: dependency: transitive description: @@ -1341,26 +1348,26 @@ packages: dependency: transitive description: name: sqflite - sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d + sha256: ff5a2436ef8ebdfda748fbfe957f9981524cb5ff11e7bafa8c42771840e8a788 url: "https://pub.dev" source: hosted - version: "2.3.3+1" + version: "2.3.3+2" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "4058172e418eb7e7f2058dcb7657d451a8fc264afa0dea4dbd0f304a57131611" + sha256: "2d8e607db72e9cb7748c9c6e739e2c9618320a5517de693d5a24609c4671b1a4" url: "https://pub.dev" source: hosted - version: "2.5.4+3" + version: "2.5.4+4" sqflite_common_ffi: dependency: transitive description: name: sqflite_common_ffi - sha256: "4d6137c29e930d6e4a8ff373989dd9de7bac12e3bc87bce950f6e844e8ad3bb5" + sha256: a6057d4c87e9260ba1ec436ebac24760a110589b9c0a859e128842eb69a7ef04 url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.3+1" sqflite_common_ffi_web: dependency: transitive description: @@ -1413,10 +1420,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "51b08572b9f091f8c3eb4d9d4be253f196ff0075d5ec9b10a884026d5b55d7bc" + sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" url: "https://pub.dev" source: hosted - version: "3.3.0+2" + version: "3.3.0+3" talk_app: dependency: "direct main" description: @@ -1532,10 +1539,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" url_launcher_platform_interface: dependency: transitive description: @@ -1564,10 +1571,10 @@ packages: dependency: transitive description: name: uuid - sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77 + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.5.0" + version: "4.5.1" vector_graphics: dependency: "direct main" description: @@ -1644,10 +1651,10 @@ packages: dependency: transitive description: name: web - sha256: d43c1d6b787bf0afad444700ae7f4db8827f701bc61c255ac8d328c6f4d52062 + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.1.0" webview_flutter: dependency: transitive description: diff --git a/packages/neon_framework/example/pubspec.yaml b/packages/neon_framework/example/pubspec.yaml index 17cbda7ab64..d6ed145a5ae 100644 --- a/packages/neon_framework/example/pubspec.yaml +++ b/packages/neon_framework/example/pubspec.yaml @@ -8,6 +8,10 @@ environment: dependencies: built_collection: ^5.0.0 + cookbook_app: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/cookbook_app dashboard_app: git: url: https://github.com/nextcloud/neon diff --git a/packages/neon_framework/example/pubspec_overrides.yaml b/packages/neon_framework/example/pubspec_overrides.yaml index 3839f558d53..3e4aa070698 100644 --- a/packages/neon_framework/example/pubspec_overrides.yaml +++ b/packages/neon_framework/example/pubspec_overrides.yaml @@ -1,7 +1,9 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app +# melos_managed_dependency_overrides: account_repository,cookbook_app,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app dependency_overrides: account_repository: path: ../packages/account_repository + cookbook_app: + path: ../packages/cookbook_app cookie_store: path: ../../cookie_store dashboard_app: diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index bb49cb49ea3..72d6e02e39d 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -2,7 +2,7 @@ "@@locale": "en", "nextcloud": "Nextcloud", "nextcloudLogo": "Nextcloud logo", - "appImplementationName": "{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} talk{Talk} other{}}", + "appImplementationName": "{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} talk{Talk} cookbook{Cookbook} other{}}", "@appImplementationName": { "placeholders": { "app": {} diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index 0b6dafc8335..33198d7bc1f 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -106,7 +106,7 @@ abstract class NeonLocalizations { /// No description provided for @appImplementationName. /// /// In en, this message translates to: - /// **'{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} talk{Talk} other{}}'** + /// **'{app, select, nextcloud{Nextcloud} core{Server} dashboard{Dashboard} files{Files} news{News} notes{Notes} notifications{Notifications} talk{Talk} cookbook{Cookbook} other{}}'** String appImplementationName(String app); /// No description provided for @loginAgain. diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index d8dae1a560f..98b470160ea 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -27,6 +27,7 @@ class NeonLocalizationsEn extends NeonLocalizations { 'notes': 'Notes', 'notifications': 'Notifications', 'talk': 'Talk', + 'cookbook': 'Cookbook', 'other': '', }, ); diff --git a/packages/neon_framework/packages/cookbook_app/LICENSE b/packages/neon_framework/packages/cookbook_app/LICENSE new file mode 120000 index 00000000000..f0b83dad961 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/LICENSE @@ -0,0 +1 @@ +../../../../assets/AGPL-3.0.txt \ No newline at end of file diff --git a/packages/neon_framework/packages/cookbook_app/analysis_options.yaml b/packages/neon_framework/packages/cookbook_app/analysis_options.yaml new file mode 100644 index 00000000000..a7fed286dc9 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:neon_lints/flutter.yaml + +analyzer: + exclude: + - lib/l10n/cookbook_* + - '**/routes.g.dart' + +custom_lint: + rules: + - avoid_exports: false diff --git a/packages/neon_framework/packages/cookbook_app/assets/app.svg.vec b/packages/neon_framework/packages/cookbook_app/assets/app.svg.vec new file mode 100644 index 0000000000000000000000000000000000000000..47e84b15ad3958d013d53bf568ecf6624959bc2c GIT binary patch literal 1152 zcmZWnJ%|%Q6n?pAI8KlgTomq*6e7C*5Wzz(Hk<6ALQn$w3JX!RkrZ+$qF}RNbD(!# z6Nz$Q;6QYzu&~hLz{TH?LQ>p{ph&>VG}?%VAn|)|)*o=lac|Gy0L!Q*s&wVHbO(i zLxz%?;i-yY@3^68+StJf@W2gDI;5T=^NQhg7kKZ$+lKY`&?-Xbqp|U3aHNXy4Ev$P zHhz=g(@=Osk3!kUd@ocTYKNjrK+FNEl75UMUxu#SljsY|d6HiZ zxw!%Ni+YlcxZgb9!Y15r0c#WBNj*hH=B*CB?Yd6EPRDQTar^OpM94!>PA z_RdX1r!N@l%^TaDF}!vjYd~*Kk?2N-^0sF1eJ(context, CookbookLocalizations)!; + } + + static const LocalizationsDelegate delegate = _CookbookLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en') + ]; + +} + +class _CookbookLocalizationsDelegate extends LocalizationsDelegate { + const _CookbookLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupCookbookLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => ['en'].contains(locale.languageCode); + + @override + bool shouldReload(_CookbookLocalizationsDelegate old) => false; +} + +CookbookLocalizations lookupCookbookLocalizations(Locale locale) { + + + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': return CookbookLocalizationsEn(); + } + + throw FlutterError( + 'CookbookLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.' + ); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations_en.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations_en.dart new file mode 100644 index 00000000000..01b559fd76b --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations_en.dart @@ -0,0 +1,10 @@ +import 'cookbook_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class CookbookLocalizationsEn extends CookbookLocalizations { + CookbookLocalizationsEn([String locale = 'en']) : super(locale); + + +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart new file mode 100644 index 00000000000..3e200c9f59f --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart @@ -0,0 +1,13 @@ +import 'package:cookbook_app/l10n/cookbook_localizations.dart'; +import 'package:flutter/widgets.dart'; + +export 'package:cookbook_app/l10n/cookbook_localizations.dart'; + +/// Extension for easier l10 usage. +extension AppLocalizationsX on BuildContext { + /// Returns the localization currently active for this context. + /// + /// This is the same as manually getting it through + /// `CookbookLocalizations.of(this)`. + CookbookLocalizations get l10n => CookbookLocalizations.of(this); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart new file mode 100644 index 00000000000..9462962dbb7 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart @@ -0,0 +1,38 @@ +/// The Neon client for the Cookbook app. +/// +/// Add `CookbookApp()` to your runNeon command to execute this app. +library; + +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/neon/neon.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:neon_framework/models.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; + +/// Implementation of the server `cookbook` app. +final class CookbookApp extends AppImplementation { + /// Creates a new Cookbook app implementation instance. + CookbookApp(); + + @override + final String id = cookbook.appID; + + @override + final LocalizationsDelegate localizationsDelegate = CookbookLocalizations.delegate; + + @override + final List supportedLocales = CookbookLocalizations.supportedLocales; + + @override + late final CookbookOptions options = CookbookOptions(storage); + + @override + CookbookBloc buildBloc(Account account) => CookbookBloc(); + + @override + final Widget page = const Placeholder(); + + @override + final RouteBase route = $cookbookAppRoute; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/bloc.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/bloc.dart new file mode 100644 index 00000000000..fc4916087b1 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/bloc.dart @@ -0,0 +1,11 @@ +import 'package:logging/logging.dart'; +import 'package:neon_framework/blocs.dart'; + +/// Neon bloc for the cookbook client. +class CookbookBloc extends Bloc { + @override + void dispose() {} + + @override + final Logger log = Logger('CookbookBloc'); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart new file mode 100644 index 00000000000..3d570824aab --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart @@ -0,0 +1,3 @@ +export 'bloc.dart'; +export 'options.dart'; +export 'routes.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/options.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/options.dart new file mode 100644 index 00000000000..7d13efedf2a --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/options.dart @@ -0,0 +1,10 @@ +import 'package:neon_framework/settings.dart'; + +/// Settings options specific to the cookbook app. +class CookbookOptions extends AppImplementationOptions { + /// Creates a new cookbook options instance. + CookbookOptions(super.storage) { + super.categories = []; + super.options = []; + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart new file mode 100644 index 00000000000..0855b1fee0b --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; + +part 'routes.g.dart'; + +/// Route for the cookbook app. +@TypedGoRoute( + path: '$appsBaseRoutePrefix${cookbook.appID}', + name: cookbook.appID, +) +@immutable +class CookbookAppRoute extends NeonBaseAppRoute { + /// Creates a new cookbook app route. + const CookbookAppRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) => const Placeholder(); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.g.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.g.dart new file mode 100644 index 00000000000..f0b943fb633 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'routes.dart'; + +// ************************************************************************** +// GoRouterGenerator +// ************************************************************************** + +List get $appRoutes => [ + $cookbookAppRoute, + ]; + +RouteBase get $cookbookAppRoute => GoRouteData.$route( + path: '/apps/notifications', + name: 'notifications', + factory: $CookbookAppRouteExtension._fromState, + ); + +extension $CookbookAppRouteExtension on CookbookAppRoute { + static CookbookAppRoute _fromState(GoRouterState state) => const CookbookAppRoute(); + + String get location => GoRouteData.$location( + '/apps/notifications', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} diff --git a/packages/neon_framework/packages/cookbook_app/pubspec.yaml b/packages/neon_framework/packages/cookbook_app/pubspec.yaml new file mode 100644 index 00000000000..0ee6acd7944 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/pubspec.yaml @@ -0,0 +1,44 @@ +name: cookbook_app +version: 1.0.0 +publish_to: 'none' + +environment: + sdk: ^3.0.0 + flutter: ^3.22.0 + +dependencies: + built_collection: ^5.0.0 + equatable: ^2.0.0 + flutter: + sdk: flutter + flutter_bloc: ^8.0.0 + flutter_localizations: + sdk: flutter + go_router: ^14.0.0 + intl: ^0.19.0 + logging: ^1.0.0 + meta: ^1.0.0 + neon_framework: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework + nextcloud: ^6.1.0 + +dev_dependencies: + build_runner: ^2.4.11 + built_value_test: ^8.9.2 + custom_lint: ^0.6.7 + flutter_test: + sdk: flutter + go_router_builder: ^2.7.0 + mocktail: ^1.0.4 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + vector_graphics_compiler: ^1.1.11+1 + +flutter: + uses-material-design: true + assets: + - assets/ diff --git a/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml new file mode 100644 index 00000000000..0abe46d2bdd --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml @@ -0,0 +1,20 @@ +# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +dependency_overrides: + account_repository: + path: ../account_repository + cookie_store: + path: ../../../cookie_store + dynamite_runtime: + path: ../../../dynamite/packages/dynamite_runtime + interceptor_http_client: + path: ../../../interceptor_http_client + neon_framework: + path: ../.. + neon_http_client: + path: ../neon_http_client + neon_lints: + path: ../../../neon_lints + nextcloud: + path: ../../../nextcloud + sort_box: + path: ../sort_box diff --git a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml index d4b2d2ff99a..fbf277d4fc5 100644 --- a/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/neon_storage/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: neon_lints,cookie_store,cookie_store_conformance_tests,dynamite_runtime,nextcloud +# melos_managed_dependency_overrides: cookie_store,cookie_store_conformance_tests,dynamite_runtime,neon_lints,nextcloud dependency_overrides: cookie_store: path: ../../../cookie_store diff --git a/tool/generate-assets.sh b/tool/generate-assets.sh index 92018ba252c..336315673eb 100755 --- a/tool/generate-assets.sh +++ b/tool/generate-assets.sh @@ -85,6 +85,7 @@ done < <(find external/nextcloud-server/{core/img,apps/*/img} -name "*.svg" -not precompile_assets ) +copy_app_svg cookbook external/nextcloud-cookbook copy_app_svg dashboard external/nextcloud-server/apps/dashboard copy_app_svg files external/nextcloud-server/apps/files copy_app_svg news external/nextcloud-news From 9de4340d090f865222087d422aac981e8759c62d Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Sun, 14 Jul 2024 19:19:26 +0200 Subject: [PATCH 4/5] feat(cookbook_app): add recipe repository Signed-off-by: Nikolas Rimikis --- .../cookbook_recipe_repository/LICENSE | 1 + .../analysis_options.yaml | 1 + .../lib/recipe_repository.dart | 2 + .../lib/src/models/category.dart | 30 + .../lib/src/models/category.g.dart | 113 ++++ .../lib/src/models/models.dart | 4 + .../lib/src/models/recipe.dart | 99 ++++ .../lib/src/models/recipe.g.dart | 318 +++++++++++ .../lib/src/models/recipe_stub.dart | 38 ++ .../lib/src/models/recipe_stub.g.dart | 166 ++++++ .../lib/src/recipe_repository.dart | 252 ++++++++ .../lib/src/utils/iso8601_datetime.dart | 21 + .../lib/src/utils/iso8601_duration.dart | 87 +++ .../lib/src/utils/recipe_converter.dart | 71 +++ .../lib/src/utils/recipe_stub_converter.dart | 27 + .../lib/src/utils/utils.dart | 4 + .../cookbook_recipe_repository/pubspec.yaml | 24 + .../pubspec_overrides.yaml | 8 + .../test/models/category_test.dart | 85 +++ .../test/models/recipe_stub_test.dart | 107 ++++ .../test/models/recipe_test.dart | 177 ++++++ .../test/recipe_repository_test.dart | 536 ++++++++++++++++++ .../test/utils/iso8601_datetime_test.dart | 28 + .../test/utils/iso8601_duration_test.dart | 60 ++ .../test/utils/recipe_converter_test.dart | 72 +++ .../utils/recipe_stub_converter_test.dart | 41 ++ 26 files changed, 2372 insertions(+) create mode 120000 packages/neon_framework/packages/cookbook_recipe_repository/LICENSE create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/analysis_options.yaml create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/recipe_repository.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.g.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/models.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.g.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.g.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/recipe_repository.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_datetime.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_duration.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_converter.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_stub_converter.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/utils.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/pubspec.yaml create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/pubspec_overrides.yaml create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/models/category_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_stub_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/recipe_repository_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_datetime_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_duration_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_converter_test.dart create mode 100644 packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_stub_converter_test.dart diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/LICENSE b/packages/neon_framework/packages/cookbook_recipe_repository/LICENSE new file mode 120000 index 00000000000..8c3b87b40b3 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/LICENSE @@ -0,0 +1 @@ +../../../../../assets/AGPL-3.0.txt \ No newline at end of file diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/analysis_options.yaml b/packages/neon_framework/packages/cookbook_recipe_repository/analysis_options.yaml new file mode 100644 index 00000000000..c3fd4266afe --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:neon_lints/flutter.yaml diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/recipe_repository.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/recipe_repository.dart new file mode 100644 index 00000000000..8405f2b3a5d --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/recipe_repository.dart @@ -0,0 +1,2 @@ +export 'src/models/models.dart'; +export 'src/recipe_repository.dart'; diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.dart new file mode 100644 index 00000000000..23203fb6524 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.dart @@ -0,0 +1,30 @@ +import 'package:built_value/built_value.dart'; + +part 'category.g.dart'; + +/// The model of a Category. +/// +/// This class is closely related to the one from the cookbook api. +/// It contains further information like the thumbnail url. +abstract class Category implements Built { + /// Creates a new category. + factory Category([void Function(CategoryBuilder) updates]) = _$Category; + Category._(); + + /// The id of the main recipe. + /// + /// This recipe can be used to fetch the category cover image. + String get mainRecipeId; + + /// The name of the category. + String get name; + + /// The number of recipes in the category. + int get recipeCount; + + /// The name of the category containing all recipes. + static const String all = '_'; + + /// The name of the category containing uncategorized images. + static const String uncategorized = '*'; +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.g.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.g.dart new file mode 100644 index 00000000000..56e1a72f31a --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/category.g.dart @@ -0,0 +1,113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Category extends Category { + @override + final String mainRecipeId; + @override + final String name; + @override + final int recipeCount; + + factory _$Category([void Function(CategoryBuilder)? updates]) => (CategoryBuilder()..update(updates))._build(); + + _$Category._({required this.mainRecipeId, required this.name, required this.recipeCount}) : super._() { + BuiltValueNullFieldError.checkNotNull(mainRecipeId, r'Category', 'mainRecipeId'); + BuiltValueNullFieldError.checkNotNull(name, r'Category', 'name'); + BuiltValueNullFieldError.checkNotNull(recipeCount, r'Category', 'recipeCount'); + } + + @override + Category rebuild(void Function(CategoryBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + CategoryBuilder toBuilder() => CategoryBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Category && + mainRecipeId == other.mainRecipeId && + name == other.name && + recipeCount == other.recipeCount; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, mainRecipeId.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, recipeCount.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'Category') + ..add('mainRecipeId', mainRecipeId) + ..add('name', name) + ..add('recipeCount', recipeCount)) + .toString(); + } +} + +class CategoryBuilder implements Builder { + _$Category? _$v; + + String? _mainRecipeId; + String? get mainRecipeId => _$this._mainRecipeId; + set mainRecipeId(String? mainRecipeId) => _$this._mainRecipeId = mainRecipeId; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + int? _recipeCount; + int? get recipeCount => _$this._recipeCount; + set recipeCount(int? recipeCount) => _$this._recipeCount = recipeCount; + + CategoryBuilder(); + + CategoryBuilder get _$this { + final $v = _$v; + if ($v != null) { + _mainRecipeId = $v.mainRecipeId; + _name = $v.name; + _recipeCount = $v.recipeCount; + _$v = null; + } + return this; + } + + @override + void replace(Category other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$Category; + } + + @override + void update(void Function(CategoryBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Category build() => _build(); + + _$Category _build() { + final _$result = _$v ?? + _$Category._( + mainRecipeId: BuiltValueNullFieldError.checkNotNull(mainRecipeId, r'Category', 'mainRecipeId'), + name: BuiltValueNullFieldError.checkNotNull(name, r'Category', 'name'), + recipeCount: BuiltValueNullFieldError.checkNotNull(recipeCount, r'Category', 'recipeCount')); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/models.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/models.dart new file mode 100644 index 00000000000..e33ca7c1c03 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/models.dart @@ -0,0 +1,4 @@ +export 'package:nextcloud/cookbook.dart' show Nutrition, NutritionBuilder; +export 'category.dart'; +export 'recipe.dart'; +export 'recipe_stub.dart'; diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.dart new file mode 100644 index 00000000000..4ad820bbe71 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.dart @@ -0,0 +1,99 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:timezone/timezone.dart' as tz; + +part 'recipe.g.dart'; + +// TODO: only one image url is really needed. + +/// The model of a Recipe. +/// +/// This class is closely related to the one from the cookbook api. +/// It deserializes the time, duration and uri fields. +@BuiltValue(nestedBuilders: false) +abstract class Recipe implements Built { + /// Creates a new recipe. + factory Recipe([void Function(RecipeBuilder) updates]) = _$Recipe; + Recipe._(); + + /// The time required for cooking in ISO8601 format. + Duration? get cookTime; + + /// The date the recipe was created in the app. + tz.TZDateTime get dateCreated; + + /// The date the recipe was modified lastly in the app. + tz.TZDateTime? get dateModified; + + /// A description of the recipe or the empty string. + String get description; + + /// The index of the recipe. Note the representation as a string as the representation might change in the future. + String? get id; + + /// The URL of the original recipe. + Uri? get image; + + /// The URL of the placeholder of the recipe image. + Uri? get imagePlaceholderUrl; + + /// The URL of the recipe image. + Uri? get imageUrl; + + /// A comma-separated list of recipe keywords, can be empty string. + BuiltSet get keywords; + + /// The time required for preparation in ISO8601 format. + Duration? get prepTime; + + /// The name of the recipe. + String get name; + + /// Nutritional information about the recipe. + Nutrition get nutrition; + + /// The category of the recipe. + String get recipeCategory; + + /// A list of ingredients used in the recipe. + BuiltList get recipeIngredient; + + /// An ordered list with steps in making the recipe. + BuiltList get recipeInstructions; + + /// Number of servings in recipe. + int get recipeYield; + + /// The time required for the complete processing in ISO8601 format. + Duration? get totalTime; + + /// A list of objects used (but not consumed) when performing instructions or a direction. + BuiltList get tool; + + /// The URL the recipe was found at or the empty string. + Uri? get url; + + @BuiltValueHook(finalizeBuilder: true) + static void _defaults(RecipeBuilder b) { + b + ..recipeYield ??= 1 + ..recipeCategory ??= Category.uncategorized; + + if (b.id?.isEmpty ?? false) { + b.id = null; + } + + if (b.cookTime == Duration.zero) { + b.cookTime = null; + } + + if (b.totalTime == Duration.zero) { + b.totalTime = null; + } + + if (b.prepTime == Duration.zero) { + b.prepTime = null; + } + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.g.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.g.dart new file mode 100644 index 00000000000..4d5b3f97988 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe.g.dart @@ -0,0 +1,318 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipe.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$Recipe extends Recipe { + @override + final Duration? cookTime; + @override + final tz.TZDateTime dateCreated; + @override + final tz.TZDateTime? dateModified; + @override + final String description; + @override + final String? id; + @override + final Uri? image; + @override + final Uri? imagePlaceholderUrl; + @override + final Uri? imageUrl; + @override + final BuiltSet keywords; + @override + final Duration? prepTime; + @override + final String name; + @override + final Nutrition nutrition; + @override + final String recipeCategory; + @override + final BuiltList recipeIngredient; + @override + final BuiltList recipeInstructions; + @override + final int recipeYield; + @override + final Duration? totalTime; + @override + final BuiltList tool; + @override + final Uri? url; + + factory _$Recipe([void Function(RecipeBuilder)? updates]) => (RecipeBuilder()..update(updates))._build(); + + _$Recipe._( + {this.cookTime, + required this.dateCreated, + this.dateModified, + required this.description, + this.id, + this.image, + this.imagePlaceholderUrl, + this.imageUrl, + required this.keywords, + this.prepTime, + required this.name, + required this.nutrition, + required this.recipeCategory, + required this.recipeIngredient, + required this.recipeInstructions, + required this.recipeYield, + this.totalTime, + required this.tool, + this.url}) + : super._() { + BuiltValueNullFieldError.checkNotNull(dateCreated, r'Recipe', 'dateCreated'); + BuiltValueNullFieldError.checkNotNull(description, r'Recipe', 'description'); + BuiltValueNullFieldError.checkNotNull(keywords, r'Recipe', 'keywords'); + BuiltValueNullFieldError.checkNotNull(name, r'Recipe', 'name'); + BuiltValueNullFieldError.checkNotNull(nutrition, r'Recipe', 'nutrition'); + BuiltValueNullFieldError.checkNotNull(recipeCategory, r'Recipe', 'recipeCategory'); + BuiltValueNullFieldError.checkNotNull(recipeIngredient, r'Recipe', 'recipeIngredient'); + BuiltValueNullFieldError.checkNotNull(recipeInstructions, r'Recipe', 'recipeInstructions'); + BuiltValueNullFieldError.checkNotNull(recipeYield, r'Recipe', 'recipeYield'); + BuiltValueNullFieldError.checkNotNull(tool, r'Recipe', 'tool'); + } + + @override + Recipe rebuild(void Function(RecipeBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + RecipeBuilder toBuilder() => RecipeBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is Recipe && + cookTime == other.cookTime && + dateCreated == other.dateCreated && + dateModified == other.dateModified && + description == other.description && + id == other.id && + image == other.image && + imagePlaceholderUrl == other.imagePlaceholderUrl && + imageUrl == other.imageUrl && + keywords == other.keywords && + prepTime == other.prepTime && + name == other.name && + nutrition == other.nutrition && + recipeCategory == other.recipeCategory && + recipeIngredient == other.recipeIngredient && + recipeInstructions == other.recipeInstructions && + recipeYield == other.recipeYield && + totalTime == other.totalTime && + tool == other.tool && + url == other.url; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, cookTime.hashCode); + _$hash = $jc(_$hash, dateCreated.hashCode); + _$hash = $jc(_$hash, dateModified.hashCode); + _$hash = $jc(_$hash, description.hashCode); + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, image.hashCode); + _$hash = $jc(_$hash, imagePlaceholderUrl.hashCode); + _$hash = $jc(_$hash, imageUrl.hashCode); + _$hash = $jc(_$hash, keywords.hashCode); + _$hash = $jc(_$hash, prepTime.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jc(_$hash, nutrition.hashCode); + _$hash = $jc(_$hash, recipeCategory.hashCode); + _$hash = $jc(_$hash, recipeIngredient.hashCode); + _$hash = $jc(_$hash, recipeInstructions.hashCode); + _$hash = $jc(_$hash, recipeYield.hashCode); + _$hash = $jc(_$hash, totalTime.hashCode); + _$hash = $jc(_$hash, tool.hashCode); + _$hash = $jc(_$hash, url.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'Recipe') + ..add('cookTime', cookTime) + ..add('dateCreated', dateCreated) + ..add('dateModified', dateModified) + ..add('description', description) + ..add('id', id) + ..add('image', image) + ..add('imagePlaceholderUrl', imagePlaceholderUrl) + ..add('imageUrl', imageUrl) + ..add('keywords', keywords) + ..add('prepTime', prepTime) + ..add('name', name) + ..add('nutrition', nutrition) + ..add('recipeCategory', recipeCategory) + ..add('recipeIngredient', recipeIngredient) + ..add('recipeInstructions', recipeInstructions) + ..add('recipeYield', recipeYield) + ..add('totalTime', totalTime) + ..add('tool', tool) + ..add('url', url)) + .toString(); + } +} + +class RecipeBuilder implements Builder { + _$Recipe? _$v; + + Duration? _cookTime; + Duration? get cookTime => _$this._cookTime; + set cookTime(Duration? cookTime) => _$this._cookTime = cookTime; + + tz.TZDateTime? _dateCreated; + tz.TZDateTime? get dateCreated => _$this._dateCreated; + set dateCreated(tz.TZDateTime? dateCreated) => _$this._dateCreated = dateCreated; + + tz.TZDateTime? _dateModified; + tz.TZDateTime? get dateModified => _$this._dateModified; + set dateModified(tz.TZDateTime? dateModified) => _$this._dateModified = dateModified; + + String? _description; + String? get description => _$this._description; + set description(String? description) => _$this._description = description; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + Uri? _image; + Uri? get image => _$this._image; + set image(Uri? image) => _$this._image = image; + + Uri? _imagePlaceholderUrl; + Uri? get imagePlaceholderUrl => _$this._imagePlaceholderUrl; + set imagePlaceholderUrl(Uri? imagePlaceholderUrl) => _$this._imagePlaceholderUrl = imagePlaceholderUrl; + + Uri? _imageUrl; + Uri? get imageUrl => _$this._imageUrl; + set imageUrl(Uri? imageUrl) => _$this._imageUrl = imageUrl; + + BuiltSet? _keywords; + BuiltSet? get keywords => _$this._keywords; + set keywords(BuiltSet? keywords) => _$this._keywords = keywords; + + Duration? _prepTime; + Duration? get prepTime => _$this._prepTime; + set prepTime(Duration? prepTime) => _$this._prepTime = prepTime; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + Nutrition? _nutrition; + Nutrition? get nutrition => _$this._nutrition; + set nutrition(Nutrition? nutrition) => _$this._nutrition = nutrition; + + String? _recipeCategory; + String? get recipeCategory => _$this._recipeCategory; + set recipeCategory(String? recipeCategory) => _$this._recipeCategory = recipeCategory; + + BuiltList? _recipeIngredient; + BuiltList? get recipeIngredient => _$this._recipeIngredient; + set recipeIngredient(BuiltList? recipeIngredient) => _$this._recipeIngredient = recipeIngredient; + + BuiltList? _recipeInstructions; + BuiltList? get recipeInstructions => _$this._recipeInstructions; + set recipeInstructions(BuiltList? recipeInstructions) => _$this._recipeInstructions = recipeInstructions; + + int? _recipeYield; + int? get recipeYield => _$this._recipeYield; + set recipeYield(int? recipeYield) => _$this._recipeYield = recipeYield; + + Duration? _totalTime; + Duration? get totalTime => _$this._totalTime; + set totalTime(Duration? totalTime) => _$this._totalTime = totalTime; + + BuiltList? _tool; + BuiltList? get tool => _$this._tool; + set tool(BuiltList? tool) => _$this._tool = tool; + + Uri? _url; + Uri? get url => _$this._url; + set url(Uri? url) => _$this._url = url; + + RecipeBuilder(); + + RecipeBuilder get _$this { + final $v = _$v; + if ($v != null) { + _cookTime = $v.cookTime; + _dateCreated = $v.dateCreated; + _dateModified = $v.dateModified; + _description = $v.description; + _id = $v.id; + _image = $v.image; + _imagePlaceholderUrl = $v.imagePlaceholderUrl; + _imageUrl = $v.imageUrl; + _keywords = $v.keywords; + _prepTime = $v.prepTime; + _name = $v.name; + _nutrition = $v.nutrition; + _recipeCategory = $v.recipeCategory; + _recipeIngredient = $v.recipeIngredient; + _recipeInstructions = $v.recipeInstructions; + _recipeYield = $v.recipeYield; + _totalTime = $v.totalTime; + _tool = $v.tool; + _url = $v.url; + _$v = null; + } + return this; + } + + @override + void replace(Recipe other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$Recipe; + } + + @override + void update(void Function(RecipeBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + Recipe build() => _build(); + + _$Recipe _build() { + Recipe._defaults(this); + final _$result = _$v ?? + _$Recipe._( + cookTime: cookTime, + dateCreated: BuiltValueNullFieldError.checkNotNull(dateCreated, r'Recipe', 'dateCreated'), + dateModified: dateModified, + description: BuiltValueNullFieldError.checkNotNull(description, r'Recipe', 'description'), + id: id, + image: image, + imagePlaceholderUrl: imagePlaceholderUrl, + imageUrl: imageUrl, + keywords: BuiltValueNullFieldError.checkNotNull(keywords, r'Recipe', 'keywords'), + prepTime: prepTime, + name: BuiltValueNullFieldError.checkNotNull(name, r'Recipe', 'name'), + nutrition: BuiltValueNullFieldError.checkNotNull(nutrition, r'Recipe', 'nutrition'), + recipeCategory: BuiltValueNullFieldError.checkNotNull(recipeCategory, r'Recipe', 'recipeCategory'), + recipeIngredient: BuiltValueNullFieldError.checkNotNull(recipeIngredient, r'Recipe', 'recipeIngredient'), + recipeInstructions: + BuiltValueNullFieldError.checkNotNull(recipeInstructions, r'Recipe', 'recipeInstructions'), + recipeYield: BuiltValueNullFieldError.checkNotNull(recipeYield, r'Recipe', 'recipeYield'), + totalTime: totalTime, + tool: BuiltValueNullFieldError.checkNotNull(tool, r'Recipe', 'tool'), + url: url); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.dart new file mode 100644 index 00000000000..f47121f8d10 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.dart @@ -0,0 +1,38 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value/built_value.dart'; + +import 'package:timezone/timezone.dart' as tz; + +part 'recipe_stub.g.dart'; + +/// The model of a RecipeStub. +/// +/// This class is closely related to the one from the cookbook api. +/// It deserializes the time and uri fields. +@BuiltValue(nestedBuilders: false) +abstract class RecipeStub implements Built { + /// Creates a new recipe stub. + factory RecipeStub([void Function(RecipeStubBuilder) updates]) = _$RecipeStub; + RecipeStub._(); + + /// The date the recipe was created in the app. + tz.TZDateTime get dateCreated; + + /// The date the recipe was modified lastly in the app. + tz.TZDateTime? get dateModified; + + /// The identifier of the recipe. + String get id; + + /// The URL of the placeholder of the recipe image. + Uri? get imagePlaceholderUrl; + + /// The URL of the recipe image. + Uri? get imageUrl; + + /// A comma-separated list of recipe keywords, can be empty string. + BuiltSet get keywords; + + /// The name of the recipe. + String get name; +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.g.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.g.dart new file mode 100644 index 00000000000..e31710c08dc --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/models/recipe_stub.g.dart @@ -0,0 +1,166 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recipe_stub.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +class _$RecipeStub extends RecipeStub { + @override + final tz.TZDateTime dateCreated; + @override + final tz.TZDateTime? dateModified; + @override + final String id; + @override + final Uri? imagePlaceholderUrl; + @override + final Uri? imageUrl; + @override + final BuiltSet keywords; + @override + final String name; + + factory _$RecipeStub([void Function(RecipeStubBuilder)? updates]) => (RecipeStubBuilder()..update(updates))._build(); + + _$RecipeStub._( + {required this.dateCreated, + this.dateModified, + required this.id, + this.imagePlaceholderUrl, + this.imageUrl, + required this.keywords, + required this.name}) + : super._() { + BuiltValueNullFieldError.checkNotNull(dateCreated, r'RecipeStub', 'dateCreated'); + BuiltValueNullFieldError.checkNotNull(id, r'RecipeStub', 'id'); + BuiltValueNullFieldError.checkNotNull(keywords, r'RecipeStub', 'keywords'); + BuiltValueNullFieldError.checkNotNull(name, r'RecipeStub', 'name'); + } + + @override + RecipeStub rebuild(void Function(RecipeStubBuilder) updates) => (toBuilder()..update(updates)).build(); + + @override + RecipeStubBuilder toBuilder() => RecipeStubBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is RecipeStub && + dateCreated == other.dateCreated && + dateModified == other.dateModified && + id == other.id && + imagePlaceholderUrl == other.imagePlaceholderUrl && + imageUrl == other.imageUrl && + keywords == other.keywords && + name == other.name; + } + + @override + int get hashCode { + var _$hash = 0; + _$hash = $jc(_$hash, dateCreated.hashCode); + _$hash = $jc(_$hash, dateModified.hashCode); + _$hash = $jc(_$hash, id.hashCode); + _$hash = $jc(_$hash, imagePlaceholderUrl.hashCode); + _$hash = $jc(_$hash, imageUrl.hashCode); + _$hash = $jc(_$hash, keywords.hashCode); + _$hash = $jc(_$hash, name.hashCode); + _$hash = $jf(_$hash); + return _$hash; + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'RecipeStub') + ..add('dateCreated', dateCreated) + ..add('dateModified', dateModified) + ..add('id', id) + ..add('imagePlaceholderUrl', imagePlaceholderUrl) + ..add('imageUrl', imageUrl) + ..add('keywords', keywords) + ..add('name', name)) + .toString(); + } +} + +class RecipeStubBuilder implements Builder { + _$RecipeStub? _$v; + + tz.TZDateTime? _dateCreated; + tz.TZDateTime? get dateCreated => _$this._dateCreated; + set dateCreated(tz.TZDateTime? dateCreated) => _$this._dateCreated = dateCreated; + + tz.TZDateTime? _dateModified; + tz.TZDateTime? get dateModified => _$this._dateModified; + set dateModified(tz.TZDateTime? dateModified) => _$this._dateModified = dateModified; + + String? _id; + String? get id => _$this._id; + set id(String? id) => _$this._id = id; + + Uri? _imagePlaceholderUrl; + Uri? get imagePlaceholderUrl => _$this._imagePlaceholderUrl; + set imagePlaceholderUrl(Uri? imagePlaceholderUrl) => _$this._imagePlaceholderUrl = imagePlaceholderUrl; + + Uri? _imageUrl; + Uri? get imageUrl => _$this._imageUrl; + set imageUrl(Uri? imageUrl) => _$this._imageUrl = imageUrl; + + BuiltSet? _keywords; + BuiltSet? get keywords => _$this._keywords; + set keywords(BuiltSet? keywords) => _$this._keywords = keywords; + + String? _name; + String? get name => _$this._name; + set name(String? name) => _$this._name = name; + + RecipeStubBuilder(); + + RecipeStubBuilder get _$this { + final $v = _$v; + if ($v != null) { + _dateCreated = $v.dateCreated; + _dateModified = $v.dateModified; + _id = $v.id; + _imagePlaceholderUrl = $v.imagePlaceholderUrl; + _imageUrl = $v.imageUrl; + _keywords = $v.keywords; + _name = $v.name; + _$v = null; + } + return this; + } + + @override + void replace(RecipeStub other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$RecipeStub; + } + + @override + void update(void Function(RecipeStubBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + RecipeStub build() => _build(); + + _$RecipeStub _build() { + final _$result = _$v ?? + _$RecipeStub._( + dateCreated: BuiltValueNullFieldError.checkNotNull(dateCreated, r'RecipeStub', 'dateCreated'), + dateModified: dateModified, + id: BuiltValueNullFieldError.checkNotNull(id, r'RecipeStub', 'id'), + imagePlaceholderUrl: imagePlaceholderUrl, + imageUrl: imageUrl, + keywords: BuiltValueNullFieldError.checkNotNull(keywords, r'RecipeStub', 'keywords'), + name: BuiltValueNullFieldError.checkNotNull(name, r'RecipeStub', 'name')); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: deprecated_member_use_from_same_package,type=lint diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/recipe_repository.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/recipe_repository.dart new file mode 100644 index 00000000000..c2c8806fb66 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/recipe_repository.dart @@ -0,0 +1,252 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/src/models/models.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:equatable/equatable.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; + +/// {@template recipe_failure} +/// A base failure for the recipe repository failures. +/// {@endtemplate} +abstract class RecipeFailure with EquatableMixin implements Exception { + /// {@macro recipe_failure} + const RecipeFailure(this.error); + + /// The error which was caught. + final Object error; + + @override + List get props => [error]; +} + +/// {@template create_recipe_failure} +/// Thrown when creating a recipe fails. +/// {@endtemplate} +class CreateRecipeFailure extends RecipeFailure { + /// {@macro create_recipe_failure} + const CreateRecipeFailure(super.error); +} + +/// {@template read_recipe_failure} +/// Thrown when fetching a recipe fails. +/// {@endtemplate} +class ReadRecipeFailure extends RecipeFailure { + /// {@macro read_recipe_failure} + const ReadRecipeFailure(super.error); +} + +/// {@template update_recipe_failure} +/// Thrown when updating a recipe fails. +/// {@endtemplate} +class UpdateRecipeFailure extends RecipeFailure { + /// {@macro update_recipe_failure} + const UpdateRecipeFailure(super.error); +} + +/// {@template delete_recipe_failure} +/// Thrown when deleting a recipe fails. +/// {@endtemplate} +class DeleteRecipeFailure extends RecipeFailure { + /// {@macro delete_recipe_failure} + const DeleteRecipeFailure(super.error); +} + +/// {@template import_recipe_failure} +/// Thrown when importing a recipe fails. +/// {@endtemplate} +class ImportRecipeFailure extends RecipeFailure { + /// {@macro import_recipe_failure} + const ImportRecipeFailure(super.error); +} + +/// {@template read_categories_failure} +/// Thrown when reading the categories fails. +/// {@endtemplate} +class ReadCategoriesFailure extends RecipeFailure { + /// {@macro read_categories_failure} + const ReadCategoriesFailure(super.error); +} + +/// {@template read_category_failure} +/// Thrown when reading a single category fails. +/// {@endtemplate} +class ReadCategoryFailure extends RecipeFailure { + /// {@macro read_category_failure} + const ReadCategoryFailure(super.error); +} + +/// {@template recipe_repository} +/// A repository that manages recipe data. +/// {@endtemplate} +final class RecipeRepository { + /// {@macro recipe_repository} + RecipeRepository({ + required cookbook.$CategoriesClient categoriesProvider, + required cookbook.$RecipesClient recipesProvider, + }) : _recipes = recipesProvider, + _categories = categoriesProvider; + + /// Access to the categories of the recipes. + final cookbook.$CategoriesClient _categories; + + /// Everything related to recipes and their usage. + final cookbook.$RecipesClient _recipes; + + /// Creates a new recipe. + /// + /// The `id` value must be `null`. + Future createRecipe(Recipe recipe) async { + final id = recipe.id; + if (id != null) { + throw ArgumentError.value(recipe, 'recipe', 'must have a null id'); + } + + try { + const converter = RecipeToApiConverter(); + + await _recipes.newRecipe($body: converter.convert(recipe)); + } catch (error) { + throw CreateRecipeFailure(error); + } + } + + /// Reads the recipe with the given [id]. + Future readRecipe(String id) async { + try { + const converter = ApiToRecipeConverter(); + + final response = await _recipes.recipeDetails(id: id); + return converter.convert(response.body); + } catch (error) { + throw ReadRecipeFailure(error); + } + } + + /// Updates the given [recipe]. + /// + /// The `id` value must not be null. + Future updateRecipe(Recipe recipe) async { + final id = recipe.id; + if (id == null) { + throw ArgumentError.value(recipe, 'recipe', 'must have a non null id'); + } + + try { + const converter = RecipeToApiConverter(); + + await _recipes.updateRecipe(id: id, $body: converter.convert(recipe)); + } catch (error) { + throw UpdateRecipeFailure(error); + } + } + + /// Deletes the given [recipe]. + /// + /// The `id` value must not be null. + Future deleteRecipe(Recipe recipe) async { + final id = recipe.id; + if (id == null) { + throw ArgumentError.value(recipe, 'recipe', 'must have a non null id'); + } + + try { + await _recipes.deleteRecipe(id: id); + } catch (error) { + throw DeleteRecipeFailure(error); + } + } + + /// Imports the recipe at the given [url]. + Future importRecipe(Uri url) async { + try { + final requestUrl = cookbook.Url((b) { + b.url = url.toString(); + }); + + await _recipes.$import($body: requestUrl); + } catch (error) { + throw ImportRecipeFailure(error); + } + } + + /// Reads all available categories. + Future> readCategories() async { + try { + final allRecipes = await _readAllStubs(); + if (allRecipes.isEmpty) { + return BuiltList(); + } + + final categories = ListBuilder(); + final allCategory = Category((b) { + b + ..name = Category.all + ..recipeCount = allRecipes.length + ..mainRecipeId = allRecipes.first.id; + }); + + categories.add(allCategory); + + final response = await _categories.listCategories(); + + final categoriesWithRecipe = response.body.where((r) => r.recipeCount > 0); + final mainRecipes = await Future.wait( + categoriesWithRecipe.map(_fetchCategoryMainRecipe), + ); + + for (final element in categoriesWithRecipe.indexed) { + final category = Category((b) { + b + ..name = element.$2.name + ..recipeCount = element.$2.recipeCount + ..mainRecipeId = mainRecipes[element.$1].id; + }); + + categories.add(category); + } + + return categories.build(); + } catch (error) { + throw ReadCategoriesFailure(error); + } + } + + /// Reads the category identified by the given [name]. + Future> readCategory({required String name}) async { + const converter = RecipeStubConverter(); + + try { + switch (name) { + case Category.all: + return await _readAllStubs(); + + case Category.uncategorized: + final response = await _categories.recipesInCategory(category: '_'); + return response.body.map(converter.convert).toBuiltList(); + + default: + final response = await _categories.recipesInCategory(category: name); + return response.body.map(converter.convert).toBuiltList(); + } + } catch (error) { + throw ReadCategoryFailure(error); + } + } + + /// Primitive function to get the cover recipe for a [category]. + /// + /// The cover recipe must not contain a custom image. + Future _fetchCategoryMainRecipe(cookbook.Category category) async { + assert(category.recipeCount > 0, 'category must contain at least one recipe.'); + + final categoryRecipes = await readCategory(name: category.name); + return categoryRecipes.first; + } + + /// Reads all recipe stubs. + Future> _readAllStubs() async { + const converter = RecipeStubConverter(); + + final response = await _recipes.listRecipes(); + return response.body.map(converter.convert).toBuiltList(); + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_datetime.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_datetime.dart new file mode 100644 index 00000000000..131329ac5ef --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_datetime.dart @@ -0,0 +1,21 @@ +import 'package:timezone/timezone.dart' as tz; + +/// Extension to convert from Iso8601 DateTime. +extension Iso8601DateTime on tz.TZDateTime { + /// Constructs a new [tz.TZDateTime] instance based on [formattedString]. + /// + /// Works like [parse] except that this function returns `null` + /// where [parse] would throw a [FormatException]. + static tz.TZDateTime? tryParse(tz.Location location, String? formattedString) { + if (formattedString == null) { + return null; + } + + final dateTime = DateTime.tryParse(formattedString); + + if (dateTime == null) { + return null; + } + return tz.TZDateTime.from(dateTime, location); + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_duration.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_duration.dart new file mode 100644 index 00000000000..f64432ed991 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/iso8601_duration.dart @@ -0,0 +1,87 @@ +// Copyright (c) 2019, Google Inc. Please see the AUTHORS file for details. +// All rights reserved. Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/// Extension to convert from and to Iso8601 duration. +/// +/// Note that this serializer is not 100% compatible with the ISO8601 format +/// due to limitations of the [Duration] class, but is designed to produce and +/// consume reasonable strings that match the standard. +/// +/// This is copied from package:built_value. +extension Iso8601Duration on Duration { + // The unit tokens. + static const _durationToken = 'P'; + static const _dayToken = 'D'; + static const _timeToken = 'T'; + static const _hourToken = 'H'; + static const _minuteToken = 'M'; + static const _secondToken = 'S'; + + // The parse format for ISO8601 durations. + static final _parseFormat = RegExp( + r'^P(?!$)(0D|[1-9][0-9]*D)?' + r'(?:T(?!$)(0H|[1-9][0-9]*H)?(0M|[1-9][0-9]*M)?(0S|[1-9][0-9]*S)?)?$', + ); + + /// Tries to deserialize a given ISO8601 [input] String. + static Duration? tryParse(String? input) { + if (input == null) { + return null; + } + + final match = _parseFormat.firstMatch(input); + if (match == null) { + return null; + } + // Iterate through the capture groups to build the unit mappings. + final unitMappings = {}; + + // Start iterating at 1, because match[0] is the full match. + for (var i = 1; i <= match.groupCount; i++) { + final group = match[i]; + if (group == null) { + continue; + } + + // Get all but last character in group. + // The RegExp ensures this must be an int. + final value = int.parse(group.substring(0, group.length - 1)); + // Get last character. + final unit = group.substring(group.length - 1); + unitMappings[unit] = value; + } + + return Duration( + days: unitMappings[_dayToken] ?? 0, + hours: unitMappings[_hourToken] ?? 0, + minutes: unitMappings[_minuteToken] ?? 0, + seconds: unitMappings[_secondToken] ?? 0, + ); + } + + /// Returns an ISO-8601 duration. + String toIso8601String() { + if (this == Duration.zero) { + return 'PT0S'; + } + final days = inDays; + final hours = (this - Duration(days: days)).inHours; + final minutes = (this - Duration(days: days, hours: hours)).inMinutes; + final seconds = (this - Duration(days: days, hours: hours, minutes: minutes)).inSeconds; + final remainder = this - Duration(days: days, hours: hours, minutes: minutes, seconds: seconds); + + if (remainder != Duration.zero) { + throw ArgumentError.value(this, 'duration', 'Contains sub-second data which cannot be serialized.'); + } + final buffer = StringBuffer(_durationToken)..write(days == 0 ? '' : '$days$_dayToken'); + if (!(hours == 0 && minutes == 0 && seconds == 0)) { + buffer + ..write(_timeToken) + ..write(hours == 0 ? '' : '$hours$_hourToken') + ..write(minutes == 0 ? '' : '$minutes$_minuteToken') + ..write(seconds == 0 ? '' : '$seconds$_secondToken'); + } + return buffer.toString(); + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_converter.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_converter.dart new file mode 100644 index 00000000000..621887bd263 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_converter.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/src/models/models.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:timezone/timezone.dart' as tz; + +/// Converter for converting a recipe into the representation used by the nextcloud package. +final class RecipeToApiConverter extends Converter { + /// Creates a new recipe converter. + const RecipeToApiConverter(); + + @override + cookbook.Recipe convert(Recipe input) { + return cookbook.Recipe((b) { + b + ..cookTime = input.cookTime?.toIso8601String() + ..dateCreated = input.dateCreated.toIso8601String() + ..dateModified = input.dateModified?.toIso8601String() + ..description = input.description + ..id = input.id + ..image = input.image?.toString() + ..imagePlaceholderUrl = input.imagePlaceholderUrl?.toString() + ..imageUrl = input.imageUrl?.toString() + ..keywords = input.keywords.join(',') + ..name = input.name + ..nutrition.replace(input.nutrition) + ..prepTime = input.prepTime?.toIso8601String() + ..recipeCategory = input.recipeCategory + ..recipeIngredient.replace(input.recipeIngredient) + ..recipeInstructions.replace(input.recipeInstructions) + ..recipeYield = input.recipeYield + ..tool.replace(input.tool) + ..totalTime = input.totalTime?.toIso8601String() + ..url = input.url?.toString(); + }); + } +} + +/// Converter for converting a nextcloud package recipe into the representation used by the repository. +final class ApiToRecipeConverter extends Converter { + /// Creates a new recipe converter. + const ApiToRecipeConverter(); + + @override + Recipe convert(cookbook.Recipe input) { + return Recipe((b) { + b + ..cookTime = Iso8601Duration.tryParse(input.cookTime) + ..dateCreated = Iso8601DateTime.tryParse(tz.UTC, input.dateCreated) + ..dateModified = Iso8601DateTime.tryParse(tz.UTC, input.dateModified) + ..description = input.description + ..id = input.id + ..image = Uri.tryParse(input.image) + ..imagePlaceholderUrl = Uri.tryParse(input.imagePlaceholderUrl) + ..imageUrl = Uri.tryParse(input.imageUrl) + ..keywords = input.keywords.split(',').toBuiltSet() + ..name = input.name + ..nutrition = input.nutrition + ..prepTime = Iso8601Duration.tryParse(input.prepTime) + ..recipeCategory = input.recipeCategory + ..recipeIngredient = input.recipeIngredient + ..recipeInstructions = input.recipeInstructions + ..recipeYield = input.recipeYield + ..tool = input.tool + ..totalTime = Iso8601Duration.tryParse(input.totalTime) + ..url = Uri.tryParse(input.url); + }); + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_stub_converter.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_stub_converter.dart new file mode 100644 index 00000000000..15d3da43c34 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/recipe_stub_converter.dart @@ -0,0 +1,27 @@ +import 'dart:convert'; + +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/src/models/models.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:timezone/timezone.dart' as tz; + +/// Converter for converting a nextcloud package recipe into the representation used by the repository. +final class RecipeStubConverter extends Converter { + /// Creates a new recipe converter. + const RecipeStubConverter(); + + @override + RecipeStub convert(cookbook.RecipeStub input) { + return RecipeStub((b) { + b + ..dateCreated = Iso8601DateTime.tryParse(tz.UTC, input.dateCreated) + ..dateModified = Iso8601DateTime.tryParse(tz.UTC, input.dateModified) + ..id = input.id + ..imagePlaceholderUrl = Uri.tryParse(input.imagePlaceholderUrl) + ..imageUrl = Uri.tryParse(input.imageUrl) + ..keywords = input.keywords.split(',').toBuiltSet() + ..name = input.name; + }); + } +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/utils.dart b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/utils.dart new file mode 100644 index 00000000000..e6bd56bdd13 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/lib/src/utils/utils.dart @@ -0,0 +1,4 @@ +export 'iso8601_datetime.dart'; +export 'iso8601_duration.dart'; +export 'recipe_converter.dart'; +export 'recipe_stub_converter.dart'; diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/pubspec.yaml b/packages/neon_framework/packages/cookbook_recipe_repository/pubspec.yaml new file mode 100644 index 00000000000..0ce4d97f1b5 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/pubspec.yaml @@ -0,0 +1,24 @@ +name: cookbook_recipe_repository +version: 1.0.0 +publish_to: 'none' + +environment: + sdk: ^3.0.0 + +dependencies: + built_collection: ^5.0.0 + built_value: ^8.9.2 + equatable: ^2.0.0 + nextcloud: ^6.1.0 + timezone: ^0.9.4 + +dev_dependencies: + build_runner: ^2.4.11 + built_value_generator: ^8.9.2 + built_value_test: ^8.9.2 + mocktail: ^1.0.4 + neon_lints: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_lints + test: ^1.25.0 diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/pubspec_overrides.yaml b/packages/neon_framework/packages/cookbook_recipe_repository/pubspec_overrides.yaml new file mode 100644 index 00000000000..af63411d120 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/pubspec_overrides.yaml @@ -0,0 +1,8 @@ +# melos_managed_dependency_overrides: dynamite_runtime,neon_lints,nextcloud +dependency_overrides: + dynamite_runtime: + path: ../../../dynamite/packages/dynamite_runtime + neon_lints: + path: ../../../neon_lints + nextcloud: + path: ../../../nextcloud diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/models/category_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/category_test.dart new file mode 100644 index 00000000000..481ca186b06 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/category_test.dart @@ -0,0 +1,85 @@ +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('Category', () { + Category createCategory({ + String mainRecipeId = 'id', + String name = 'name', + int recipeCount = 0, + }) { + return Category((b) { + b + ..mainRecipeId = mainRecipeId + ..name = name + ..recipeCount = recipeCount; + }); + } + + group('constructor', () { + test('works correctly', () { + expect( + createCategory, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createCategory(), + equalsBuilt(createCategory()), + ); + + expect( + createCategory().hashCode, + equals(createCategory().hashCode), + ); + + expect( + createCategory(), + isNot(equals(createCategory(recipeCount: 1))), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createCategory().rebuild((_) {}), + equalsBuilt(createCategory()), + ); + }); + + test('replaces every attribute', () { + expect( + createCategory().rebuild((b) { + b + ..mainRecipeId = 'new id' + ..name = 'new name' + ..recipeCount = 1; + }), + equalsBuilt( + createCategory( + mainRecipeId: 'new id', + name: 'new name', + recipeCount: 1, + ), + ), + ); + }); + }); + + test('to string', () { + expect( + createCategory().toString(), + equals(''' +Category { + mainRecipeId=id, + name=name, + recipeCount=0, +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_stub_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_stub_test.dart new file mode 100644 index 00000000000..7e2412bd2f9 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_stub_test.dart @@ -0,0 +1,107 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + group('RecipeStub', () { + RecipeStub createRecipeStub({ + tz.TZDateTime? dateCreated, + tz.TZDateTime? dateModified, + String id = 'id', + Uri? imagePlaceholderUrl, + Uri? imageUrl, + Set keywords = const {}, + String name = 'name', + }) { + return RecipeStub((b) { + b + ..dateCreated = dateCreated ?? tz.TZDateTime(tz.UTC, 2024) + ..dateModified = dateModified ?? tz.TZDateTime(tz.UTC, 2024) + ..id = id + ..imagePlaceholderUrl = imagePlaceholderUrl ?? Uri.https('imagePlaceholderUrl') + ..imageUrl = imageUrl ?? Uri.https('imageUrl') + ..keywords = BuiltSet(keywords) + ..name = name; + }); + } + + group('constructor', () { + test('works correctly', () { + expect( + createRecipeStub, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createRecipeStub(), + equalsBuilt(createRecipeStub()), + ); + + expect( + createRecipeStub().hashCode, + equals(createRecipeStub().hashCode), + ); + + expect( + createRecipeStub(), + isNot(equals(createRecipeStub(name: 'other name'))), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createRecipeStub().rebuild((_) {}), + equalsBuilt(createRecipeStub()), + ); + }); + + test('replaces every attribute', () { + expect( + createRecipeStub().rebuild((b) { + b + ..dateCreated = tz.TZDateTime(tz.UTC, 2025) + ..dateModified = tz.TZDateTime(tz.UTC, 2025) + ..id = 'new id' + ..imagePlaceholderUrl = Uri.https('new-imagePlaceholderUrl') + ..imageUrl = Uri.https('new-imageUrl') + ..keywords = BuiltSet({'keyword'}) + ..name = 'new name'; + }), + equalsBuilt( + createRecipeStub( + dateCreated: tz.TZDateTime(tz.UTC, 2025), + dateModified: tz.TZDateTime(tz.UTC, 2025), + id: 'new id', + imagePlaceholderUrl: Uri.https('new-imagePlaceholderUrl'), + imageUrl: Uri.https('new-imageUrl'), + keywords: {'keyword'}, + name: 'new name', + ), + ), + ); + }); + }); + + test('to string', () { + expect( + createRecipeStub().toString(), + equals(''' +RecipeStub { + dateCreated=2024-01-01 00:00:00.000Z, + dateModified=2024-01-01 00:00:00.000Z, + id=id, + imagePlaceholderUrl=https://imageplaceholderurl, + imageUrl=https://imageurl, + keywords={}, + name=name, +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_test.dart new file mode 100644 index 00000000000..e5556d8da4e --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/models/recipe_test.dart @@ -0,0 +1,177 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + group('Recipe', () { + Recipe createRecipe({ + Duration? cookTime = const Duration(minutes: 1), + tz.TZDateTime? dateCreated, + tz.TZDateTime? dateModified, + String description = 'description', + String? id, + Uri? image, + Uri? imagePlaceholderUrl, + Uri? imageUrl, + Set keywords = const {}, + Duration? prepTime = const Duration(minutes: 1), + String name = 'name', + Nutrition? nutrition, + String recipeCategory = 'recipeCategory', + List recipeIngredient = const [], + List recipeInstructions = const [], + int recipeYield = 1, + Duration? totalTime = const Duration(minutes: 1), + List tool = const [], + Uri? url, + }) { + return Recipe((b) { + b + ..cookTime = cookTime + ..dateCreated = dateCreated ?? tz.TZDateTime(tz.UTC, 2024) + ..dateModified = dateModified ?? tz.TZDateTime(tz.UTC, 2024) + ..description = description + ..id = id + ..image = image ?? Uri.https('image') + ..imagePlaceholderUrl = imagePlaceholderUrl ?? Uri.https('imagePlaceholderUrl') + ..imageUrl = imageUrl ?? Uri.https('imageUrl') + ..keywords = BuiltSet(keywords) + ..prepTime = prepTime + ..name = name + ..nutrition = nutrition ?? Nutrition() + ..recipeCategory = recipeCategory + ..recipeIngredient = BuiltList(recipeIngredient) + ..recipeInstructions = BuiltList(recipeInstructions) + ..recipeYield = recipeYield + ..totalTime = totalTime + ..tool = BuiltList(tool) + ..url = url ?? Uri.https('url'); + }); + } + + group('constructor', () { + test('works correctly', () { + expect( + createRecipe, + returnsNormally, + ); + }); + }); + + test('supports value equality', () { + expect( + createRecipe(), + equalsBuilt(createRecipe()), + ); + + expect( + createRecipe().hashCode, + equals(createRecipe().hashCode), + ); + + expect( + createRecipe(), + isNot(equals(createRecipe(url: Uri.https('other-url')))), + ); + }); + + group('rebuild', () { + test('returns the same object if not attributes are changed', () { + expect( + createRecipe().rebuild((_) {}), + equalsBuilt(createRecipe()), + ); + }); + + test('replaces every attribute', () { + expect( + createRecipe().rebuild((b) { + b + ..cookTime = const Duration(hours: 1) + ..dateCreated = tz.TZDateTime(tz.UTC, 2025) + ..dateModified = tz.TZDateTime(tz.UTC, 2025) + ..description = 'new description' + ..id = 'new id' + ..image = Uri.https('new-image') + ..imagePlaceholderUrl = Uri.https('new-imagePlaceholderUrl') + ..imageUrl = Uri.https('new-imageUrl') + ..keywords = BuiltSet({'keywords'}) + ..prepTime = const Duration(hours: 1) + ..name = 'new name' + ..nutrition = Nutrition() + ..recipeCategory = 'new recipeCategory' + ..recipeIngredient = BuiltList(['recipeIngredient']) + ..recipeInstructions = BuiltList(['recipeInstructions']) + ..recipeYield = 2 + ..totalTime = const Duration(hours: 1) + ..tool = BuiltList(['tool']) + ..url = Uri.https('new-url'); + }), + equalsBuilt( + createRecipe( + cookTime: const Duration(hours: 1), + dateCreated: tz.TZDateTime(tz.UTC, 2025), + dateModified: tz.TZDateTime(tz.UTC, 2025), + description: 'new description', + id: 'new id', + image: Uri.https('new-image'), + imagePlaceholderUrl: Uri.https('new-imagePlaceholderUrl'), + imageUrl: Uri.https('new-imageUrl'), + keywords: {'keywords'}, + prepTime: const Duration(hours: 1), + name: 'new name', + nutrition: Nutrition(), + recipeCategory: 'new recipeCategory', + recipeIngredient: ['recipeIngredient'], + recipeInstructions: ['recipeInstructions'], + recipeYield: 2, + totalTime: const Duration(hours: 1), + tool: ['tool'], + url: Uri.https('new-url'), + ), + ), + ); + }); + }); + + test('normalizes empty strings and zero duration', () { + expect( + createRecipe(cookTime: Duration.zero, prepTime: Duration.zero, totalTime: Duration.zero, id: ''), + predicate((r) { + return r.cookTime == null && r.prepTime == null && r.totalTime == null && r.id == null; + }), + ); + }); + + test('to string', () { + expect( + createRecipe().toString(), + equals(''' +Recipe { + cookTime=0:01:00.000000, + dateCreated=2024-01-01 00:00:00.000Z, + dateModified=2024-01-01 00:00:00.000Z, + description=description, + image=https://image, + imagePlaceholderUrl=https://imageplaceholderurl, + imageUrl=https://imageurl, + keywords={}, + prepTime=0:01:00.000000, + name=name, + nutrition=Nutrition { + type=NutritionInformation, + }, + recipeCategory=recipeCategory, + recipeIngredient=[], + recipeInstructions=[], + recipeYield=1, + totalTime=0:01:00.000000, + tool=[], + url=https://url, +}'''), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/recipe_repository_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/recipe_repository_test.dart new file mode 100644 index 00000000000..b401dbe01e9 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/recipe_repository_test.dart @@ -0,0 +1,536 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:nextcloud/nextcloud.dart'; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +class _MockCategoriesClient extends Mock implements cookbook.$CategoriesClient {} + +class _MockRecipesClient extends Mock implements cookbook.$RecipesClient {} + +class _FakeApiRecipe extends Fake implements cookbook.Recipe {} + +class _FakeUrl extends Fake implements cookbook.Url {} + +const _recipeConverter = RecipeToApiConverter(); +const _stubConverter = RecipeStubConverter(); + +void main() { + late cookbook.$CategoriesClient categoriesClient; + late cookbook.$RecipesClient recipesClient; + + late RecipeRepository repository; + + setUpAll(() { + registerFallbackValue(_FakeApiRecipe()); + registerFallbackValue(_FakeUrl()); + }); + + setUp(() { + categoriesClient = _MockCategoriesClient(); + recipesClient = _MockRecipesClient(); + + repository = RecipeRepository( + categoriesProvider: categoriesClient, + recipesProvider: recipesClient, + ); + }); + + Recipe createRecipe({ + Duration? cookTime = const Duration(minutes: 1), + tz.TZDateTime? dateCreated, + tz.TZDateTime? dateModified, + String description = 'description', + String? id, + Uri? image, + Uri? imagePlaceholderUrl, + Uri? imageUrl, + Set keywords = const {'keyword'}, + Duration? prepTime = const Duration(minutes: 1), + String name = 'name', + Nutrition? nutrition, + String recipeCategory = 'recipeCategory', + List recipeIngredient = const [], + List recipeInstructions = const [], + int recipeYield = 1, + Duration? totalTime = const Duration(minutes: 1), + List tool = const [], + Uri? url, + }) { + return Recipe((b) { + b + ..cookTime = cookTime + ..dateCreated = dateCreated ?? tz.TZDateTime(tz.UTC, 2024) + ..dateModified = dateModified ?? tz.TZDateTime(tz.UTC, 2024) + ..description = description + ..id = id + ..image = image ?? Uri.https('image') + ..imagePlaceholderUrl = imagePlaceholderUrl ?? Uri.https('imagePlaceholderUrl') + ..imageUrl = imageUrl ?? Uri.https('imageUrl') + ..keywords = BuiltSet(keywords) + ..prepTime = prepTime + ..name = name + ..nutrition = nutrition ?? Nutrition() + ..recipeCategory = recipeCategory + ..recipeIngredient = BuiltList(recipeIngredient) + ..recipeInstructions = BuiltList(recipeInstructions) + ..recipeYield = recipeYield + ..totalTime = totalTime + ..tool = BuiltList(tool) + ..url = url ?? Uri.https('url'); + }); + } + + group('RecipeRepository', () { + final defaultRecipe = createRecipe(id: 'id'); + final defaultApiRecipe = _recipeConverter.convert(defaultRecipe); + + final apiStubs = List.generate(10, (i) { + return cookbook.RecipeStub((b) { + b + ..dateCreated = '2024-07-14T10:25:58.204680Z' + ..dateModified = '2024-07-14T10:25:58.i.toString()' + ..id = i.toString() + ..recipeId = 0 + ..imagePlaceholderUrl = 'https://imageplaceholderurl' + ..imageUrl = 'https://imageurl' + ..keywords = 'keywords' + ..name = 'name'; + }); + }).toBuiltList(); + + final stubs = apiStubs.map(_stubConverter.convert).toBuiltList(); + + test('RecipeFailure equality', () { + expect( + const CreateRecipeFailure(''), + // ignore: prefer_const_constructors + equals(CreateRecipeFailure('')), + ); + }); + + group('createRecipe', () { + test('throws with id set', () async { + expect( + repository.createRecipe(defaultRecipe), + throwsArgumentError, + ); + + verifyNever( + () => recipesClient.newRecipe( + $body: any(named: r'$body', that: equalsBuilt(defaultApiRecipe)), + ), + ); + }); + + test('rethrows errors as a CreateRecipeFailure', () async { + when(() => recipesClient.newRecipe($body: any(named: r'$body'))).thenThrow( + 'failed to create recipe', + ); + + expect( + repository.createRecipe(createRecipe()), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to create recipe'), + ), + ), + ); + + verify( + () => recipesClient.newRecipe( + $body: any(named: r'$body', that: equalsBuilt(_recipeConverter.convert(createRecipe()))), + ), + ).called(1); + }); + + test('creates a new recipe', () async { + when(() => recipesClient.newRecipe($body: any(named: r'$body'))) + .thenAnswer((_) async => const DynamiteResponse(200, 1, null)); + + expect( + repository.createRecipe(createRecipe()), + completes, + ); + + verify( + () => recipesClient.newRecipe( + $body: any(named: r'$body', that: equalsBuilt(_recipeConverter.convert(createRecipe()))), + ), + ).called(1); + }); + }); + + group('readRecipe', () { + test('rethrows errors as a ReadRecipeFailure', () async { + when(() => recipesClient.recipeDetails(id: any(named: 'id'))).thenThrow( + 'failed to read recipe', + ); + + expect( + repository.readRecipe('id'), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to read recipe'), + ), + ), + ); + + verify( + () => recipesClient.recipeDetails(id: any(named: 'id', that: equals('id'))), + ).called(1); + }); + + test('reads recipe from the provider', () async { + when(() => recipesClient.recipeDetails(id: any(named: 'id'))) + .thenAnswer((_) async => DynamiteResponse(200, defaultApiRecipe, null)); + + expect( + repository.readRecipe('id'), + completion(equalsBuilt(defaultRecipe)), + ); + + verify( + () => recipesClient.recipeDetails(id: any(named: 'id', that: equals('id'))), + ).called(1); + }); + }); + + group('updateRecipe', () { + test('throws with no id set', () async { + expect( + repository.updateRecipe(createRecipe()), + throwsArgumentError, + ); + + verifyNever( + () => recipesClient.updateRecipe( + id: any(named: 'id', that: equals('id')), + $body: any(named: r'$body', that: equalsBuilt(_recipeConverter.convert(createRecipe()))), + ), + ); + }); + + test('rethrows errors as a UpdateRecipeFailure', () async { + when(() => recipesClient.updateRecipe(id: any(named: 'id'), $body: any(named: r'$body'))).thenThrow( + 'failed to update recipe', + ); + + expect( + repository.updateRecipe(defaultRecipe), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to update recipe'), + ), + ), + ); + + verify( + () => recipesClient.updateRecipe( + id: any(named: 'id', that: equals('id')), + $body: any(named: r'$body', that: equalsBuilt(defaultApiRecipe)), + ), + ).called(1); + }); + + test('updates the recipe', () async { + when(() => recipesClient.updateRecipe(id: any(named: 'id'), $body: any(named: r'$body'))) + .thenAnswer((_) async => const DynamiteResponse(200, 1, null)); + + expect( + repository.updateRecipe(defaultRecipe), + completes, + ); + + verify( + () => recipesClient.updateRecipe( + id: any(named: 'id', that: equals('id')), + $body: any(named: r'$body', that: equalsBuilt(defaultApiRecipe)), + ), + ).called(1); + }); + }); + + group('deleteRecipe', () { + test('throws with no id set', () async { + expect( + repository.deleteRecipe(createRecipe()), + throwsArgumentError, + ); + + verifyNever( + () => recipesClient.deleteRecipe(id: any(named: 'id', that: equals('id'))), + ); + }); + + test('rethrows errors as a DeleteRecipeFailure', () async { + when(() => recipesClient.deleteRecipe(id: any(named: 'id'))).thenThrow( + 'failed to delete recipe', + ); + + expect( + repository.deleteRecipe(defaultRecipe), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to delete recipe'), + ), + ), + ); + + verify( + () => recipesClient.deleteRecipe(id: any(named: 'id', that: equals('id'))), + ).called(1); + }); + + test('deletes the recipe', () async { + when(() => recipesClient.deleteRecipe(id: any(named: 'id'))) + .thenAnswer((_) async => const DynamiteResponse(200, 'id', null)); + + expect( + repository.deleteRecipe(defaultRecipe), + completes, + ); + + verify( + () => recipesClient.deleteRecipe(id: any(named: 'id', that: equals('id'))), + ).called(1); + }); + }); + + group('importRecipe', () { + final url = Uri.https('localhost'); + + test('rethrows errors as a ImportRecipeFailure', () async { + when(() => recipesClient.$import($body: any(named: r'$body'))).thenThrow( + 'failed to import recipe', + ); + + expect( + repository.importRecipe(url), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to import recipe'), + ), + ), + ); + + verify( + () => recipesClient.$import( + $body: any( + named: r'$body', + that: predicate((e) => e.url == url.toString()), + ), + ), + ).called(1); + }); + + test('imports the recipe', () async { + when(() => recipesClient.$import($body: any(named: r'$body'))) + .thenAnswer((_) async => DynamiteResponse(200, defaultApiRecipe, null)); + + expect( + repository.importRecipe(url), + completes, + ); + + verify( + () => recipesClient.$import( + $body: any( + named: r'$body', + that: predicate((e) => e.url == url.toString()), + ), + ), + ).called(1); + }); + }); + + group('readCategories', () { + final apiCategories = List.generate(10, (i) { + return cookbook.Category((b) { + b + ..name = 'name' + ..recipeCount = i % 2; + }); + }).toBuiltList(); + + test('returns empty list when no recipes exist', () async { + when(() => recipesClient.listRecipes()).thenAnswer((_) async => DynamiteResponse(200, BuiltList(), null)); + + expect( + repository.readCategories(), + completion(isEmpty), + ); + + verify(() => recipesClient.listRecipes()).called(1); + }); + + test('returns non empty categories with image url', () async { + when(() => recipesClient.listRecipes()).thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + when(() => categoriesClient.listCategories()) + .thenAnswer((_) async => DynamiteResponse(200, apiCategories, null)); + when(() => categoriesClient.recipesInCategory(category: any(named: 'category'))) + .thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + + await expectLater( + repository.readCategories(), + completion( + predicate>((c) { + if (c.first.name != Category.all) { + return false; + } + + for (final category in c) { + if (category.recipeCount == 0) { + return false; + } + } + + return c.length == 6; + }), + ), + ); + + verify(() => recipesClient.listRecipes()).called(1); + verify(() => categoriesClient.listCategories()).called(1); + verify( + () => categoriesClient.recipesInCategory(category: any(named: 'category')), + ).called(5); + }); + + test('rethrows errors as a ReadCategoriesFailure', () async { + when(() => recipesClient.listRecipes()).thenThrow('failed to read categories'); + when(() => categoriesClient.listCategories()) + .thenAnswer((_) async => DynamiteResponse(200, apiCategories, null)); + when(() => categoriesClient.recipesInCategory(category: any(named: 'category'))) + .thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + + expect( + repository.readCategories(), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to read categories'), + ), + ), + ); + verify(() => recipesClient.listRecipes()).called(1); + verifyNever(() => categoriesClient.listCategories()); + verifyNever( + () => categoriesClient.recipesInCategory(category: any(named: 'category')), + ); + }); + }); + + group('readCategory', () { + test('can read category', () async { + when(() => categoriesClient.recipesInCategory(category: any(named: 'category'))) + .thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + + expect( + repository.readCategory(name: 'Category'), + completion(stubs), + ); + + verify( + () => categoriesClient.recipesInCategory( + category: any(named: 'category', that: matches('Category')), + ), + ).called(1); + }); + + test('can read uncategorized recipes', () async { + when(() => categoriesClient.recipesInCategory(category: any(named: 'category'))) + .thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + + expect( + repository.readCategory(name: Category.uncategorized), + completion(stubs), + ); + + verify( + () => categoriesClient.recipesInCategory( + category: any(named: 'category', that: matches('_')), + ), + ).called(1); + }); + + test('can read all recipes', () async { + when(() => recipesClient.listRecipes()).thenAnswer((_) async => DynamiteResponse(200, apiStubs, null)); + + expect( + repository.readCategory(name: Category.all), + completion(stubs), + ); + + verify(() => recipesClient.listRecipes()).called(1); + }); + + test('rethrows errors as a ReadCategoryFailure', () async { + when(() => categoriesClient.recipesInCategory(category: any(named: 'category'))) + .thenThrow('failed to read category'); + when(() => recipesClient.listRecipes()).thenThrow('failed to read category'); + + expect( + repository.readCategory(name: 'Category'), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to read category'), + ), + ), + ); + verify( + () => categoriesClient.recipesInCategory( + category: any(named: 'category', that: matches('Category')), + ), + ).called(1); + + expect( + repository.readCategory(name: Category.uncategorized), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to read category'), + ), + ), + ); + + verify( + () => categoriesClient.recipesInCategory( + category: any(named: 'category', that: matches('_')), + ), + ).called(1); + + expect( + repository.readCategory(name: Category.all), + throwsA( + isA().having( + (f) => f.error, + 'error', + equals('failed to read category'), + ), + ), + ); + + verify(() => recipesClient.listRecipes()).called(1); + }); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_datetime_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_datetime_test.dart new file mode 100644 index 00000000000..034814af5dd --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_datetime_test.dart @@ -0,0 +1,28 @@ +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + group('Iso8601DateTime', () { + test('can parse null', () { + expect( + Iso8601DateTime.tryParse(tz.UTC, null), + isNull, + ); + }); + + test('can parse invalid string', () { + expect( + Iso8601DateTime.tryParse(tz.UTC, 'null'), + isNull, + ); + }); + + test('can parse valid string', () { + expect( + Iso8601DateTime.tryParse(tz.UTC, '2024-07-14T10:25:58.204680Z'), + equals(tz.TZDateTime(tz.UTC, 2024, 07, 14, 10, 25, 58, 0, 204680)), + ); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_duration_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_duration_test.dart new file mode 100644 index 00000000000..93f913482ef --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/iso8601_duration_test.dart @@ -0,0 +1,60 @@ +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('Iso8601DateTime', () { + test('can parse null', () { + expect( + Iso8601Duration.tryParse(null), + isNull, + ); + }); + + test('can parse invalid string', () { + expect( + Iso8601Duration.tryParse('null'), + isNull, + ); + }); + + test('can parse valid string', () { + expect( + Iso8601Duration.tryParse('P1DT2H3M4S'), + equals( + const Duration( + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + ), + ), + ); + }); + + test('can serialize duration', () { + expect( + const Duration( + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + ).toIso8601String(), + equals('P1DT2H3M4S'), + ); + }); + + test('can not serialize duration with sub second precision', () { + expect( + () => const Duration( + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + milliseconds: 5, + microseconds: 6, + ).toIso8601String(), + throwsArgumentError, + ); + }); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_converter_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_converter_test.dart new file mode 100644 index 00000000000..bffa4989ee0 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_converter_test.dart @@ -0,0 +1,72 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + final repositoryRecipe = Recipe((b) { + b + ..cookTime = Duration.zero + ..dateCreated = tz.TZDateTime(tz.UTC, 2024) + ..dateModified = tz.TZDateTime(tz.UTC, 2024) + ..description = 'description' + ..id = 'id' + ..image = Uri.https('image') + ..imagePlaceholderUrl = Uri.https('imagePlaceholderUrl') + ..imageUrl = Uri.https('imageUrl') + ..keywords = BuiltSet({'keyword', 'keyword2'}) + ..prepTime = const Duration(hours: 1) + ..name = 'name' + ..nutrition = Nutrition() + ..recipeCategory = 'recipeCategory' + ..recipeIngredient = BuiltList(['recipeIngredient']) + ..recipeInstructions = BuiltList(['recipeInstructions']) + ..recipeYield = 1 + ..totalTime = const Duration(minutes: 1) + ..tool = BuiltList(['tool', 'tool2']) + ..url = Uri.https('url'); + }); + + final apiRecipe = cookbook.Recipe((b) { + b + ..cookTime = null + ..dateCreated = '2024-01-01T00:00:00.000Z' + ..dateModified = '2024-01-01T00:00:00.000Z' + ..description = 'description' + ..id = 'id' + ..image = 'https://image' + ..imagePlaceholderUrl = 'https://imageplaceholderurl' + ..imageUrl = 'https://imageurl' + ..keywords = 'keyword,keyword2' + ..prepTime = 'PT1H' + ..name = 'name' + ..recipeCategory = 'recipeCategory' + ..recipeIngredient = ListBuilder(['recipeIngredient']) + ..recipeInstructions = ListBuilder(['recipeInstructions']) + ..recipeYield = 1 + ..totalTime = 'PT1M' + ..tool = ListBuilder(['tool', 'tool2']) + ..url = 'https://url'; + }); + + test('RecipeToApiConverter converts from repository to api', () { + const converter = RecipeToApiConverter(); + + expect( + converter.convert(repositoryRecipe), + equalsBuilt(apiRecipe), + ); + }); + + test('RecipeToApiConverter converts from api to repository ', () { + const converter = ApiToRecipeConverter(); + + expect( + converter.convert(apiRecipe), + equalsBuilt(repositoryRecipe), + ); + }); +} diff --git a/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_stub_converter_test.dart b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_stub_converter_test.dart new file mode 100644 index 00000000000..975414b2a1b --- /dev/null +++ b/packages/neon_framework/packages/cookbook_recipe_repository/test/utils/recipe_stub_converter_test.dart @@ -0,0 +1,41 @@ +import 'package:built_collection/built_collection.dart'; +import 'package:built_value_test/matcher.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:cookbook_recipe_repository/src/utils/utils.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:test/test.dart'; +import 'package:timezone/timezone.dart' as tz; + +void main() { + test('RecipeStubConverter converts from api to repository', () { + const converter = RecipeStubConverter(); + + expect( + converter.convert( + cookbook.RecipeStub((b) { + b + ..dateCreated = '2024-01-01T00:00:00.000Z' + ..dateModified = '2024-01-01T00:00:00.000Z' + ..id = 'id' + ..recipeId = 0 + ..imagePlaceholderUrl = 'https://imageplaceholderurl' + ..imageUrl = 'https://imageurl' + ..keywords = 'keyword,keyword2' + ..name = 'name'; + }), + ), + equalsBuilt( + RecipeStub((b) { + b + ..dateCreated = tz.TZDateTime(tz.UTC, 2024) + ..dateModified = tz.TZDateTime(tz.UTC, 2024) + ..id = 'id' + ..imagePlaceholderUrl = Uri.https('imagePlaceholderUrl') + ..imageUrl = Uri.https('imageUrl') + ..keywords = BuiltSet({'keyword', 'keyword2'}) + ..name = 'name'; + }), + ), + ); + }); +} From 78a715eea6b169b4939fe4c0a9f58bd76c170ff2 Mon Sep 17 00:00:00 2001 From: Nikolas Rimikis Date: Tue, 16 Jul 2024 19:15:49 +0200 Subject: [PATCH 5/5] feat(cookbook_app): add home view Signed-off-by: Nikolas Rimikis --- .cspell/dart_flutter.txt | 3 +- packages/neon_framework/example/pubspec.lock | 7 + .../example/pubspec_overrides.yaml | 4 +- .../cookbook_app/lib/l10n/arb/cookbook_en.arb | 39 +++- .../lib/l10n/cookbook_localizations.dart | 60 ++++-- .../lib/l10n/cookbook_localizations_en.dart | 32 +++ .../packages/cookbook_app/lib/l10n/l10n.dart | 15 ++ .../cookbook_app/lib/neon_cookbook.dart | 2 +- .../src/categories/bloc/categories_bloc.dart | 42 ++++ .../src/categories/bloc/categories_event.dart | 17 ++ .../src/categories/bloc/categories_state.dart | 51 +++++ .../lib/src/categories/categories.dart | 4 + .../utils/category_grid_delegate.dart | 44 ++++ .../lib/src/categories/utils/utils.dart | 1 + .../src/categories/view/categories_page.dart | 24 +++ .../src/categories/view/categories_view.dart | 72 +++++++ .../lib/src/categories/view/view.dart | 2 + .../src/categories/widgets/category_card.dart | 80 +++++++ .../lib/src/categories/widgets/widgets.dart | 1 + .../lib/src/home/cubit/home_cubit.dart | 13 ++ .../lib/src/home/cubit/home_state.dart | 24 +++ .../cookbook_app/lib/src/home/home.dart | 2 + .../lib/src/home/view/home_page.dart | 34 +++ .../lib/src/home/view/home_view.dart | 72 +++++++ .../cookbook_app/lib/src/home/view/view.dart | 2 + .../cookbook_app/lib/src/neon/neon.dart | 1 + .../cookbook_app/lib/src/neon/routes.dart | 3 +- .../recipe_list/bloc/recipe_list_bloc.dart | 46 ++++ .../recipe_list/bloc/recipe_list_event.dart | 17 ++ .../recipe_list/bloc/recipe_list_state.dart | 51 +++++ .../lib/src/recipe_list/recipe_list.dart | 3 + .../recipe_list/view/recipe_list_page.dart | 38 ++++ .../recipe_list/view/recipe_list_view.dart | 78 +++++++ .../lib/src/recipe_list/view/view.dart | 2 + .../src/recipe_list/widgets/date_chip.dart | 54 +++++ .../recipe_list/widgets/recipe_list_item.dart | 44 ++++ .../lib/src/recipe_list/widgets/widgets.dart | 2 + .../widgets/loading_refresh_indicator.dart | 202 ++++++++++++++++++ .../lib/src/widgets/recipe_image.dart | 57 +++++ .../cookbook_app/lib/src/widgets/widgets.dart | 2 + .../packages/cookbook_app/pubspec.yaml | 8 +- .../cookbook_app/pubspec_overrides.yaml | 4 +- 42 files changed, 1240 insertions(+), 19 deletions(-) create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart create mode 100644 packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart diff --git a/.cspell/dart_flutter.txt b/.cspell/dart_flutter.txt index caaec313796..fdbb6b23c05 100644 --- a/.cspell/dart_flutter.txt +++ b/.cspell/dart_flutter.txt @@ -1,3 +1,4 @@ +arrowshape autofocus checkmark cupertino @@ -10,6 +11,7 @@ goldens lerp pubspec qrcode +Relayout steb sublist todos @@ -18,4 +20,3 @@ unawaited unfocus writeln xmark -arrowshape diff --git a/packages/neon_framework/example/pubspec.lock b/packages/neon_framework/example/pubspec.lock index cf4789a5692..1c2cc6784c2 100644 --- a/packages/neon_framework/example/pubspec.lock +++ b/packages/neon_framework/example/pubspec.lock @@ -220,6 +220,13 @@ packages: relative: true source: path version: "1.0.0" + cookbook_recipe_repository: + dependency: "direct overridden" + description: + path: "../packages/cookbook_recipe_repository" + relative: true + source: path + version: "1.0.0" cookie_store: dependency: "direct overridden" description: diff --git a/packages/neon_framework/example/pubspec_overrides.yaml b/packages/neon_framework/example/pubspec_overrides.yaml index 3e4aa070698..339d1b70104 100644 --- a/packages/neon_framework/example/pubspec_overrides.yaml +++ b/packages/neon_framework/example/pubspec_overrides.yaml @@ -1,9 +1,11 @@ -# melos_managed_dependency_overrides: account_repository,cookbook_app,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app +# melos_managed_dependency_overrides: account_repository,cookbook_app,cookbook_recipe_repository,cookie_store,dashboard_app,dynamite_runtime,files_app,interceptor_http_client,neon_framework,neon_http_client,neon_lints,news_app,nextcloud,notes_app,notifications_app,sort_box,talk_app dependency_overrides: account_repository: path: ../packages/account_repository cookbook_app: path: ../packages/cookbook_app + cookbook_recipe_repository: + path: ../packages/cookbook_recipe_repository cookie_store: path: ../../cookie_store dashboard_app: diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb b/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb index 7dc6d05dc6d..96275440d15 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/arb/cookbook_en.arb @@ -1,3 +1,40 @@ { - "@@locale": "en" + "@@locale": "en", + "recipeCreateButton": "Create Recipe", + "@recipeCreateButton": { + "type": "text", + "description": "Button to open the create recipe screen" + }, + "recipeListTitle": "Category: {name}", + "@recipeListTitle": { + "type": "text", + "description": "Title of the category view.", + "placeholders": { + "name": { + "description": "The name of the category.", + "type": "String", + "example": "Vegan" + } + } + }, + "noRecipes": "No recipes available.", + "errorLoadFailed": "Failed to load Recipe!", + "@errorLoadFailed": { + "type": "text", + "description": "Error message when fetching the recipes failed." + }, + "categoryAll": "All Recipes", + "categoryUncategorized": "Uncategorized", + "categoryItems": "{count, plural, =0{no items} =1 {1 item} other {{count} items}}", + "@categoryItems": { + "type": "text", + "description": "Number of recipes in a category.", + "placeholders": { + "count": { + "description": "The number of recipes.", + "type": "int", + "example": "4" + } + } + } } diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart index 558356e4518..993f65c7b25 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/cookbook_localizations.dart @@ -89,10 +89,49 @@ abstract class CookbookLocalizations { ]; /// A list of this localizations delegate's supported locales. - static const List supportedLocales = [ - Locale('en') - ]; + static const List supportedLocales = [Locale('en')]; + + /// Button to open the create recipe screen + /// + /// In en, this message translates to: + /// **'Create Recipe'** + String get recipeCreateButton; + + /// Title of the category view. + /// + /// In en, this message translates to: + /// **'Category: {name}'** + String recipeListTitle(String name); + + /// No description provided for @noRecipes. + /// + /// In en, this message translates to: + /// **'No recipes available.'** + String get noRecipes; + + /// Error message when fetching the recipes failed. + /// + /// In en, this message translates to: + /// **'Failed to load Recipe!'** + String get errorLoadFailed; + + /// No description provided for @categoryAll. + /// + /// In en, this message translates to: + /// **'All Recipes'** + String get categoryAll; + + /// No description provided for @categoryUncategorized. + /// + /// In en, this message translates to: + /// **'Uncategorized'** + String get categoryUncategorized; + /// Number of recipes in a category. + /// + /// In en, this message translates to: + /// **'{count, plural, =0{no items} =1 {1 item} other {{count} items}}'** + String categoryItems(int count); } class _CookbookLocalizationsDelegate extends LocalizationsDelegate { @@ -111,17 +150,14 @@ class _CookbookLocalizationsDelegate extends LocalizationsDelegate 'Create Recipe'; + + @override + String recipeListTitle(String name) { + return 'Category: $name'; + } + + @override + String get noRecipes => 'No recipes available.'; + + @override + String get errorLoadFailed => 'Failed to load Recipe!'; + + @override + String get categoryAll => 'All Recipes'; + + @override + String get categoryUncategorized => 'Uncategorized'; + @override + String categoryItems(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count items', + one: '1 item', + zero: 'no items', + ); + return '$_temp0'; + } } diff --git a/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart index 3e200c9f59f..f118c44beb3 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/l10n/l10n.dart @@ -11,3 +11,18 @@ extension AppLocalizationsX on BuildContext { /// `CookbookLocalizations.of(this)`. CookbookLocalizations get l10n => CookbookLocalizations.of(this); } + +/// Extension for custom localizations constructed from other ones. +extension CookbookLocalizationsX on CookbookLocalizations { + /// Translates the special categories '_' (all recipes) and '*' (uncategorized). + /// + /// In en, this message translates to: + /// **'{name, select, _{[categoryAll]} *{[categoryUncategorized]} other{{name}}}'** + String categoryName(String name) { + return switch (name) { + '_' => categoryAll, + '*' => categoryUncategorized, + _ => name, + }; + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart index 9462962dbb7..be09f6a35a6 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/neon_cookbook.dart @@ -31,7 +31,7 @@ final class CookbookApp extends AppImplementation CookbookBloc buildBloc(Account account) => CookbookBloc(); @override - final Widget page = const Placeholder(); + final Widget page = const HomePage(); @override final RouteBase route = $cookbookAppRoute; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart new file mode 100644 index 00000000000..5d6cdcb7ddc --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_bloc.dart @@ -0,0 +1,42 @@ +import 'package:bloc/bloc.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'categories_event.dart'; +part 'categories_state.dart'; + +/// The bloc controlling the categories overview. +final class CategoriesBloc extends Bloc<_CategoriesEvent, CategoriesState> { + /// Creates a new categories bloc. + CategoriesBloc({ + required RecipeRepository recipeRepository, + }) : _recipeRepository = recipeRepository, + super(CategoriesState()) { + on(_onRefreshCategories); + + add(const RefreshCategories()); + } + + final RecipeRepository _recipeRepository; + + Future _onRefreshCategories( + RefreshCategories event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: CategoriesStatus.loading)); + + final categories = await _recipeRepository.readCategories(); + + emit( + state.copyWith( + categories: categories, + status: CategoriesStatus.success, + ), + ); + } on ReadCategoriesFailure { + emit(state.copyWith(status: CategoriesStatus.failure)); + } + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart new file mode 100644 index 00000000000..5c10af8f359 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_event.dart @@ -0,0 +1,17 @@ +part of 'categories_bloc.dart'; + +/// Events for the [CategoriesBloc]. +sealed class _CategoriesEvent extends Equatable { + const _CategoriesEvent(); + + @override + List get props => []; +} + +/// {@template RefreshCategories} +/// Event that triggers a reload of the categories. +/// {@endtemplate} +final class RefreshCategories extends _CategoriesEvent { + /// {@macro RefreshCategories} + const RefreshCategories(); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart new file mode 100644 index 00000000000..8fccfda3c7b --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/bloc/categories_state.dart @@ -0,0 +1,51 @@ +part of 'categories_bloc.dart'; + +/// The status of the [CategoriesState]. +enum CategoriesStatus { + /// When no event has been handled. + initial, + + /// When the categories are loading. + loading, + + /// When the categories have been fetched successfully. + success, + + /// When a failure occurred while loading the categories. + failure, +} + +/// State of the [CategoriesBloc]. +final class CategoriesState extends Equatable { + /// Creates a new state for managing the categories. + CategoriesState({ + BuiltList? categories, + this.status = CategoriesStatus.initial, + }) : categories = categories ?? BuiltList(); + + /// The list of categories. + /// + /// Defaults to an empty list. + final BuiltList categories; + + /// The status of the state. + final CategoriesStatus status; + + /// Creates a copies with mutated fields. + CategoriesState copyWith({ + BuiltList? categories, + String? error, + CategoriesStatus? status, + }) { + return CategoriesState( + categories: categories ?? this.categories, + status: status ?? this.status, + ); + } + + @override + List get props => [ + categories, + status, + ]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart new file mode 100644 index 00000000000..849bec56e57 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/categories.dart @@ -0,0 +1,4 @@ +export 'bloc/categories_bloc.dart'; +export 'utils/utils.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart new file mode 100644 index 00000000000..934399a905a --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/category_grid_delegate.dart @@ -0,0 +1,44 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; + +/// Controls the layout of the category cards in a grid. +class CategoryGridDelegate extends SliverGridDelegate { + /// Creates a delegate for the category card layout. + const CategoryGridDelegate({ + this.extent = 0.0, + }); + + /// The height extend the card will take. + final double extent; + + static const double _maxCrossAxisExtent = 250; + static const double _mainAxisSpacing = 8; + static const double _crossAxisSpacing = 8; + + @override + SliverGridLayout getLayout(SliverConstraints constraints) { + var crossAxisCount = (constraints.crossAxisExtent / (_maxCrossAxisExtent + _crossAxisSpacing)).ceil(); + // Ensure a minimum count of 1, can be zero and result in an infinite extent + // below when the window size is 0. + crossAxisCount = math.max(1, crossAxisCount); + final double usableCrossAxisExtent = math.max( + 0, + constraints.crossAxisExtent - _crossAxisSpacing * (crossAxisCount - 1), + ); + final childCrossAxisExtent = usableCrossAxisExtent / crossAxisCount; + final childMainAxisExtent = childCrossAxisExtent + extent; + + return SliverGridRegularTileLayout( + crossAxisCount: crossAxisCount, + mainAxisStride: childMainAxisExtent + _mainAxisSpacing, + crossAxisStride: childCrossAxisExtent + _crossAxisSpacing, + childMainAxisExtent: childMainAxisExtent, + childCrossAxisExtent: childCrossAxisExtent, + reverseCrossAxis: axisDirectionIsReversed(constraints.crossAxisDirection), + ); + } + + @override + bool shouldRelayout(CategoryGridDelegate oldDelegate) => oldDelegate.extent != extent; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart new file mode 100644 index 00000000000..c1a77ff5140 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/utils/utils.dart @@ -0,0 +1 @@ +export 'category_grid_delegate.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart new file mode 100644 index 00000000000..c9a88946437 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_page.dart @@ -0,0 +1,24 @@ +import 'package:cookbook_app/src/categories/categories.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The page for showing the categories. +class CategoriesPage extends StatelessWidget { + /// Creates a new page for showing the categories. + const CategoriesPage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => CategoriesBloc( + recipeRepository: context.read(), + ), + ), + ], + child: const CategoriesView(), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart new file mode 100644 index 00000000000..f4223de7d57 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/categories_view.dart @@ -0,0 +1,72 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/categories/categories.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the categories page. +class CategoriesView extends StatelessWidget { + /// Creates a new categories view. + const CategoriesView({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: LoadingRefreshIndicator( + isLoading: context.select( + (bloc) => bloc.state.status == CategoriesStatus.loading, + ), + onRefresh: () { + context.read().add(const RefreshCategories()); + }, + child: BlocConsumer( + listener: (context, state) { + if (state.status == CategoriesStatus.failure) { + final theme = Theme.of(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.errorLoadFailed, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }, + builder: (context, state) { + if (state.status == CategoriesStatus.initial) { + return const SizedBox(); + } + + if (state.status != CategoriesStatus.loading && state.categories.isEmpty) { + return Center( + child: Text(context.l10n.noRecipes), + ); + } + + // TODO: this is ugly code + final extent = CategoryCard.hightExtend(context); + return GridView.builder( + key: const Key('CategoriesView-grid'), + padding: const EdgeInsets.all(16), + gridDelegate: CategoryGridDelegate(extent: extent), + itemCount: state.categories.length, + itemBuilder: (context, index) { + final category = state.categories[index]; + + return CategoryCard( + category: category, + key: Key('categoryCard-item-$index'), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart new file mode 100644 index 00000000000..7f281551ad1 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/view/view.dart @@ -0,0 +1,2 @@ +export 'categories_page.dart'; +export 'categories_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart new file mode 100644 index 00000000000..b627b7ccff4 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/category_card.dart @@ -0,0 +1,80 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/widgets.dart'; + +/// The material design card for the categories screen. +class CategoryCard extends StatelessWidget { + /// Creates a new categories card. + const CategoryCard({ + required this.category, + super.key, + }); + + /// The category displayed on the card. + final Category category; + + // TODO: this is really ugly. + static const double _spacer = 8; + static const _labelPadding = EdgeInsets.symmetric(horizontal: 8); + static TextStyle _nameStyle(BuildContext context) => Theme.of(context).textTheme.labelSmall!; + static TextStyle _itemStyle(BuildContext context) => Theme.of(context).textTheme.labelSmall!; + + /// Calculates the hight this card will take if built with the same context. + static double hightExtend(BuildContext context) => + _spacer + _itemStyle(context).fontSize! + _itemStyle(context).fontSize! + 2 * _labelPadding.horizontal; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final size = constraints.maxWidth; + + return GestureDetector( + child: Card( + color: theme.colorScheme.secondaryContainer, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NeonImageWrapper( + borderRadius: BorderRadius.circular(12), + child: RecipeImage( + recipeID: category.mainRecipeId, + size: Size.square(size), + ), + ), + const SizedBox(height: _spacer), + Padding( + padding: _labelPadding, + child: Text( + context.l10n.categoryName(category.name), + maxLines: 1, + style: _nameStyle(context), + ), + ), + Padding( + padding: _labelPadding, + child: Text( + context.l10n.categoryItems(category.recipeCount), + style: _itemStyle(context), + ), + ), + ], + ), + ), + onTap: () async => Navigator.of(context).push( + RecipeListPage.route( + category: category, + recipeRepository: context.read(), + ), + ), + ); + }, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart new file mode 100644 index 00000000000..b03af441852 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/categories/widgets/widgets.dart @@ -0,0 +1 @@ +export 'category_card.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart new file mode 100644 index 00000000000..79fe15e14ab --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_cubit.dart @@ -0,0 +1,13 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; + +part 'home_state.dart'; + +/// Cubit for managing the home navigation. +class HomeCubit extends Cubit { + /// Creates a new home navigation cubit. + HomeCubit() : super(const HomeState()); + + /// Sets the home tab to the given one. + void setTab(HomeTab tab) => emit(HomeState(tab: tab)); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart new file mode 100644 index 00000000000..8cf39ebb387 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/cubit/home_state.dart @@ -0,0 +1,24 @@ +part of 'home_cubit.dart'; + +/// Available tabs for the cookbook home screen. +enum HomeTab { + /// The Recipe categories. + categories, + + /// The timer screen. + timers, +} + +/// The state of the [HomeCubit]. +final class HomeState extends Equatable { + /// Creates a new home state. + const HomeState({ + this.tab = HomeTab.categories, + }); + + /// The active tab in the bottom navigation. + final HomeTab tab; + + @override + List get props => [tab]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart new file mode 100644 index 00000000000..0e9281ae5bc --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/home.dart @@ -0,0 +1,2 @@ +export 'cubit/home_cubit.dart'; +export 'view/view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart new file mode 100644 index 00000000000..39bf9b33a03 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_page.dart @@ -0,0 +1,34 @@ +import 'package:cookbook_app/src/home/home.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/models.dart'; +import 'package:nextcloud/cookbook.dart'; + +/// The main page of the cookbook app. +class HomePage extends StatelessWidget { + /// Creates a new home page for the cookbook app. + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (context) { + final account = context.read(); + final categoryProvider = account.client.cookbook.categories; + final recipeProvider = account.client.cookbook.recipes; + + return RecipeRepository( + categoriesProvider: categoryProvider, + recipesProvider: recipeProvider, + ); + }, + ), + BlocProvider(create: (_) => HomeCubit()), + ], + child: const HomeView(), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart new file mode 100644 index 00000000000..edbf7997592 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/home_view.dart @@ -0,0 +1,72 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/home/home.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the home page. +class HomeView extends StatelessWidget { + /// Creates a new home view. + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + final selectedTab = context.select((cubit) => cubit.state.tab); + + return Scaffold( + body: IndexedStack( + index: selectedTab.index, + children: const [Placeholder(), Placeholder()], + ), + floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: FloatingActionButton( + shape: const CircleBorder(), + tooltip: context.l10n.recipeCreateButton, + key: const Key('homeView_createRecipe_floatingActionButton'), + onPressed: () { + throw UnimplementedError('navigate to RecipeEditScreen'); + }, + child: const Icon(Icons.add), + ), + bottomNavigationBar: BottomAppBar( + shape: const CircularNotchedRectangle(), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _HomeTabButton( + groupValue: selectedTab, + value: HomeTab.categories, + icon: const Icon(Icons.receipt_long_outlined), + ), + _HomeTabButton( + groupValue: selectedTab, + value: HomeTab.timers, + icon: const Icon(Icons.alarm_add_outlined), + ), + ], + ), + ), + ); + } +} + +class _HomeTabButton extends StatelessWidget { + const _HomeTabButton({ + required this.groupValue, + required this.value, + required this.icon, + }); + + final HomeTab groupValue; + final HomeTab value; + final Widget icon; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => context.read().setTab(value), + iconSize: 32, + color: groupValue != value ? null : Theme.of(context).colorScheme.secondary, + icon: icon, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart new file mode 100644 index 00000000000..53cf2b9b3e0 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/home/view/view.dart @@ -0,0 +1,2 @@ +export 'home_page.dart'; +export 'home_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart index 3d570824aab..092bc3b1ea0 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/neon.dart @@ -1,3 +1,4 @@ +export 'package:cookbook_app/src/home/home.dart'; export 'bloc.dart'; export 'options.dart'; export 'routes.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart index 0855b1fee0b..76af15788c6 100644 --- a/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart +++ b/packages/neon_framework/packages/cookbook_app/lib/src/neon/routes.dart @@ -1,3 +1,4 @@ +import 'package:cookbook_app/src/neon/neon.dart'; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:neon_framework/utils.dart'; @@ -16,5 +17,5 @@ class CookbookAppRoute extends NeonBaseAppRoute { const CookbookAppRoute(); @override - Widget build(BuildContext context, GoRouterState state) => const Placeholder(); + Widget build(BuildContext context, GoRouterState state) => const HomePage(); } diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart new file mode 100644 index 00000000000..fcfdcc89bc6 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_bloc.dart @@ -0,0 +1,46 @@ +import 'package:bloc/bloc.dart'; +import 'package:built_collection/built_collection.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'recipe_list_event.dart'; +part 'recipe_list_state.dart'; + +/// The bloc controlling the recipes in a single category. +final class RecipeListBloc extends Bloc<_RecipeListEvent, RecipeListState> { + /// Creates a new recipe bloc. + RecipeListBloc({ + required RecipeRepository recipeRepository, + required this.category, + }) : _recipeRepository = recipeRepository, + super(RecipeListState()) { + on(_onRefreshRecipeList); + + add(const RefreshRecipeList()); + } + + final RecipeRepository _recipeRepository; + + /// The category this bloc manages. + final Category category; + + Future _onRefreshRecipeList( + RefreshRecipeList event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: RecipeListStatus.loading)); + + final recipes = await _recipeRepository.readCategory(name: category.name); + + emit( + state.copyWith( + recipes: recipes, + status: RecipeListStatus.success, + ), + ); + } on ReadCategoryFailure { + emit(state.copyWith(status: RecipeListStatus.failure)); + } + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart new file mode 100644 index 00000000000..667718eb986 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_event.dart @@ -0,0 +1,17 @@ +part of 'recipe_list_bloc.dart'; + +/// Events for the [RecipeListBloc]. +sealed class _RecipeListEvent extends Equatable { + const _RecipeListEvent(); + + @override + List get props => []; +} + +/// {@template RefreshRecipeList} +/// Event that triggers a reload of the recipe list. +/// {@endtemplate} +final class RefreshRecipeList extends _RecipeListEvent { + /// {@macro RefreshRecipeList} + const RefreshRecipeList(); +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart new file mode 100644 index 00000000000..2b7cf85077d --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/bloc/recipe_list_state.dart @@ -0,0 +1,51 @@ +part of 'recipe_list_bloc.dart'; + +/// The status of the [RecipeListState]. +enum RecipeListStatus { + /// When no event has been handled. + initial, + + /// When the categories are loading. + loading, + + /// When the categories have been fetched successfully. + success, + + /// When a failure occurred while loading the categories. + failure, +} + +/// State of the [RecipeListBloc]. +final class RecipeListState extends Equatable { + /// Creates a new state for managing the recipes in a category. + RecipeListState({ + BuiltList? recipes, + this.status = RecipeListStatus.initial, + }) : recipes = recipes ?? BuiltList(); + + /// The list of recipes. + /// + /// Defaults to an empty list. + final BuiltList recipes; + + /// The status of the state. + final RecipeListStatus status; + + /// Creates a copies with mutated fields. + RecipeListState copyWith({ + BuiltList? recipes, + String? error, + RecipeListStatus? status, + }) { + return RecipeListState( + recipes: recipes ?? this.recipes, + status: status ?? this.status, + ); + } + + @override + List get props => [ + recipes, + status, + ]; +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart new file mode 100644 index 00000000000..0118734cdc0 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/recipe_list.dart @@ -0,0 +1,3 @@ +export 'bloc/recipe_list_bloc.dart'; +export 'view/view.dart'; +export 'widgets/widgets.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart new file mode 100644 index 00000000000..622bf915f3d --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_page.dart @@ -0,0 +1,38 @@ +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The page for displaying the recipes in a category. +class RecipeListPage extends StatelessWidget { + /// Creates a new category page. + const RecipeListPage({super.key}); + + /// The route to navigate to this page. + static Route route({ + required Category category, + required RecipeRepository recipeRepository, + }) { + return MaterialPageRoute( + fullscreenDialog: true, + builder: (context) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => RecipeListBloc( + category: category, + // If the repository where to be inserted above the main app we could easily access it everywhere :( + recipeRepository: recipeRepository, //context.read(), + ), + ), + RepositoryProvider.value(value: recipeRepository), + ], + child: const RecipeListPage(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return const RecipeListView(); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart new file mode 100644 index 00000000000..c4434128c0e --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/recipe_list_view.dart @@ -0,0 +1,78 @@ +import 'package:cookbook_app/l10n/l10n.dart'; +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// The material design view for the recipe list page. +class RecipeListView extends StatelessWidget { + /// Creates a new recipe list view. + const RecipeListView({super.key}); + + @override + Widget build(BuildContext context) { + final category = context.select((bloc) => bloc.category); + + return Scaffold( + appBar: AppBar( + title: Text( + context.l10n.recipeListTitle( + context.l10n.categoryName(category.name), + ), + ), + ), + body: LoadingRefreshIndicator( + isLoading: context.select( + (bloc) => bloc.state.status == RecipeListStatus.loading, + ), + onRefresh: () { + context.read().add(const RefreshRecipeList()); + }, + child: BlocConsumer( + listener: (context, state) { + if (state.status == RecipeListStatus.failure) { + final theme = Theme.of(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.errorLoadFailed, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + ), + ), + backgroundColor: theme.colorScheme.errorContainer, + ), + ); + } + }, + builder: (context, state) { + if (state.status == RecipeListStatus.initial) { + return const SizedBox(); + } + + if (state.status != RecipeListStatus.loading && state.recipes.isEmpty) { + return Center( + child: Text(context.l10n.noRecipes), + ); + } + + return Padding( + padding: const EdgeInsets.all(8), + child: ListView.separated( + itemCount: state.recipes.length, + itemBuilder: (context, index) { + final recipe = state.recipes[index]; + + return RecipeListItem(recipe: recipe); + }, + separatorBuilder: (context, index) => const Divider(), + ), + ); + }, + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart new file mode 100644 index 00000000000..e76e300dce9 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/view/view.dart @@ -0,0 +1,2 @@ +export 'recipe_list_page.dart'; +export 'recipe_list_view.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart new file mode 100644 index 00000000000..0448824bc67 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/date_chip.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A chip style UI element to display a date. +class DateChip extends StatelessWidget { + /// Creates a new date chip. + const DateChip({ + required this.date, + this.dateFormat = DateFormat.YEAR_NUM_MONTH_DAY, + this.icon, + super.key, + }); + + /// The date to display. + final DateTime date; + + /// The format to use for the date. + final String dateFormat; + + /// An optional leading icon to display in front of the date. + final IconData? icon; + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodySmall!; + final colorScheme = Theme.of(context).colorScheme; + final content = DateFormat(dateFormat).format(date); + + return Card( + color: colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: textStyle.fontSize, + color: colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 4), + Text( + content, + style: textStyle.copyWith( + color: colorScheme.onSecondaryContainer, + ), + overflow: TextOverflow.fade, + ), + ], + ), + ), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart new file mode 100644 index 00000000000..7f7d69e1e87 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/recipe_list_item.dart @@ -0,0 +1,44 @@ +import 'package:cookbook_app/src/recipe_list/recipe_list.dart'; +import 'package:cookbook_app/src/widgets/widgets.dart'; +import 'package:cookbook_recipe_repository/recipe_repository.dart'; +import 'package:flutter/material.dart'; + +/// The Item to display the recipe information in the recipe list. +class RecipeListItem extends StatelessWidget { + /// Creates a new recipe list item for the given [recipe]. + const RecipeListItem({ + required this.recipe, + super.key, + }); + + /// The recipe to display the information for. + final RecipeStub recipe; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: RecipeImage( + recipeID: recipe.id, + size: const Size.square(80), + ), + ), + title: Text(recipe.name), + subtitle: Row( + children: [ + DateChip( + date: recipe.dateCreated, + icon: Icons.edit_calendar_outlined, + ), + if (recipe.dateModified != null && recipe.dateModified != recipe.dateCreated) + DateChip( + date: recipe.dateModified!, + icon: Icons.edit_outlined, + ), + ], + ), + onTap: () async => throw UnimplementedError('navigate to RecipePage'), + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart new file mode 100644 index 00000000000..0c2552eedbf --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/recipe_list/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'date_chip.dart'; +export 'recipe_list_item.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart new file mode 100644 index 00000000000..bbf1df06254 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/loading_refresh_indicator.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// The signature for a function that's called when the user has dragged a +/// [LoadingRefreshIndicator] far enough to demonstrate that they want the app +/// to refresh. +/// +/// Used by [LoadingRefreshIndicator.onRefresh]. +typedef LoadingRefreshCallback = void Function(); + +/// A loading indicator wrapper around [RefreshIndicator.adaptive]. +/// +/// This allows showing the refresh indicator from an external source. +class LoadingRefreshIndicator extends StatefulWidget { + /// Creates a new loading refresh indicator. + const LoadingRefreshIndicator({ + required this.onRefresh, + required this.child, + this.atTop = true, + this.isLoading = false, + this.displacement = 40.0, + this.edgeOffset = 0.0, + this.color, + this.backgroundColor, + this.notificationPredicate = defaultScrollNotificationPredicate, + this.semanticsLabel, + this.semanticsValue, + this.strokeWidth = RefreshProgressIndicator.defaultStrokeWidth, + this.triggerMode = RefreshIndicatorTriggerMode.onEdge, + super.key, + }); + + /// Whether the content is loading and the loading indicator should be shown. + final bool isLoading; + + /// Equivalent to the `atTop` argument in [RefreshIndicatorState.show]. + /// + /// It defaults to showing the indicator at the top. To show it at the + /// bottom, set `atTop` to false. + final bool atTop; + + /// The widget below this widget in the tree. + /// + /// The refresh indicator will be stacked on top of this child. The indicator + /// will appear when child's Scrollable descendant is over-scrolled. + /// + /// Typically a [ListView] or [CustomScrollView]. + final Widget child; + + /// The distance from the child's top or bottom [edgeOffset] where + /// the refresh indicator will settle. During the drag that exposes the refresh + /// indicator, its actual displacement may significantly exceed this value. + /// + /// In most cases, [displacement] distance starts counting from the parent's + /// edges. However, if [edgeOffset] is larger than zero then the [displacement] + /// value is calculated from that offset instead of the parent's edge. + final double displacement; + + /// The offset where [RefreshProgressIndicator] starts to appear on drag start. + /// + /// Depending whether the indicator is showing on the top or bottom, the value + /// of this variable controls how far from the parent's edge the progress + /// indicator starts to appear. This may come in handy when, for example, the + /// UI contains a top [Widget] which covers the parent's edge where the progress + /// indicator would otherwise appear. + /// + /// By default, the edge offset is set to 0. + /// + /// See also: + /// + /// * [displacement], can be used to change the distance from the edge that + /// the indicator settles. + final double edgeOffset; + + /// A function that's called when the user has dragged the refresh indicator + /// far enough to demonstrate that they want the app to refresh. The returned + /// [Future] must complete when the refresh operation is finished. + final LoadingRefreshCallback onRefresh; + + /// The progress indicator's foreground color. The current theme's + /// [ColorScheme.primary] by default. + final Color? color; + + /// The progress indicator's background color. The current theme's + /// [ThemeData.canvasColor] by default. + final Color? backgroundColor; + + /// A check that specifies whether a [ScrollNotification] should be + /// handled by this widget. + /// + /// By default, checks whether `notification.depth == 0`. Set it to something + /// else for more complicated layouts. + final ScrollNotificationPredicate notificationPredicate; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsLabel} + /// + /// This will be defaulted to [MaterialLocalizations.refreshIndicatorSemanticLabel] + /// if it is null. + final String? semanticsLabel; + + /// {@macro flutter.progress_indicator.ProgressIndicator.semanticsValue} + final String? semanticsValue; + + /// Defines [strokeWidth] for `RefreshIndicator`. + /// + /// By default, the value of [strokeWidth] is 2.0 pixels. + final double strokeWidth; + + /// Defines how this [RefreshIndicator] can be triggered when users overscroll. + /// + /// The [RefreshIndicator] can be pulled out in two cases, + /// 1, Keep dragging if the scrollable widget at the edge with zero scroll position + /// when the drag starts. + /// 2, Keep dragging after overscroll occurs if the scrollable widget has + /// a non-zero scroll position when the drag starts. + /// + /// If this is [RefreshIndicatorTriggerMode.anywhere], both of the cases above can be triggered. + /// + /// If this is [RefreshIndicatorTriggerMode.onEdge], only case 1 can be triggered. + /// + /// Defaults to [RefreshIndicatorTriggerMode.onEdge]. + final RefreshIndicatorTriggerMode triggerMode; + + @override + State createState() => _LoadingRefreshIndicatorState(); +} + +class _LoadingRefreshIndicatorState extends State { + final refreshIndicatorKey = GlobalKey(); + + Completer? completer; + late bool isLoading; + + @override + void initState() { + if (widget.isLoading) { + WidgetsBinding.instance.addPostFrameCallback((_) { + show(); + }); + } + + super.initState(); + } + + @override + void didUpdateWidget(covariant LoadingRefreshIndicator oldWidget) { + if (oldWidget.isLoading == widget.isLoading) { + return; + } + + if (widget.isLoading) { + show(); + } else { + hide(); + } + + super.didUpdateWidget(oldWidget); + } + + void show() { + isLoading = true; + unawaited( + refreshIndicatorKey.currentState!.show(atTop: widget.atTop), + ); + } + + void hide() { + completer?.complete(); + completer = null; + isLoading = false; + } + + Future onRefresh() async { + if (isLoading) { + isLoading = false; + } else { + widget.onRefresh(); + } + + completer = Completer(); + await completer!.future; + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator.adaptive( + key: refreshIndicatorKey, + displacement: widget.displacement, + edgeOffset: widget.edgeOffset, + onRefresh: onRefresh, + color: widget.color, + backgroundColor: widget.backgroundColor, + notificationPredicate: widget.notificationPredicate, + semanticsLabel: widget.semanticsLabel, + semanticsValue: widget.semanticsValue, + strokeWidth: widget.strokeWidth, + triggerMode: widget.triggerMode, + child: widget.child, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart new file mode 100644 index 00000000000..2744cbaff80 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/recipe_image.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:nextcloud/cookbook.dart' as cookbook; +import 'package:vector_graphics/vector_graphics.dart'; + +/// Displays the image for a given recipe. +class RecipeImage extends StatelessWidget { + /// Creates a new recipe image. + const RecipeImage({ + required this.recipeID, + this.size, + super.key, + }); + + /// The id of the recipe to display the image for. + final String recipeID; + + /// The size of the recipe image to fetch. + /// + /// Uses [cookbook.GetImageSize] for the sizes and falls back to the full + /// resolution if hone is specified. + final Size? size; + + @override + Widget build(BuildContext context) { + final sizeParam = switch (size?.longestSide) { + != null && <= 16 => cookbook.GetImageSize.thumb16, + != null && <= 250 => cookbook.GetImageSize.thumb, + _ => cookbook.GetImageSize.full, + }; + + return NeonApiImage( + key: Key('recipe-image-$recipeID-$sizeParam'), + getRequest: (client) { + return client.cookbook.recipes.$getImage_Request(id: recipeID, size: sizeParam); + }, + etag: null, + expires: null, + account: context.read(), + errorBuilder: (context, error) { + return VectorGraphic( + key: Key('recipe-image-fallback-$sizeParam'), + width: size?.width, + height: size?.height, + loader: const AssetBytesLoader( + 'assets/app.svg.vec', + packageName: 'neon_cookbook', + ), + ); + }, + fit: BoxFit.fill, + size: size, + ); + } +} diff --git a/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart new file mode 100644 index 00000000000..f8d0fd97e87 --- /dev/null +++ b/packages/neon_framework/packages/cookbook_app/lib/src/widgets/widgets.dart @@ -0,0 +1,2 @@ +export 'loading_refresh_indicator.dart'; +export 'recipe_image.dart'; diff --git a/packages/neon_framework/packages/cookbook_app/pubspec.yaml b/packages/neon_framework/packages/cookbook_app/pubspec.yaml index 0ee6acd7944..e93a73c0231 100644 --- a/packages/neon_framework/packages/cookbook_app/pubspec.yaml +++ b/packages/neon_framework/packages/cookbook_app/pubspec.yaml @@ -7,7 +7,12 @@ environment: flutter: ^3.22.0 dependencies: + bloc: ^8.0.0 built_collection: ^5.0.0 + cookbook_recipe_repository: + git: + url: https://github.com/nextcloud/neon + path: packages/neon_framework/packages/cookbook_recipe_repository equatable: ^2.0.0 flutter: sdk: flutter @@ -22,7 +27,8 @@ dependencies: git: url: https://github.com/nextcloud/neon path: packages/neon_framework - nextcloud: ^6.1.0 + nextcloud: ^7.0.0 + vector_graphics: ^1.0.0 dev_dependencies: build_runner: ^2.4.11 diff --git a/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml index 0abe46d2bdd..01898d83867 100644 --- a/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml +++ b/packages/neon_framework/packages/cookbook_app/pubspec_overrides.yaml @@ -1,7 +1,9 @@ -# melos_managed_dependency_overrides: account_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: account_repository,cookbook_recipe_repository,cookie_store,dynamite_runtime,interceptor_http_client,neon_framework,neon_http_client,neon_lints,nextcloud,sort_box dependency_overrides: account_repository: path: ../account_repository + cookbook_recipe_repository: + path: ../cookbook_recipe_repository cookie_store: path: ../../../cookie_store dynamite_runtime: