Compare commits

...

100 Commits

Author SHA1 Message Date
misha
cbeb91a9f0 fix: analyzer issue 2025-11-28 01:00:56 +03:00
misha
9ca09e8137 Remove unwanted file 2025-11-28 00:49:38 +03:00
misha
8917f54e7b feat(tests): fixed feed tests failing and added tests for my_diets, my_plans and recipes 2025-11-28 00:46:25 +03:00
misha
626f74d20d Merge remote-tracking branch 'upstream/main' into issue-791 2025-11-27 23:50:39 +03:00
misha
c6c42b0525 feat(test): repostory, cubit and widget tests for feed 2025-11-25 11:13:37 +03:00
misha
b83d91e7f2 feat(tests): feed service tests 2025-11-20 09:54:12 +03:00
e5029f581d Merge pull request 'Updated app version' (#780) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#780
2025-11-19 13:57:51 +00:00
misha
c69fe7f0a6 Updated app version 2025-11-19 16:57:17 +03:00
fc0e9b1063 Merge pull request 'Update proj version' (#778) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#778
2025-11-19 13:48:20 +00:00
misha
453f119317 Merge remote-tracking branch 'upstream/main' 2025-11-19 16:47:43 +03:00
misha
6d0bee4da8 set build type to prod 2025-11-19 16:46:18 +03:00
misha
04d4be38d4 Update proj version 2025-11-19 16:45:08 +03:00
fa22cf5d36 Merge pull request 'feat(personalDiets)' (#760) from misha/eatvio_app:issue-756 into main
Reviewed-on: eatvio/eatvio_app#760
2025-11-19 09:58:07 +00:00
91e8348015 Merge pull request 'feat(registration): fixed registration on android devices, feat(leaders): fixed diet creation loader' (#776) from misha/eatvio_app:issue-738 into main
Reviewed-on: eatvio/eatvio_app#776
2025-11-19 08:17:36 +00:00
misha
1b948b99e3 Merge remote-tracking branch 'upstream/main' into issue-738 2025-11-19 11:16:45 +03:00
misha
b6eac3c46f feat(registration): fixed registration on android devices, feat(leaders): fixed diet creation loader 2025-11-19 11:07:16 +03:00
mso7nueh
39775cd7a8 Merge pull request 'updated app version' (#774) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#774
2025-11-17 10:22:48 +00:00
Username1548
36e2f12e3f updated app version 2025-11-17 12:01:31 +03:00
mso7nueh
66d2533add Merge pull request 'fix: Issue-752' (#769) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#769
2025-11-14 10:34:42 +00:00
mso7nueh
425f16bfc9 fix(consultation): Issue-752 2025-11-13 19:17:28 +03:00
mso7nueh
c5e30d8f00 Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-11-13 18:59:48 +03:00
mso7nueh
d0935fbaae feat: upd flutter 2025-11-13 18:58:56 +03:00
dda0aa7440 Merge pull request 'fix: tests fix' (#768) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#768
2025-11-13 08:21:48 +00:00
misha
c81327b895 fix: tests fix 2025-11-13 11:21:02 +03:00
732d667ac1 Merge pull request 'improvements: added loaders' (#766) from misha/eatvio_app:issue-580 into main
Reviewed-on: eatvio/eatvio_app#766
2025-11-13 07:13:00 +00:00
misha
ba9f4a3584 improvements: added loaders 2025-11-13 10:12:11 +03:00
mso7nueh
f63d901a55 Merge pull request 'feat(ci/cd): add coverage test' (#764) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#764
2025-11-12 11:03:23 +00:00
mso7nueh
9148a4c668 feat(ci/cd): add coverage test 2025-11-12 13:34:55 +03:00
mso7nueh
a3f0aa3e67 Merge pull request 'feat(oauth): Issue-691+180' (#762) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#762
2025-11-11 14:40:09 +00:00
mso7nueh
8a390c2a9b Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-11-11 17:38:23 +03:00
mso7nueh
92b37e77b1 fix(socail,diets): Issue-180+691 2025-11-11 17:38:15 +03:00
101c1e15af Merge pull request 'feat(registration)' (#739) from misha/eatvio_app:issue-738 into main
Reviewed-on: eatvio/eatvio_app#739
2025-11-11 07:24:37 +00:00
mso7nueh
172c05ac69 merge upstream 2025-11-10 21:16:17 +00:00
misha
4eecc458f8 fix: formatting 2025-11-10 18:59:03 +03:00
misha
4b781adb79 feat(registration): added api integration for short registration 2025-11-10 18:55:55 +03:00
misha
dc400ef5b7 feat(personalDiets): fixed notification and chat banner 2025-11-10 13:50:06 +03:00
mso7nueh
d1160ba35f feat(oauth): Issue-691 2025-11-07 01:38:00 +03:00
mso7nueh
a46ac5bb4c Merge pull request 'feat(diary_test): Issue-755' (#759) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#759
2025-11-06 19:05:03 +00:00
misha
992cb38cdd feat(personalDiets): fixed ui bugs and added new notification type for personal diet 2025-11-06 08:00:30 +03:00
mso7nueh
2a06a5476a feat(diary_test): Issue-755 2025-11-06 00:38:08 +03:00
mso7nueh
f3853b58d9 Merge pull request 'fix: Issue-757' (#758) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#758
2025-11-05 21:17:36 +00:00
mso7nueh
bbc99ec9f4 fix(settings): Issue-757 2025-11-05 23:55:48 +03:00
ff946a5938 Merge pull request 'feat(personalDiets)' (#561) from misha/eatvio_app:personalDiets into main
Reviewed-on: eatvio/eatvio_app#561
2025-10-31 10:27:20 +00:00
misha
a72c40d8ee Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-31 13:26:05 +03:00
misha
6ed7faed15 feat(personalDiets): addde base url from app config for consultation widget 2025-10-31 13:23:13 +03:00
mso7nueh
12f51d49ea fix(profile): subs screen and blog post padding 2025-10-31 12:59:02 +03:00
misha
16fccaea8e Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-31 12:41:36 +03:00
Username1548
a723673d23 Merge branch 'main' of http://gitea.office.local:3000/eatvio/eatvio_app 2025-10-31 12:39:49 +03:00
Username1548
89f697f7a6 changed app version 2025-10-31 12:38:02 +03:00
d883c9e19a Merge pull request 'feat(marketing)' (#745) from misha/eatvio_app:issue-689 into main
Reviewed-on: eatvio/eatvio_app#745
2025-10-31 09:10:18 +00:00
misha
884830f232 Merge remote-tracking branch 'upstream/main' into issue-689 2025-10-31 12:08:02 +03:00
mso7nueh
7b297f954e Merge pull request 'Issue-746' (#747) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#747
2025-10-31 08:59:06 +00:00
mso7nueh
dbd92a65ea feat(md): Issue-746 2025-10-31 00:20:21 +03:00
mso7nueh
f3af743487 merge upstream 2025-10-30 19:27:36 +00:00
Username1548
c95f1988ae commit to create new build 2025-10-30 17:13:58 +03:00
mso7nueh
35a5e6d6be merge upstream 2025-10-30 09:13:28 +00:00
mso7nueh
977b7150fe feat(md): Issue-746 2025-10-29 17:27:05 +03:00
5b81014e72 Merge pull request 'feat(InfoByOrder)' (#750) from misha/eatvio_app:issue-681 into main
Reviewed-on: eatvio/eatvio_app#750
2025-10-29 01:39:05 +00:00
misha
563d84ca12 feat(InfoByOrder): added user info to InfoByOrderScreen author card 2025-10-29 04:38:04 +03:00
06cafb4257 Merge pull request 'feat(profile)' (#749) from misha/eatvio_app:issue-742 into main
Reviewed-on: eatvio/eatvio_app#749
2025-10-29 01:02:36 +00:00
misha
a34cb01c1f feat(profile): changed profile bages layout 2025-10-29 04:01:27 +03:00
mso7nueh
8799375880 feat(md): Issue-746 2025-10-27 17:07:20 +03:00
mso7nueh
fb5a0d52bc feat(feed): mdToHtml 2025-10-24 18:55:01 +03:00
misha
2e3172bc45 Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-24 13:21:03 +03:00
misha
41313f09af feat(marketing): added marketing bottom sheet 2025-10-23 17:25:12 +03:00
mso7nueh
ce0099c385 Merge pull request 'feat(tests): blog post create' (#744) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#744
2025-10-22 15:42:27 +00:00
mso7nueh
14e4d7973e feat(tests): blog post create 2025-10-22 18:42:01 +03:00
mso7nueh
a138707111 Merge pull request 'feat(test): diet, plan, registration' (#741) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#741
2025-10-22 13:48:38 +00:00
mso7nueh
4c7dd4a12a Merge branch 'main' of http://gitea.office.local:3000/mso7nueh/eatvio_app 2025-10-22 16:48:14 +03:00
mso7nueh
c2eda628dd WIP: feat(tests): blog post create 2025-10-22 16:48:08 +03:00
mso7nueh
53f2b78dfa Merge pull request 'feat(test): diet, plan, registration' (#741) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#741
2025-10-22 13:47:07 +00:00
misha
e147f6e182 feat(registration): finished layout for new registration screen 2025-10-22 15:27:02 +03:00
mso7nueh
ed612aa019 feat(test): diet, plan, registration 2025-10-21 21:02:17 +03:00
mso7nueh
1f4c450ce7 Merge pull request 'feat(tests): recipe create, fix oauth' (#737) from mso7nueh/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#737
2025-10-21 11:38:31 +00:00
mso7nueh
1841199291 fix: analyzer 2025-10-21 14:07:12 +03:00
mso7nueh
4c5532ff32 fix: upd ver 2025-10-21 14:00:40 +03:00
mso7nueh
c1cbf1658b fix(oauth): sex=null 2025-10-21 13:59:19 +03:00
misha
2440204294 feat(registration): added base layout for new registration page, and a bit of state managment 2025-10-20 17:55:56 +03:00
mso7nueh
e7ab43c845 feat(tests): recipe create widget test 2025-10-20 12:04:53 +03:00
mso7nueh
b9136a80de feat(tests); recipe create 2025-10-17 17:11:27 +03:00
bb68ea963c Merge pull request 'feat(tests)' (#736) from misha/eatvio_app:tests into main
Reviewed-on: eatvio/eatvio_app#736
2025-10-17 13:14:20 +00:00
misha
228ea7c902 feat(tests): added sign in skip 2025-10-17 16:13:34 +03:00
8c85d1837a Merge pull request 'feat(deepLinks)' (#692) from misha/eatvio_app:deepLinks into main
Reviewed-on: eatvio/eatvio_app#692
2025-10-17 13:04:11 +00:00
misha
1d8f414672 Merge remote-tracking branch 'upstream/main' into deepLinks 2025-10-17 16:03:35 +03:00
49453c8b31 Merge pull request 'feat(pushNotifications)' (#708) from misha/eatvio_app:issue-707 into main
Reviewed-on: eatvio/eatvio_app#708
2025-10-17 12:25:36 +00:00
misha
46841fb502 feat(personalDiets): fixed PlanState widget in order to show personal diet state 2025-10-02 13:49:58 +03:00
misha
8e72350152 Merge remote-tracking branch 'upstream/main' into personalDiets 2025-10-02 12:25:40 +03:00
misha
1bc0ab3237 fixed profile back button 2025-09-24 10:01:06 +03:00
Username1548
6b7b1f2d18 feat(deepLinks): added diary deep link and improved profile and chat deep links 2025-09-10 15:19:49 +03:00
Username1548
82bc51e45c feat(deepLinks): added chat and profile deeplinks 2025-09-10 08:08:58 +03:00
Username1548
77666aca0e Merge remote-tracking branch 'upstream/main' into deepLinks 2025-09-09 21:24:36 +03:00
Username1548
16430af989 gitignore 2025-09-09 21:24:05 +03:00
Username1548
8c2cd5eedd Merge remote-tracking branch 'upstream/main' into personalDiets 2025-09-05 11:28:51 +03:00
Username1548
996f2ecd70 Merge branch 'main' into personalDiets 2025-07-16 17:04:21 +07:00
Username1548
19787f459e feat(personalDiet): fixed wrong path for attach diet method 2025-07-16 17:03:46 +07:00
Username1548
7e64b24a8c added layout for suggestions page 2025-07-15 21:49:47 +07:00
Username1548
a07d085ba7 feat(personalDiets): preapared chats for consultations with personal diets 2025-07-14 20:24:40 +07:00
Username1548
5616bedd25 feat(personalDiets): diets suggestions 2025-07-14 13:44:44 +07:00
Username1548
0a3d9e577d Merge branch 'main' into personalDiets 2025-07-14 12:17:37 +07:00
Username1548
55e3abec99 feat(personal diets): added personal diet option to plans 2025-07-14 12:15:26 +07:00
174 changed files with 21015 additions and 2204 deletions

View File

@@ -140,7 +140,21 @@ jobs:
run: flutter analyze
- name: Test
run: flutter test
run: flutter test --coverage
- name: Test coverage
run: |
awk -F: '
/^LF:/ { total += $2 }
/^LH:/ { covered += $2 }
END {
if (total == 0) {
print "Coverage: 0%"
} else {
printf "Coverage: %.2f%%\n", (covered / total) * 100
}
}
' coverage/lcov.info
- name: Build apk
run: |

View File

@@ -4,4 +4,7 @@ run-tests:
run-analyse:
flutter analyze
run-checks: run-analyse run-tests
run-checks: run-analyse run-tests
build-generated-files:
dart run build_runner build --delete-conflicting-outputs

View File

@@ -19,15 +19,15 @@ def yandexClientId = project.hasProperty('YANDEX_CLIENT_ID') ? project.property(
android {
namespace = "com.eatvioapp.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {

View File

@@ -63,6 +63,18 @@
<data android:pathPattern="/ru/plan/.*" />
<data android:pathPattern="/en/plan/.*" />
<data android:pathPattern="/ru/chat/.*" />
<data android:pathPattern="/en/chat/.*" />
<data android:pathPattern="/ru/chat" />
<data android:pathPattern="/en/chat" />
<data android:pathPattern="/ru/profile/.*" />
<data android:pathPattern="/en/profile/.*" />
<data android:pathPattern="/ru/diary/.*" />
<data android:pathPattern="/en/diary/.*" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.

3
assets/icons/check.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="12" height="9" viewBox="0 0 12 9" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16229 6.8625L10.5185 0.50625C10.6685 0.35625 10.8435 0.28125 11.0435 0.28125C11.2435 0.28125 11.4185 0.35625 11.5685 0.50625C11.7185 0.65625 11.7935 0.834375 11.7935 1.04063C11.7935 1.24688 11.7185 1.425 11.5685 1.575L4.68729 8.475C4.53729 8.625 4.36229 8.7 4.16229 8.7C3.96229 8.7 3.78729 8.625 3.63729 8.475L0.412289 5.25C0.262289 5.1 0.190414 4.92188 0.196664 4.71563C0.202914 4.50938 0.281039 4.33125 0.431039 4.18125C0.581039 4.03125 0.759164 3.95625 0.965414 3.95625C1.17166 3.95625 1.34979 4.03125 1.49979 4.18125L4.16229 6.8625Z" fill="#16A249"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

View File

@@ -0,0 +1,11 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.2" width="20" height="20" rx="6" fill="#16A249"/>
<g clip-path="url(#clip0_1823_10218)">
<path d="M8.16229 12.8625L14.5185 6.50625C14.6685 6.35625 14.8435 6.28125 15.0435 6.28125C15.2435 6.28125 15.4185 6.35625 15.5685 6.50625C15.7185 6.65625 15.7935 6.83438 15.7935 7.04063C15.7935 7.24688 15.7185 7.425 15.5685 7.575L8.68729 14.475C8.53729 14.625 8.36229 14.7 8.16229 14.7C7.96229 14.7 7.78729 14.625 7.63729 14.475L4.41229 11.25C4.26229 11.1 4.19041 10.9219 4.19666 10.7156C4.20291 10.5094 4.28104 10.3313 4.43104 10.1813C4.58104 10.0313 4.75916 9.95625 4.96541 9.95625C5.17166 9.95625 5.34979 10.0313 5.49979 10.1813L8.16229 12.8625Z" fill="#16A249"/>
</g>
<defs>
<clipPath id="clip0_1823_10218">
<rect width="12" height="9" fill="white" transform="translate(4 6)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@@ -0,0 +1,12 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1846_342)">
<path d="M5.33333 16C3.85185 16 2.59259 15.4815 1.55556 14.4444C0.518518 13.4074 0 12.1481 0 10.6667V5.33333C0 3.85185 0.518518 2.59259 1.55556 1.55556C2.59259 0.518518 3.85185 0 5.33333 0H10.6667C12.1481 0 13.4074 0.518518 14.4444 1.55556C15.4815 2.59259 16 3.85185 16 5.33333V10.6667C16 12.1481 15.4815 13.4074 14.4444 14.4444C13.4074 15.4815 12.1481 16 10.6667 16H5.33333ZM5.33333 14.2222H10.6667C11.6444 14.2222 12.4815 13.8741 13.1778 13.1778C13.8741 12.4815 14.2222 11.6444 14.2222 10.6667V5.33333C14.2222 4.35556 13.8741 3.51852 13.1778 2.82222C12.4815 2.12593 11.6444 1.77778 10.6667 1.77778H5.33333C4.35556 1.77778 3.51852 2.12593 2.82222 2.82222C2.12593 3.51852 1.77778 4.35556 1.77778 5.33333V10.6667C1.77778 11.6444 2.12593 12.4815 2.82222 13.1778C3.51852 13.8741 4.35556 14.2222 5.33333 14.2222Z" fill="#D22121"/>
<rect x="4.81836" y="10.9749" width="8" height="1" rx="0.5" transform="rotate(-45 4.81836 10.9749)" fill="#D22121"/>
<rect x="5.52539" y="5.31799" width="8" height="1" rx="0.5" transform="rotate(45 5.52539 5.31799)" fill="#D22121"/>
</g>
<defs>
<clipPath id="clip0_1846_342">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

9
assets/icons/logo_2.svg Normal file
View File

@@ -0,0 +1,9 @@
<svg width="89" height="90" viewBox="0 0 89 90" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.0119 0C65.9947 0 83 9.05026 83 29.6764C83 50.2792 65.9947 67 45.0119 67C24.0053 67 7 50.2792 7 29.6764C7 9.05026 24.0053 0 45.0119 0Z" fill="#43A6F2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.8844 43.5162C39.8585 43.5162 34.9274 42.5623 30.3282 40.7209C25.7291 38.8573 21.5803 36.1284 18.1428 32.7119C16.5544 31.1589 16.6493 28.6741 18.3087 27.2098C19.9919 25.7234 22.6234 25.8122 24.2118 27.3651C26.867 30.0052 30.0675 32.1129 33.6235 33.5327C37.1796 34.9748 40.9964 35.7069 44.8844 35.7069C48.7486 35.7069 52.5655 34.9748 56.1215 33.5327C59.6776 32.1129 62.878 30.0052 65.5332 27.3651C67.1216 25.8122 69.7531 25.7234 71.4363 27.2098C73.0958 28.6741 73.1906 31.1589 71.6022 32.7119C68.1647 36.1284 64.016 38.8573 59.4168 40.7209C54.8176 42.5623 49.8866 43.5162 44.8844 43.5162Z" fill="white"/>
<path d="M9.69721 80.8957L3.51318 88.877C2.93793 89.6257 2.28049 90 1.54087 90C0.513623 90 0 89.3382 0 88.0147V77.3663C0 75.7888 0.56841 75 1.70523 75C2.84205 75 3.41046 75.7888 3.41046 77.3663V84.2246L9.71775 76.0829C10.2793 75.361 10.882 75 11.5257 75C12.5803 75 13.1077 75.7487 13.1077 77.246V87.6337C13.1077 89.2112 12.5393 90 11.4024 90C10.2656 90 9.69721 89.2112 9.69721 87.6337V80.8957Z" fill="#323F4B"/>
<path d="M23.0017 78.2888C21.769 78.2888 21.0019 78.2553 20.7006 78.1885C19.7829 78.0013 19.3241 77.5201 19.3241 76.7447C19.3241 75.7019 20.1322 75.1805 21.7484 75.1805H27.6859C29.3021 75.1805 30.1102 75.7019 30.1102 76.7447C30.1102 77.5201 29.6514 78.0013 28.7337 78.1885C28.4324 78.2553 27.6654 78.2888 26.4327 78.2888V87.6337C26.4327 89.2112 25.8642 90 24.7274 90C23.5769 90 23.0017 89.2112 23.0017 87.6337V78.2888Z" fill="#323F4B"/>
<path d="M38.6564 89.8195C37.7935 89.8195 37.1909 89.6791 36.8485 89.3984C36.506 89.1043 36.3348 88.5428 36.3348 87.7139V77.5267C36.3348 76.6578 36.506 76.0495 36.8485 75.7019C37.1909 75.3543 37.8072 75.1805 38.6975 75.1805H41.4916C42.8476 75.1805 43.9981 75.4545 44.9432 76.0027C46.0937 76.6578 46.6689 77.6003 46.6689 78.8302C46.6689 80.4211 46.08 81.4973 44.9021 82.0588V82.119C45.7787 82.3864 46.4087 82.7807 46.7922 83.3021C47.1757 83.8102 47.3675 84.512 47.3675 85.4078C47.3675 86.8917 46.8401 88.0214 45.7855 88.7968C44.8541 89.4786 43.6009 89.8195 42.0258 89.8195H38.6564ZM39.7453 83.5027V86.9519H41.4505C42.1764 86.9519 42.7175 86.8583 43.0736 86.6711C43.5393 86.4171 43.7721 85.9626 43.7721 85.3075C43.7721 84.5455 43.5461 84.0374 43.0941 83.7834C42.7654 83.5963 42.197 83.5027 41.3889 83.5027H39.7453ZM39.7453 78.0281V80.8957H41.2245C41.8957 80.8957 42.3682 80.8155 42.6421 80.6551C43.0256 80.4412 43.2174 80.0401 43.2174 79.4519C43.2174 78.5027 42.5531 78.0281 41.2245 78.0281H39.7453Z" fill="#323F4B"/>
<path d="M64.1493 80.8957L57.9653 88.877C57.39 89.6257 56.7326 90 55.993 90C54.9657 90 54.4521 89.3382 54.4521 88.0147V77.3663C54.4521 75.7888 55.0205 75 56.1573 75C57.2941 75 57.8626 75.7888 57.8626 77.3663V84.2246L64.1698 76.0829C64.7314 75.361 65.3341 75 65.9778 75C67.0324 75 67.5598 75.7487 67.5598 77.246V87.6337C67.5598 89.2112 66.9913 90 65.8545 90C64.7177 90 64.1493 89.2112 64.1493 87.6337V80.8957Z" fill="#323F4B"/>
<path d="M74.6391 82.5201C74.6391 80.3275 75.2691 78.5428 76.5292 77.1658C77.8304 75.7219 79.5904 75 81.8093 75C84.0418 75 85.8087 75.7152 87.1099 77.1457C88.37 78.5227 89 80.3142 89 82.5201C89 84.7126 88.37 86.4973 87.1099 87.8743C85.795 89.2914 84.0281 90 81.8093 90C79.5904 90 77.8304 89.2848 76.5292 87.8543C75.2691 86.4906 74.6391 84.7126 74.6391 82.5201ZM78.255 82.5602C78.255 83.7634 78.5358 84.7594 79.0973 85.5481C79.7274 86.4706 80.6314 86.9318 81.8093 86.9318C82.9872 86.9318 83.898 86.4773 84.5417 85.5682C85.1033 84.766 85.3841 83.7634 85.3841 82.5602C85.3841 81.3168 85.117 80.2941 84.5828 79.492C83.9391 78.5428 83.0146 78.0682 81.8093 78.0682C80.604 78.0682 79.6863 78.5428 79.0563 79.492C78.5221 80.2941 78.255 81.3168 78.255 82.5602Z" fill="#323F4B"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

3
assets/icons/sucess.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33333 16C3.85185 16 2.59259 15.4815 1.55556 14.4444C0.518519 13.4074 0 12.1481 0 10.6667V5.33333C0 3.85185 0.518519 2.59259 1.55556 1.55556C2.59259 0.518519 3.85185 0 5.33333 0H10.6667C12.1481 0 13.4074 0.518519 14.4444 1.55556C15.4815 2.59259 16 3.85185 16 5.33333V10.6667C16 12.1481 15.4815 13.4074 14.4444 14.4444C13.4074 15.4815 12.1481 16 10.6667 16H5.33333ZM7.11111 9.06667L5.77778 7.73333C5.61481 7.57037 5.40741 7.48889 5.15556 7.48889C4.9037 7.48889 4.6963 7.57037 4.53333 7.73333C4.37037 7.8963 4.28889 8.1037 4.28889 8.35556C4.28889 8.60741 4.37037 8.81481 4.53333 8.97778L6.48889 10.9333C6.66667 11.1111 6.87407 11.2 7.11111 11.2C7.34815 11.2 7.55556 11.1111 7.73333 10.9333L11.8222 6.84444C11.9852 6.68148 12.0667 6.47407 12.0667 6.22222C12.0667 5.97037 11.9852 5.76296 11.8222 5.6C11.6593 5.43704 11.4519 5.35556 11.2 5.35556C10.9481 5.35556 10.7407 5.43704 10.5778 5.6L7.11111 9.06667ZM5.33333 14.2222H10.6667C11.6444 14.2222 12.4815 13.8741 13.1778 13.1778C13.8741 12.4815 14.2222 11.6444 14.2222 10.6667V5.33333C14.2222 4.35556 13.8741 3.51852 13.1778 2.82222C12.4815 2.12593 11.6444 1.77778 10.6667 1.77778H5.33333C4.35556 1.77778 3.51852 2.12593 2.82222 2.82222C2.12593 3.51852 1.77778 4.35556 1.77778 5.33333V10.6667C1.77778 11.6444 2.12593 12.4815 2.82222 13.1778C3.51852 13.8741 4.35556 14.2222 5.33333 14.2222Z" fill="#16A249"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
assets/images/logo_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -1,5 +1,7 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
import 'package:eatvio_app/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -12,7 +14,7 @@ void main() {
await AppInitializer.initialize(buildType: AppBuildType.tests);
});
testWidgets('Signs in successfully with valid credentials', (WidgetTester tester) async {
testWidgets('Signs in successfully with valid credentials', (WidgetTester tester) async {
await tester.pumpWidget(const app.MyApp());
await tester.pumpAndSettle(const Duration(seconds: 3));
await skipWelcomeScreen(tester);
@@ -24,14 +26,14 @@ void main() {
expect(passwordFieldFinder, findsOneWidget, reason: 'Password text field should be found.');
expect(signInButtonFinder, findsOneWidget, reason: 'Sign In button should be found.');
await tester.enterText(emailFieldFinder, 'reshetnick.mike+user4@gmail.com');
await tester.enterText(passwordFieldFinder, '1234567890');
await tester.enterText(emailFieldFinder, EnvConfig.testEmail);
await tester.enterText(passwordFieldFinder, EnvConfig.testPassword);
await tester.pumpAndSettle();
await tester.tap(signInButtonFinder);
await tester.pumpAndSettle(const Duration(seconds: 3));
expect(signInButtonFinder, findsNothing, reason: 'Sign-in button should not be visible after successful login and navigation.');
expect(FeedScreen, findsOne, reason: 'Feed screen should be visible after successful login and navigation.');
});
}

View File

@@ -0,0 +1,21 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:flutter_test/flutter_test.dart';
Future<void> skipSignIn(WidgetTester tester) async {
final emailFieldFinder = find.byKey(AppKeys.emailTextFieldAuthScreen);
final passwordFieldFinder = find.byKey(AppKeys.passwordTextFieldAuthScreen);
final signInButtonFinder = find.byKey(AppKeys.signInButtonAuthScreen);
expect(emailFieldFinder, findsOneWidget, reason: 'Email text field should be found.');
expect(passwordFieldFinder, findsOneWidget, reason: 'Password text field should be found.');
expect(signInButtonFinder, findsOneWidget, reason: 'Sign In button should be found.');
await tester.enterText(emailFieldFinder, EnvConfig.testEmail);
await tester.enterText(passwordFieldFinder, EnvConfig.testPassword);
await tester.pumpAndSettle();
await tester.tap(signInButtonFinder);
await tester.pumpAndSettle(const Duration(seconds: 3));
}

View File

@@ -5,4 +5,4 @@ Future<void> skipWelcomeScreen(WidgetTester tester) async {
final welcomeScreenButtonFinder = find.byKey(AppKeys.welcomeScreenButton);
await tester.tap(welcomeScreenButtonFinder);
await tester.pumpAndSettle();
}
}

View File

@@ -0,0 +1,147 @@
# Интеграционные тесты создания рецепта
Этот набор интеграционных тестов покрывает полный пользовательский путь создания нового рецепта в приложении Eatvio.
## Структура файлов
- `create_recipe_test.dart` - Основные интеграционные тесты
- `create_recipe_helpers.dart` - Helper функции для тестирования
- `README.md` - Документация
## Тестовые сценарии
### 1. Полное создание рецепта
**Файл:** `create_recipe_test.dart` - `should create and publish a new recipe successfully`
**Описание:** Тестирует полный пользовательский путь создания рецепта с заполнением всех полей.
**Шаги:**
1. Запуск приложения и вход в систему
2. Навигация к экрану создания рецепта
3. Заполнение основной информации (название, тип, порции, калории, время)
4. Добавление тегов
5. Добавление ингредиентов
6. Добавление шагов приготовления
7. Симуляция добавления изображения
8. Публикация рецепта
9. Проверка успешного перехода
### 2. Валидация полей
**Файл:** `create_recipe_test.dart` - `should validate required fields before publishing`
**Описание:** Проверяет, что валидация обязательных полей работает корректно.
**Шаги:**
1. Запуск приложения и вход в систему
2. Навигация к экрану создания рецепта
3. Попытка публикации без заполнения полей
4. Проверка, что остались на экране создания (валидация не прошла)
### 3. Минимальное создание рецепта
**Файл:** `create_recipe_test.dart` - `should create recipe with minimal required fields`
**Описание:** Тестирует создание рецепта с минимально необходимыми полями.
**Шаги:**
1. Запуск приложения и вход в систему
2. Навигация к экрану создания рецепта
3. Заполнение только обязательных полей
4. Добавление минимума ингредиентов
5. Симуляция добавления изображения
6. Публикация рецепта
7. Проверка успешного перехода
## Helper функции
### Навигация
- `navigateToCreateRecipeScreen()` - Переход к экрану создания рецепта
### Заполнение формы
- `fillRecipeBasicInfo()` - Заполнение основной информации о рецепте
- `selectRecipeType()` - Выбор типа рецепта
- `addTags()` - Добавление тегов
- `addIngredients()` - Добавление ингредиентов
- `addCookingSteps()` - Добавление шагов приготовления
### Публикация и проверки
- `publishRecipe()` - Публикация рецепта
- `verifyRecipePublished()` - Проверка успешной публикации
- `verifyValidationErrors()` - Проверка ошибок валидации
### Симуляция изображений
- `mockImageSelection()` - Симуляция выбора изображения для рецепта
- `mockStepImageSelection()` - Симуляция выбора изображения для шага
### Тестовые данные
- `createTestRecipeData()` - Создание набора тестовых данных
## Ключи для тестирования
В файле `lib/app_keys.dart` добавлены следующие ключи для стабильного тестирования:
### Основные поля
- `recipeCreateScreenTitleField` - Поле названия
- `recipeCreateScreenDescriptionField` - Поле описания
- `recipeCreateScreenTypeDropdown` - Выпадающий список типа рецепта
- `recipeCreateScreenPortionsField` - Поле количества порций
- `recipeCreateScreenCaloriesField` - Поле калорийности
### Время приготовления
- `recipeCreateScreenTotalHoursField` - Часы общего времени
- `recipeCreateScreenTotalMinutesField` - Минуты общего времени
- `recipeCreateScreenActiveHoursField` - Часы активного времени
- `recipeCreateScreenActiveMinutesField` - Минуты активного времени
### Теги и ингредиенты
- `recipeCreateScreenTagField` - Поле для ввода тега
- `recipeCreateScreenAddTagButton` - Кнопка добавления тега
- `recipeCreateScreenAddIngredientButton` - Кнопка добавления ингредиента
### Шаги приготовления
- `recipeCreateScreenAddStepButton` - Кнопка добавления шага
- `recipeCreateScreenStepDescriptionField` - Поле описания шага
- `recipeCreateScreenStepImageWidget` - Виджет изображения шага
### Изображения и публикация
- `recipeCreateScreenImageWidget` - Виджет основного изображения
- `recipeCreateScreenPublishButton` - Кнопка публикации
## Запуск тестов
```bash
# Запуск всех интеграционных тестов
flutter test integration_test/
# Запуск только тестов создания рецепта
flutter test integration_test/create_recipe/
# Запуск конкретного теста
flutter test integration_test/create_recipe/create_recipe_test.dart
```
## Требования
- Flutter SDK
- Настроенная тестовая среда (AppBuildType.tests)
- Тестовые учетные данные в .env файле
- Доступ к API бэкенда для тестирования
## Примечания
1. **Изображения**: В текущей реализации используется симуляция выбора изображений. Для полноценного тестирования рекомендуется добавить моки для `image_picker`.
2. **API взаимодействие**: Тесты взаимодействуют с реальным API бэкенда. Убедитесь, что тестовая среда настроена корректно.
3. **Время выполнения**: Тесты могут занимать значительное время из-за анимаций и API запросов. Используйте `pumpAndSettle()` с достаточными таймаутами.
4. **Стабильность**: Ключи для тестирования добавлены в UI компоненты для обеспечения стабильности тестов при изменениях в интерфейсе.
## Расширение тестов
Для добавления новых тестовых сценариев:
1. Создайте новый `testWidgets` в `create_recipe_test.dart`
2. Используйте существующие helper функции или создайте новые в `create_recipe_helpers.dart`
3. При необходимости добавьте новые ключи в `app_keys.dart`
4. Обновите документацию в этом файле

View File

@@ -0,0 +1,237 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// Helper функции для интеграционных тестов создания рецепта
/// Навигация к экрану создания рецепта
Future<void> navigateToCreateRecipeScreen(WidgetTester tester) async {
// Переключаемся на вкладку "Рецепты" если она есть
final recipesTab = find.text('Рецепты');
if (recipesTab.evaluate().isNotEmpty) {
await tester.tap(recipesTab);
await tester.pumpAndSettle();
}
// Находим кнопку добавления рецепта (круглая кнопка с плюсом)
final addRecipeButton = find.byType(InkWell).first;
await tester.tap(addRecipeButton);
await tester.pumpAndSettle(const Duration(seconds: 2));
}
/// Заполняет основную информацию о рецепте
Future<void> fillRecipeBasicInfo(
WidgetTester tester, {
String title = 'Интеграционный Тестовый Рецепт',
TypeOfRecipe type = TypeOfRecipe.salads,
int portions = 4,
double calories = 250.0,
int totalHours = 1,
int totalMinutes = 30,
int activeHours = 0,
int activeMinutes = 45,
String description = 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.',
}) async {
// Заполняем название рецепта
final titleField = find.byKey(AppKeys.recipeCreateScreenTitleField);
await tester.enterText(titleField, title);
await tester.pumpAndSettle();
// Выбираем тип рецепта
await selectRecipeType(tester, type);
// Заполняем количество порций
final portionsField = find.byKey(AppKeys.recipeCreateScreenPortionsField);
await tester.enterText(portionsField, portions.toString());
await tester.pumpAndSettle();
// Заполняем калорийность
final caloriesField = find.byKey(AppKeys.recipeCreateScreenCaloriesField);
await tester.enterText(caloriesField, calories.toString());
await tester.pumpAndSettle();
// Заполняем общее время приготовления
final totalHoursField = find.byKey(AppKeys.recipeCreateScreenTotalHoursField);
await tester.enterText(totalHoursField, totalHours.toString());
await tester.pumpAndSettle();
final totalMinutesField = find.byKey(AppKeys.recipeCreateScreenTotalMinutesField);
await tester.enterText(totalMinutesField, totalMinutes.toString());
await tester.pumpAndSettle();
// Заполняем активное время приготовления
final activeHoursField = find.byKey(AppKeys.recipeCreateScreenActiveHoursField);
await tester.enterText(activeHoursField, activeHours.toString());
await tester.pumpAndSettle();
final activeMinutesField = find.byKey(AppKeys.recipeCreateScreenActiveMinutesField);
await tester.enterText(activeMinutesField, activeMinutes.toString());
await tester.pumpAndSettle();
// Заполняем описание
final descriptionField = find.byKey(AppKeys.recipeCreateScreenDescriptionField);
await tester.enterText(descriptionField, description);
await tester.pumpAndSettle();
}
/// Выбирает тип рецепта из выпадающего списка
Future<void> selectRecipeType(WidgetTester tester, TypeOfRecipe type) async {
final typeDropdown = find.byKey(AppKeys.recipeCreateScreenTypeDropdown);
await tester.tap(typeDropdown);
await tester.pumpAndSettle();
// Выбираем нужный тип из выпадающего списка
final typeOption = find.text(type.toString());
await tester.tap(typeOption);
await tester.pumpAndSettle();
}
/// Добавляет теги к рецепту
Future<void> addTags(WidgetTester tester, List<String> tags) async {
final tagField = find.byKey(AppKeys.recipeCreateScreenTagField);
final addTagButton = find.byKey(AppKeys.recipeCreateScreenAddTagButton);
for (final tag in tags) {
await tester.enterText(tagField, tag);
await tester.pumpAndSettle();
await tester.tap(addTagButton);
await tester.pumpAndSettle();
}
}
/// Добавляет ингредиенты к рецепту
Future<void> addIngredients(WidgetTester tester, List<Map<String, String>> ingredients) async {
final addIngredientButton = find.byKey(AppKeys.recipeCreateScreenAddIngredientButton);
for (final ingredient in ingredients) {
await tester.ensureVisible(addIngredientButton);
await tester.tap(addIngredientButton);
await tester.pumpAndSettle();
await addIngredientInModal(tester, ingredient['name']!, ingredient['quantity']!);
await tester.pumpAndSettle();
}
}
/// Добавляет ингредиент в модальном окне
Future<void> addIngredientInModal(WidgetTester tester, String name, String quantity) async {
// Ищем поля в модальном окне
final nameFields = find.byType(TextFormField);
if (nameFields.evaluate().isNotEmpty) {
await tester.enterText(nameFields.first, name);
await tester.pumpAndSettle();
}
// Ищем поле количества (второе поле)
if (nameFields.evaluate().length > 1) {
await tester.enterText(nameFields.at(1), quantity);
await tester.pumpAndSettle();
}
// Ищем кнопку добавления в модальном окне
final addButton = find.text('Добавить');
if (addButton.evaluate().isNotEmpty) {
await tester.tap(addButton);
await tester.pumpAndSettle();
}
}
/// Добавляет шаги приготовления
Future<void> addCookingSteps(WidgetTester tester, List<Map<String, String>> steps) async {
final addStepButton = find.byKey(AppKeys.recipeCreateScreenAddStepButton);
for (int i = 0; i < steps.length; i++) {
final step = steps[i];
// Заполняем описание шага
final stepDescription = find.byKey(Key('${AppKeys.recipeCreateScreenStepDescriptionField}_$i'));
await tester.enterText(stepDescription, step['description']!);
await tester.pumpAndSettle();
// Симулируем добавление изображения к шагу если указано
if (step.containsKey('hasImage') && step['hasImage'] == 'true') {
await mockStepImageSelection(tester, i);
}
// Добавляем следующий шаг (кроме последнего)
if (i < steps.length - 1) {
await tester.ensureVisible(addStepButton);
await tester.tap(addStepButton);
await tester.pumpAndSettle();
}
}
}
/// Публикует рецепт
Future<void> publishRecipe(WidgetTester tester) async {
final publishButton = find.byKey(AppKeys.recipeCreateScreenPublishButton);
await tester.ensureVisible(publishButton);
await tester.tap(publishButton);
await tester.pumpAndSettle(const Duration(seconds: 5)); // Дать время на API-запрос
}
/// Симулирует выбор изображения для основного рецепта
Future<void> mockImageSelection(WidgetTester tester) async {
final imageWidget = find.byKey(AppKeys.recipeCreateScreenImageWidget);
if (imageWidget.evaluate().isNotEmpty) {
await tester.tap(imageWidget);
await tester.pumpAndSettle();
// В реальном интеграционном тесте здесь будет мок image_picker
// который возвращает тестовое изображение
}
}
/// Симулирует выбор изображения для шага приготовления
Future<void> mockStepImageSelection(WidgetTester tester, int stepIndex) async {
final stepImageWidget = find.byKey(Key('${AppKeys.recipeCreateScreenStepImageWidget}_$stepIndex'));
if (stepImageWidget.evaluate().isNotEmpty) {
await tester.tap(stepImageWidget);
await tester.pumpAndSettle();
// В реальном интеграционном тесте здесь будет мок image_picker
// который возвращает тестовое изображение
}
}
/// Проверяет, что рецепт был успешно опубликован
void verifyRecipePublished(WidgetTester tester) {
// После успешной публикации должен произойти переход обратно на экран ленты
expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsNothing);
// Можно добавить дополнительные проверки в зависимости от того, куда происходит переход
}
/// Проверяет, что валидация полей работает корректно
void verifyValidationErrors(WidgetTester tester) {
// Проверяем, что остались на экране создания рецепта (валидация не прошла)
expect(find.byType(RecipeCreateScreen), findsOneWidget);
expect(find.byKey(AppKeys.recipeCreateScreenPublishButton), findsOneWidget);
}
/// Создает тестовые данные для рецепта
Map<String, dynamic> createTestRecipeData() {
return {
'title': 'Интеграционный Тестовый Рецепт',
'type': TypeOfRecipe.salads,
'portions': 4,
'calories': 250.0,
'totalHours': 1,
'totalMinutes': 30,
'activeHours': 0,
'activeMinutes': 45,
'description': 'Этот рецепт создан для интеграционного тестирования. Он содержит все необходимые ингредиенты и пошаговую инструкцию приготовления.',
'tags': ['здоровое питание', 'быстро', 'легко'],
'ingredients': [
{'name': 'Помидоры', 'quantity': '200'},
{'name': 'Огурцы', 'quantity': '150'},
{'name': 'Лук', 'quantity': '50'},
],
'steps': [
{'description': 'Помойте и нарежьте помидоры и огурцы кубиками среднего размера.', 'hasImage': 'true'},
{'description': 'Нарежьте лук полукольцами и добавьте к овощам.', 'hasImage': 'false'},
{'description': 'Смешайте все ингредиенты в большой миске, добавьте соль и перец по вкусу.', 'hasImage': 'true'},
],
};
}

View File

@@ -0,0 +1,128 @@
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/features/create_recipe/entities/recipe_create_entity.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../common_cases/skip_welcome_screen.dart';
import '../common_cases/skip_sign_in.dart';
import 'create_recipe_helpers.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await AppInitializer.initialize(buildType: AppBuildType.tests);
});
group('Create Recipe Integration Tests', () {
testWidgets('should create and publish a new recipe successfully', (tester) async {
// 1. Запустить приложение
await tester.pumpWidget(const app.MyApp());
await tester.pumpAndSettle(const Duration(seconds: 20));
// 2. Пропустить онбординг и войти в систему
await skipWelcomeScreen(tester);
await skipSignIn(tester);
await tester.pumpAndSettle(const Duration(seconds: 3));
// 3. Перейти на экран создания рецепта
await navigateToCreateRecipeScreen(tester);
// Проверяем, что мы на экране создания рецепта
expect(find.byType(RecipeCreateScreen), findsOneWidget);
// 4. Заполнить все поля формы
final testData = createTestRecipeData();
await fillRecipeBasicInfo(
tester,
title: testData['title'] as String,
type: testData['type'] as TypeOfRecipe,
portions: testData['portions'] as int,
calories: testData['calories'] as double,
totalHours: testData['totalHours'] as int,
totalMinutes: testData['totalMinutes'] as int,
activeHours: testData['activeHours'] as int,
activeMinutes: testData['activeMinutes'] as int,
description: testData['description'] as String,
);
// 5. Добавить теги
await addTags(tester, testData['tags'] as List<String>);
// 6. Добавить ингредиенты
await addIngredients(tester, testData['ingredients'] as List<Map<String, String>>);
// 7. Добавить шаги приготовления
await addCookingSteps(tester, testData['steps'] as List<Map<String, String>>);
// 8. Симулировать добавление изображения
await mockImageSelection(tester);
// 9. Нажать "Опубликовать"
await publishRecipe(tester);
// 10. Проверить результат
verifyRecipePublished(tester);
});
testWidgets('should validate required fields before publishing', (tester) async {
// 1. Запустить приложение и войти в систему
await tester.pumpWidget(const app.MyApp());
await tester.pumpAndSettle(const Duration(seconds: 3));
await skipWelcomeScreen(tester);
await skipSignIn(tester);
await tester.pumpAndSettle(const Duration(seconds: 3));
// 2. Перейти на экран создания рецепта
await navigateToCreateRecipeScreen(tester);
// 3. Попытаться опубликовать без заполнения полей
await publishRecipe(tester);
// 4. Проверить, что валидация сработала
verifyValidationErrors(tester);
});
testWidgets('should create recipe with minimal required fields', (tester) async {
// 1. Запустить приложение и войти в систему
await tester.pumpWidget(const app.MyApp());
await tester.pumpAndSettle(const Duration(seconds: 3));
await skipWelcomeScreen(tester);
await skipSignIn(tester);
await tester.pumpAndSettle(const Duration(seconds: 3));
// 2. Перейти на экран создания рецепта
await navigateToCreateRecipeScreen(tester);
// 3. Заполнить только обязательные поля
await fillRecipeBasicInfo(
tester,
title: 'Минимальный тестовый рецепт',
type: TypeOfRecipe.firstMeals,
portions: 2,
calories: 300.0,
totalHours: 0,
totalMinutes: 30,
activeHours: 0,
activeMinutes: 15,
description: 'Простой рецепт для тестирования.',
);
// 4. Добавить минимум ингредиентов
await addIngredients(tester, [
{'name': 'Тестовый ингредиент', 'quantity': '100'},
]);
// 5. Симулировать добавление изображения
await mockImageSelection(tester);
// 6. Опубликовать рецепт
await publishRecipe(tester);
// 7. Проверить результат
verifyRecipePublished(tester);
});
});
}

View File

@@ -0,0 +1,31 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/main.dart' as app;
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../common_cases/skip_sign_in.dart';
import '../common_cases/skip_welcome_screen.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
await AppInitializer.initialize(buildType: AppBuildType.tests);
});
testWidgets('qwe', (WidgetTester tester) async {
await tester.pumpWidget(const app.MyApp());
await tester.pumpAndSettle(const Duration(seconds: 3));
await skipWelcomeScreen(tester);
await skipSignIn(tester);
final blogPostCreationFinder = find.byKey(AppKeys.feedScreenBlogPostCreationButton);
expect(blogPostCreationFinder, findsOneWidget, reason: 'Blog post creation button should be found.');
await tester.tap(blogPostCreationFinder);
await tester.pumpAndSettle();
});
}

View File

@@ -388,7 +388,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 80;
CURRENT_PROJECT_VERSION = 83;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -406,7 +406,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.2.3;
MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
@@ -539,7 +539,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 80;
CURRENT_PROJECT_VERSION = 83;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -557,7 +557,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.2.3;
MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
@@ -578,7 +578,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 80;
CURRENT_PROJECT_VERSION = 83;
DEVELOPMENT_TEAM = VL68C36HYS;
ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = (
@@ -596,7 +596,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.2.3;
MARKETING_VERSION = 1.3.3;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>80</string>
<string>83</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@@ -1,13 +1,44 @@
import 'package:flutter/material.dart';
class AppKeys {
class AppKeys {
// Auth Screen
static const hiddenLogoButtonAuthScreen = ValueKey('hiddenLogoButtonAuthScreen');
static const emailTextFieldAuthScreen = ValueKey('emailTextFieldAuthScreen');
static const passwordTextFieldAuthScreen = ValueKey('passwordTextFieldAuthScreen');
static const signInButtonAuthScreen = ValueKey('signInButtonAuthScreen');
// Welcome Screen
// Welcome Screen
static const welcomeScreenButton = ValueKey('welcomeScreenButton');
// Feed Screen
static const feedScreenBlogPostCreationButton = ValueKey('feedScreenBlogPostCreationButton');
// BlogPostCreateScreen
static const blogPostCreateScreenTitleTextField = ValueKey('blogPostCreateScreenTitleTextField');
static const blogPostCreateScreenContentTextField = ValueKey('blogPostCreateScreenContentTextField');
static const blogPostCreateScreenVideoLinkTextField = ValueKey('blogPostCreateScreenVideoLinkTextField');
static const blogPostCreateScreenCreateButton = ValueKey('blogPostCreateScreenCreateButton');
static const imageWidget = ValueKey('imageWidget');
// Recipe Create Screen
static const recipeCreateScreenTitleField = ValueKey('recipeCreateScreenTitleField');
static const recipeCreateScreenDescriptionField = ValueKey('recipeCreateScreenDescriptionField');
static const recipeCreateScreenTypeDropdown = ValueKey('recipeCreateScreenTypeDropdown');
static const recipeCreateScreenPortionsField = ValueKey('recipeCreateScreenPortionsField');
static const recipeCreateScreenCaloriesField = ValueKey('recipeCreateScreenCaloriesField');
static const recipeCreateScreenTotalHoursField = ValueKey('recipeCreateScreenTotalHoursField');
static const recipeCreateScreenTotalMinutesField = ValueKey('recipeCreateScreenTotalMinutesField');
static const recipeCreateScreenActiveHoursField = ValueKey('recipeCreateScreenActiveHoursField');
static const recipeCreateScreenActiveMinutesField = ValueKey('recipeCreateScreenActiveMinutesField');
static const recipeCreateScreenTagField = ValueKey('recipeCreateScreenTagField');
static const recipeCreateScreenAddTagButton = ValueKey('recipeCreateScreenAddTagButton');
static const recipeCreateScreenAddIngredientButton = ValueKey('recipeCreateScreenAddIngredientButton');
static const recipeCreateScreenAddStepButton = ValueKey('recipeCreateScreenAddStepButton');
static const recipeCreateScreenPublishButton = ValueKey('recipeCreateScreenPublishButton');
static const recipeCreateScreenImageWidget = ValueKey('recipeCreateScreenImageWidget');
static const recipeCreateScreenStepDescriptionField = ValueKey('recipeCreateScreenStepDescriptionField');
static const recipeCreateScreenStepImageWidget = ValueKey('recipeCreateScreenStepImageWidget');
static const recipeCreateScreenIngredientNameField = ValueKey('recipeCreateScreenIngredientNameField');
static const recipeCreateScreenIngredientQuantityField = ValueKey('recipeCreateScreenIngredientQuantityField');
static const recipeCreateScreenIngredientAddButton = ValueKey('recipeCreateScreenIngredientAddButton');
}

View File

@@ -0,0 +1,502 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
class MarkdownEditor extends StatefulWidget {
const MarkdownEditor({
super.key,
this.initialText,
this.hintText,
this.hasError = false,
this.minLines = 4,
this.maxLines = 6,
this.heightPreview = 120,
this.onChanged,
this.onToolbarVisibilityChanged,
this.showInlineToolbar = true,
});
final String? initialText;
final String? hintText;
final bool hasError;
final int minLines;
final int maxLines;
final double heightPreview;
final void Function(String value)? onChanged;
final void Function(bool visible)? onToolbarVisibilityChanged;
final bool showInlineToolbar;
@override
State<MarkdownEditor> createState() => MarkdownEditorState();
}
class MarkdownEditorState extends State<MarkdownEditor> {
late final TextEditingController _controller;
final FocusNode _focusNode = FocusNode();
bool _isPreview = false;
bool _enablePanel = false;
late final KeyboardVisibilityController _keyboardVisibilityController;
late final StreamSubscription<bool> _keyboardSubscription;
bool get isToolbarVisible => _enablePanel || _isPreview;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialText ?? '');
_keyboardVisibilityController = KeyboardVisibilityController();
_keyboardSubscription = _keyboardVisibilityController.onChange.listen((visible) {
setState(() {
_enablePanel = visible;
});
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
});
}
@override
void dispose() {
_keyboardSubscription.cancel();
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
bool _hasFormatting(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
} else {
final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
return beforeCursor == prefix && afterCursor == suffix;
}
}
return false;
}
void _removeFormatting(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
} else {
final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
final afterCursor = text.substring(cursorPosition + suffix.length);
final newText = beforeCursor + afterCursor;
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition - prefix.length);
}
}
widget.onChanged?.call(_controller.text);
}
void _applyMarkdownFormat(String prefix, String suffix) {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final formattedText = '$prefix$selectedText$suffix';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
} else {
final cursorPosition = selection.baseOffset;
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition + prefix.length);
}
widget.onChanged?.call(_controller.text);
}
void _applyHeading() {
if (_hasFormatting('# ', '')) {
_removeFormatting('# ', '');
} else {
_applyMarkdownFormat('# ', '');
}
}
void _applyBold() {
if (_hasFormatting('**', '**')) {
_removeFormatting('**', '**');
} else {
_applyMarkdownFormat('**', '**');
}
}
void _applyItalic() {
if (_hasFormatting('*', '*')) {
_removeFormatting('*', '*');
} else {
_applyMarkdownFormat('*', '*');
}
}
void _applyNumberedList() {
if (_hasFormatting('1. ', '')) {
_removeFormatting('1. ', '');
} else {
_applyMarkdownFormat('1. ', '');
}
}
void _applyBulletedList() {
if (_hasFormatting('- ', '')) {
_removeFormatting('- ', '');
} else {
_applyMarkdownFormat('- ', '');
}
}
void _applyLink() {
final text = _controller.text;
final selection = _controller.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
if (linkRegex.hasMatch(selectedText)) {
final match = linkRegex.firstMatch(selectedText);
final linkText = match?.group(1) ?? selectedText;
final newText = text.replaceRange(selection.start, selection.end, linkText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
} else {
final formattedText = '[$selectedText](url)';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
_controller.text = newText;
_controller.selection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
}
} else {
_applyMarkdownFormat('[', '](url)');
}
widget.onChanged?.call(_controller.text);
}
void _applyHorizontalRule() {
final cursorPosition = _controller.selection.baseOffset;
final text = _controller.text;
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
_controller.text = newText;
_controller.selection = TextSelection.collapsed(offset: cursorPosition + 5);
widget.onChanged?.call(_controller.text);
}
void _togglePreview() {
setState(() {
_isPreview = !_isPreview;
});
if (!_isPreview) {
Future.delayed(const Duration(milliseconds: 100), () {
_focusNode.requestFocus();
});
} else {
_focusNode.unfocus();
}
widget.onToolbarVisibilityChanged?.call(isToolbarVisible);
}
Widget _mdButton(IconData icon, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
child: Icon(icon),
),
),
);
}
Widget _mdTextButton(String text, VoidCallback onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
child: Center(
child: Text(
text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
),
),
),
),
);
}
Widget _buildMarkdownPreview(BuildContext context) {
return Container(
height: widget.heightPreview,
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: SingleChildScrollView(child: _parseMarkdown(_controller.text)),
);
}
Widget buildToolbar(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: MediaQuery.of(context).size.width - 76,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
),
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
),
],
);
}
Widget _parseMarkdown(String text) {
if (text.isEmpty) {
return Text(widget.hintText ?? '');
}
final lines = text.split('\n');
final widgets = <Widget>[];
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.trim().isEmpty) {
widgets.add(const SizedBox(height: 8));
continue;
}
if (line.startsWith('# ')) {
widgets.add(
Text(
line.substring(2),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('## ')) {
widgets.add(
Text(
line.substring(3),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('### ')) {
widgets.add(
Text(
line.substring(4),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('- ') || line.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(2))),
],
),
),
);
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
final number = match?.group(1) ?? '1';
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$number. ', style: const TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
],
),
),
);
} else if (line.trim() == '---') {
widgets.add(const Divider(color: Colors.grey, thickness: 1));
} else {
widgets.add(_parseInlineMarkdown(line));
}
if (i < lines.length - 1) {
widgets.add(const SizedBox(height: 4));
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
}
Widget _parseInlineMarkdown(String text) {
final spans = <TextSpan>[];
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
int lastEnd = 0;
for (final match in regex.allMatches(text)) {
if (match.start > lastEnd) {
spans.add(
TextSpan(
text: text.substring(lastEnd, match.start),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
final matchedText = match.group(0)!;
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
spans.add(
TextSpan(
text: matchedText.substring(2, matchedText.length - 2),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
spans.add(
TextSpan(
text: matchedText.substring(1, matchedText.length - 1),
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
),
);
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
if (linkMatch != null) {
final linkText = linkMatch.group(1)!;
spans.add(
TextSpan(
text: linkText,
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
),
);
}
}
lastEnd = match.end;
}
if (lastEnd < text.length) {
spans.add(
TextSpan(
text: text.substring(lastEnd),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
return RichText(text: TextSpan(children: spans));
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isPreview)
_buildMarkdownPreview(context)
else
CustomTextFormField(
controller: _controller,
focusNode: _focusNode,
maxLines: widget.maxLines,
minLines: widget.minLines,
hintText: widget.hintText,
hasError: widget.hasError,
onChanged: (value) => widget.onChanged?.call(value),
),
const SizedBox(height: 8),
if (widget.showInlineToolbar && (isToolbarVisible || !_isPreview))
Row(
children: [
Expanded(
child: Container(
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
),
const SizedBox(width: 8),
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0x00000000).withAlpha((255 * 0.2).toInt()), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
],
),
child: Row(children: [_mdButton(_isPreview ? Icons.edit : Icons.visibility, _togglePreview)]),
),
],
),
],
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/locator_service.dart';
@@ -40,6 +41,7 @@ class AppConfig {
BlocProvider<ChatsCubit>(create: (context) => sl<ChatsCubit>()..getChatRoomsWithLoading()),
BlocProvider<ViewsCubit>(create: (context) => sl<ViewsCubit>()),
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
BlocProvider<MarketingCubit>(create: (context) => sl<MarketingCubit>()),
];
/// Делегаты локализации

View File

@@ -29,23 +29,45 @@ class AppRouter {
/// Обработка редиректов для deep links
static String? _handleRedirect(BuildContext context, GoRouterState state) {
final segments = state.uri.pathSegments;
if (segments.length >= 3) {
if (segments.length == 4) {
final type = segments[1];
switch (type) {
case 'diary':
FlutterNativeSplash.remove();
return '/diary-screen';
}
}
if (segments.length == 3) {
final type = segments[1];
final contentIdentifier = segments[2];
switch (type) {
case 'recipe':
FlutterNativeSplash.remove();
return '${RouteConstants.aboutRecipesScreen}?slug=$contentIdentifier';
return '/about-recipes-screen?slug=$contentIdentifier';
case 'blog_post':
FlutterNativeSplash.remove();
return '${RouteConstants.blogPostScreen}?id=$contentIdentifier';
return '/blog-post-screen?id=$contentIdentifier';
case 'plan':
FlutterNativeSplash.remove();
return '${RouteConstants.aboutPlanScreen}?slug=$contentIdentifier';
return '/about-plan-screen?slug=$contentIdentifier';
case 'diet':
FlutterNativeSplash.remove();
return '${RouteConstants.aboutDietScreen}?slug=$contentIdentifier';
return '/about-diet-screen?slug=$contentIdentifier';
case 'chat':
FlutterNativeSplash.remove();
return '/chat-select-screen';
case 'profile':
FlutterNativeSplash.remove();
return '/profile-screen?slug=$contentIdentifier';
}
}
if (segments.length == 2) {
final type = segments[1];
switch (type) {
case 'chat':
FlutterNativeSplash.remove();
return '/chat-select-screen';
}
}

View File

@@ -0,0 +1,29 @@
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit_state.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MarketingCubit extends Cubit<MarketingCubitState> {
final Prefs prefs;
MarketingCubit(this.prefs) : super(const MarketingCubitState());
Future<bool> checkRegistrationDate() async {
try {
final milliseconds = prefs.getInt(PrefsKeys.registrationDate);
final marketingBottomSheetHasBeenShown = prefs.getBool(PrefsKeys.marketingBottomSheetHasBeenShown);
if (milliseconds == null || marketingBottomSheetHasBeenShown == true) {
return false;
}
final date = DateTime.fromMillisecondsSinceEpoch(milliseconds);
final today = DateTime.now();
final daysCount = today.difference(date).inDays;
final needToShowMarketingBottomSheet = daysCount >= 14;
if (needToShowMarketingBottomSheet) {
await prefs.setBool(PrefsKeys.marketingBottomSheetHasBeenShown, true);
}
return needToShowMarketingBottomSheet;
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,3 @@
class MarketingCubitState {
const MarketingCubitState();
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -38,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
@@ -46,6 +48,7 @@ import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
@@ -74,10 +77,11 @@ class CubitRegistrations {
static void _registerFactoryCubits() {
// Auth related cubits
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl()));
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl()));
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
// Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
@@ -130,7 +134,7 @@ class CubitRegistrations {
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
@@ -143,5 +147,7 @@ class CubitRegistrations {
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
}
}

View File

@@ -23,12 +23,14 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/about_recipe_repository.dart';
import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.dart';
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
import 'package:get_it/get_it.dart';
@@ -68,6 +70,8 @@ class RepositoryRegistrations {
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
// Content repositories
sl.registerLazySingleton<BlogPostRepository>(() => BlogPostRepository(blogPostService: sl()));

View File

@@ -25,12 +25,14 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
import 'package:eatvio_app/features/plans/services/plan_service.dart';
import 'package:eatvio_app/features/plans/services/plans_service.dart';
import 'package:eatvio_app/features/profile/services/profile_service.dart';
import 'package:eatvio_app/features/recipe/services/about_recipe_service.dart';
import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
import 'package:eatvio_app/features/registration/services/registration_service.dart';
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
import 'package:eatvio_app/features/settings/services/settings_service.dart';
import 'package:get_it/get_it.dart';
@@ -76,6 +78,8 @@ class ServiceRegistrations {
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
// Content services
sl.registerLazySingleton<CreateRecipeService>(() => CreateRecipeService(sl()));

View File

@@ -0,0 +1,11 @@
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
mixin RegistrationDateCacheMixin {
late final Prefs prefs;
Future<void> cacheRegistrationDate(DateTime date) async {
await prefs.setInt(PrefsKeys.registrationDate, date.millisecondsSinceEpoch);
}
}

View File

@@ -1 +1 @@
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed }
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }

View File

@@ -9,6 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/pages/scan_qr_code_screen.
import 'package:eatvio_app/features/registration/presentation/pages/registration_screen.dart';
import 'package:eatvio_app/features/registration/presentation/pages/verify_screen.dart';
import 'package:eatvio_app/features/registration/presentation/pages/verify_email_screen.dart';
import 'package:eatvio_app/features/registration_v2/presentation/pages/registration_v2_page.dart';
import 'package:go_router/go_router.dart';
/// Маршруты для аутентификации
@@ -26,6 +27,7 @@ class AuthRoutes {
},
routes: [
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
GoRoute(
path: RouteConstants.forgetPasswordScreen,
name: 'forgetPasswordScreen',

View File

@@ -29,6 +29,8 @@ import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.
import 'package:eatvio_app/features/my_finance/presentation/pages/user_finance_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/presentation/pages/profile_screen.dart';
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
import 'package:eatvio_app/features/recipe/presentation/pages/my_recipes_screen.dart';
@@ -146,10 +148,16 @@ class OtherRoutes {
GoRoute(
path: RouteConstants.profileScreen,
name: 'profileScreen',
builder: (context, state) => ProfileScreen(author: state.extra as Author),
builder: (context, state) {
final slug = state.uri.queryParameters['slug'];
if (slug != null) {
return ProfileScreen(slug: slug);
}
return ProfileScreen(slug: state.extra as String);
},
),
GoRoute(
path: RouteConstants.subsScreen,
path: '${RouteConstants.subsScreen}/:slug',
name: 'subsScreen',
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug']!, subType: state.extra as SubType),
),
@@ -168,7 +176,7 @@ class OtherRoutes {
GoRoute(
path: RouteConstants.emptyChatScreen,
name: 'emptyChatScreen',
builder: (context, state) => EmptyChatScreen(author: state.extra as Author),
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
),
// Consultant routes
@@ -187,7 +195,11 @@ class OtherRoutes {
),
// Other routes
GoRoute(path: RouteConstants.glossaryScreen, name: 'glossaryScreen', builder: (context, state) => GlossaryScreen(passKey: state.extra as String)),
GoRoute(
path: RouteConstants.glossaryScreen,
name: 'glossaryScreen',
builder: (context, state) => GlossaryScreen(passKey: state.extra as String),
),
GoRoute(path: RouteConstants.blockedUsersScreen, name: 'blockedUsersScreen', builder: (context, state) => const BlockedUsersScreen()),
GoRoute(
path: RouteConstants.notificationsSettingsScreen,
@@ -208,7 +220,8 @@ class OtherRoutes {
final data = state.extra as Map<String, dynamic>;
final planId = data['planId'] as int;
final successfulPayment = data['successfulPayment'] as bool?;
return InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment);
final customerSlug = data['customerSlug'] as String?;
return InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment, customerSlug: customerSlug);
},
),
GoRoute(
@@ -231,6 +244,11 @@ class OtherRoutes {
return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay);
},
),
GoRoute(
path: PersonalDietSuggestionScreen.path,
name: PersonalDietSuggestionScreen.name,
builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int,),
),
];
}
}

View File

@@ -3,6 +3,7 @@ class RouteConstants {
// Auth routes
static const String authScreen = '/auth-screen';
static const String registrationScreen = 'registration-screen';
static const String registrationScreenV2 = 'registration-screen-v2';
static const String forgetPasswordScreen = 'forget-password-screen';
static const String scanQRCodeScreen = 'scan-qr-code-screen';
static const String verifyScreen = '/verify-screen';

View File

@@ -10,161 +10,76 @@ class CustomTextStyles {
static TextStyle get headlineSmallMulish => theme.textTheme.headlineSmall!.mulish;
// Title text style
static TextStyle get titleMediumSegoeUI => theme.textTheme.titleMedium!.segoeUI.copyWith(
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumSegoeUI => theme.textTheme.titleMedium!.segoeUI.copyWith(fontWeight: FontWeight.w600);
static TextStyle get bodyMediumBlack900 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black900,
);
static TextStyle get bodyMediumBlack900 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black900);
static TextStyle get bodyMediumBlack90013 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black900,
fontSize: 13,
);
static TextStyle get bodyMediumBlack90013 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black900, fontSize: 13);
static TextStyle get bodyMediumBluegray800 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumBluegray800_1 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800_1 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumBluegray800_2 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.black,
);
static TextStyle get bodyMediumBluegray800_2 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.black);
static TextStyle get bodyMediumWhiteA700 => theme.textTheme.bodyMedium!.copyWith(
color: appTheme.whiteA700,
);
static TextStyle get bodyMediumWhiteA700 => theme.textTheme.bodyMedium!.copyWith(color: appTheme.whiteA700);
static TextStyle get bodySmallMulishBlack900 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black900,
fontSize: 12,
);
static TextStyle get bodySmallMulishBlack900 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black900, fontSize: 12);
static TextStyle get bodySmallMulishBluegray800 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray800 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray80012 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray80012 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray80012_1 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.black,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray80012_1 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.black, fontSize: 12);
static TextStyle get bodySmallMulishBluegray900 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.blueGray900,
fontSize: 12,
);
static TextStyle get bodySmallMulishBluegray900 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.blueGray900, fontSize: 12);
static TextStyle get bodySmallMulishGray600 => theme.textTheme.bodySmall!.mulish.copyWith(
color: appTheme.gray600,
);
static TextStyle get bodySmallMulishGray600 => theme.textTheme.bodySmall!.mulish.copyWith(color: appTheme.gray600);
static TextStyle get bodySmallMulishPrimary => theme.textTheme.bodySmall!.mulish.copyWith(
color: theme.colorScheme.primary,
fontSize: 12,
);
static TextStyle get bodySmallMulishPrimary => theme.textTheme.bodySmall!.mulish.copyWith(color: theme.colorScheme.primary, fontSize: 12);
static TextStyle get bodySmallSegoeUIBlack900 => theme.textTheme.bodySmall!.segoeUI.copyWith(
color: appTheme.black900,
fontSize: 12,
);
static TextStyle get bodySmallSegoeUIBlack900 => theme.textTheme.bodySmall!.segoeUI.copyWith(color: appTheme.black900, fontSize: 12);
// Label text style
static TextStyle get labelLargeGray60003 => theme.textTheme.labelLarge!.copyWith(
color: appTheme.gray60003,
);
static TextStyle get labelLargeGray60003 => theme.textTheme.labelLarge!.copyWith(color: appTheme.gray60003);
static TextStyle get labelLargeWhiteA700 => theme.textTheme.labelLarge!.copyWith(
color: appTheme.whiteA700,
);
static TextStyle get labelLargeWhiteA700 => theme.textTheme.labelLarge!.copyWith(color: appTheme.whiteA700);
// Title text style
static TextStyle get titleMedium16 => theme.textTheme.titleMedium!.copyWith(
fontSize: 16,
);
static TextStyle get titleMedium16 => theme.textTheme.titleMedium!.copyWith(fontSize: 16);
static TextStyle get titleMediumBlack => theme.textTheme.titleMedium!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w900,
);
static TextStyle get titleMediumBlack => theme.textTheme.titleMedium!.copyWith(fontSize: 17, fontWeight: FontWeight.w900);
static TextStyle get titleMediumBlack900 => theme.textTheme.titleMedium!.copyWith(
color: appTheme.black900,
);
static TextStyle get titleMediumBlack900 => theme.textTheme.titleMedium!.copyWith(color: appTheme.black900);
static TextStyle get titleMediumSemiBold => theme.textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumSemiBold => theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600);
static TextStyle get titleMediumWhiteA700 => theme.textTheme.titleMedium!.copyWith(
color: appTheme.whiteA700,
fontWeight: FontWeight.w600,
);
static TextStyle get titleMediumWhiteA700 => theme.textTheme.titleMedium!.copyWith(color: appTheme.whiteA700, fontWeight: FontWeight.w600);
static TextStyle get titleSmallBlue800 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.blue800,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallBlue800 => theme.textTheme.titleSmall!.copyWith(color: appTheme.blue800, fontWeight: FontWeight.w800);
static TextStyle get titleSmallBluegray800 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
);
static TextStyle get titleSmallBluegray800 => theme.textTheme.titleSmall!.copyWith(color: appTheme.black);
static TextStyle get titleSmallBluegray800Bold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
fontWeight: FontWeight.w700,
);
static TextStyle get titleSmallBluegray800Bold => theme.textTheme.titleSmall!.copyWith(color: appTheme.black, fontWeight: FontWeight.w700);
static TextStyle get titleSmallBluegray800ExtraBold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.black,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallBluegray800ExtraBold => theme.textTheme.titleSmall!.copyWith(color: appTheme.black, fontWeight: FontWeight.w800);
static TextStyle get titleSmallBold => theme.textTheme.titleSmall!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
);
static TextStyle get titleSmallBold => theme.textTheme.titleSmall!.copyWith(fontSize: 15, fontWeight: FontWeight.w700);
static TextStyle get titleSmallGreenA70001 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.greenA70001,
);
static TextStyle get titleSmallGreenA70001 => theme.textTheme.titleSmall!.copyWith(color: appTheme.greenA70001);
static TextStyle get titleSmallGreenA70001ExtraBold => theme.textTheme.titleSmall!.copyWith(
color: appTheme.greenA70001,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallGreenA70001ExtraBold => theme.textTheme.titleSmall!.copyWith(color: appTheme.greenA70001, fontWeight: FontWeight.w800);
static TextStyle get titleSmallLightgreen300 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.lightGreen300,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallLightgreen300 => theme.textTheme.titleSmall!.copyWith(color: appTheme.lightGreen300, fontWeight: FontWeight.w800);
static TextStyle get titleSmallRed200 => theme.textTheme.titleSmall!.copyWith(
color: appTheme.red200,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallRed200 => theme.textTheme.titleSmall!.copyWith(color: appTheme.red200, fontWeight: FontWeight.w800);
static TextStyle get titleSmallSecondaryContainer => theme.textTheme.titleSmall!.copyWith(
color: theme.colorScheme.secondaryContainer,
fontWeight: FontWeight.w800,
);
static TextStyle get titleSmallSecondaryContainer =>
theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer, fontWeight: FontWeight.w800);
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(
color: theme.colorScheme.secondaryContainer,
);
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(
color: theme.colorScheme.onPrimary,
);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
}
extension on TextStyle {
@@ -178,75 +93,32 @@ extension on TextStyle {
}
abstract class TextStyles {
static const headingXLSemibold = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const headingXLSemibold = TextStyle(fontSize: 20, fontWeight: FontWeight.w600, height: 1.255);
static const headingFS40FW600 = TextStyle(
fontSize: 40,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const headingFS40FW600 = TextStyle(fontSize: 40, fontWeight: FontWeight.w600, height: 1.255);
static const textLSemibold = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textLSemibold = TextStyle(fontSize: 18, fontWeight: FontWeight.w600, height: 1.255);
static const textLMedium = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textMSemibold = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textLMedium = TextStyle(fontSize: 18, fontWeight: FontWeight.w500, height: 1.255);
static const textMMedium = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1,
);
static const textMSemibold = TextStyle(fontSize: 16, fontWeight: FontWeight.w600, height: 1.255);
static const textMMediumAuto = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textMMedium = TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1);
static const textSSemibold = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.255,
);
static const textMMediumAuto = TextStyle(fontSize: 16, fontWeight: FontWeight.w500, height: 1.255);
static const textSMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textSSemibold = TextStyle(fontSize: 14, fontWeight: FontWeight.w600, height: 1.255);
static const textFS13FW400 = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.255,
);
static const textSMedium = TextStyle(fontSize: 14, fontWeight: FontWeight.w500, height: 1.255);
static const textXSSemibold = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.255,
);
static const textFS13FW400 = TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1.255);
static const textXSSemibold = TextStyle(fontSize: 12, fontWeight: FontWeight.w500, height: 1.255);
static const textFS10FW500 = TextStyle(fontSize: 10, fontWeight: FontWeight.w500, height: 1.255);
static const textFS24FW700 = TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.255);
static const textFS22FW600 = TextStyle(fontSize: 22, fontWeight: FontWeight.w600, height: 1.255);
static const textFS10FW500 = TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.255,
);
}

View File

@@ -7,23 +7,17 @@ class ThemeHelper {
// The current app theme
final _appTheme = 'primary';
// A map of custom color themes supported by the app
final Map<String, PrimaryColors> _supportedCustomColor = {
'primary': PrimaryColors(),
};
// A map of custom color themes supported by the app
final Map<String, PrimaryColors> _supportedCustomColor = {'primary': PrimaryColors()};
// A map of color schemes supported by the app
final Map<String, ColorScheme> _supportedColorScheme = {
'primary': ColorSchemes.primaryColorScheme,
};
// A map of color schemes supported by the app
final Map<String, ColorScheme> _supportedColorScheme = {'primary': ColorSchemes.primaryColorScheme};
/// Returns the primary colors for the current theme.
PrimaryColors _getThemeColors() {
//throw exception to notify given theme is not found or not generated by the generator
if (!_supportedCustomColor.containsKey(_appTheme)) {
throw Exception(
"$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner",
);
throw Exception("$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner");
}
//return theme from map
@@ -34,9 +28,7 @@ class ThemeHelper {
ThemeData _getThemeData() {
//throw exception to notify given theme is not found or not generated by the generator
if (!_supportedColorScheme.containsKey(_appTheme)) {
throw Exception(
"$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner",
);
throw Exception("$_appTheme is not found.Make sure you have added this theme class in JSON Try running flutter pub run build_runner");
}
//return theme from map
@@ -60,17 +52,9 @@ class ThemeHelper {
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
backgroundColor: Colors.transparent,
side: BorderSide(
color: colorScheme.primary,
width: 1,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
visualDensity: const VisualDensity(
vertical: -4,
horizontal: -4,
),
side: BorderSide(color: colorScheme.primary, width: 1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
visualDensity: const VisualDensity(vertical: -4, horizontal: -4),
padding: EdgeInsets.zero,
),
),
@@ -101,7 +85,7 @@ class PrimaryColors {
// Blue
Color get blue400 => const Color(0XFF43A6F2);
Color get blueA400 => const Color(0XFF2F80ED);
Color get blueA400 => const Color(0XFF2F80ED);
// BlueGray
Color get blueGray400 => const Color(0XFF7B8794);
@@ -141,12 +125,17 @@ class PrimaryColors {
Color get gray60003 => const Color(0XFF707070);
Color get gray60005 => const Color(0xFF6C7C93);
// Green
Color get greenA700 => const Color(0XFF019A3E);
Color get greenA70001 => const Color(0XFF00BE13);
Color get green => const Color(0XFF0BB450);
Color get darkGreen => const Color(0XFF16A249);
Color get transparentGreen => const Color.fromARGB(12, 1, 154, 63);
// LightGreen
Color get lightGreen300 => const Color(0XFFB2DF8A);
@@ -180,6 +169,8 @@ class PrimaryColors {
Color get lightGray0 => const Color(0xFFF6F6F6);
Color get darkGrey => const Color(0xFF5B656F);
Color get lightGray00 => const Color(0xFFE9E9EA);
Color get dottedBorder => const Color(0xFFE6E6E6);
@@ -206,6 +197,8 @@ class PrimaryColors {
Color get cardNotificationsBG => const Color(0xFFA4A4A4);
Color get grey7B => const Color(0xFF7B7B7B);
Color get greyD9 => const Color(0xFFD9D9D9);
}
PrimaryColors get appTheme => ThemeHelper().themeColor();

View File

@@ -3,16 +3,28 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
class CustomDropDownButton<T> extends StatelessWidget {
const CustomDropDownButton({super.key, required this.items, required this.onChanged, this.value, this.hint, this.hasError = false});
const CustomDropDownButton({
super.key,
required this.items,
required this.onChanged,
this.value,
this.hint,
this.selectedItemBuilder,
this.hasError = false,
this.isExpanded = false,
});
final T? value;
final List<DropdownMenuItem<T>> items;
final void Function(T?)? onChanged;
final Widget? hint;
final bool hasError;
final List<Widget> Function(BuildContext)? selectedItemBuilder;
final bool isExpanded;
@override
Widget build(BuildContext context) {
return DropdownButtonFormField(
hint: hint,
isExpanded: isExpanded,
icon: SvgPicture.asset('assets/icons/arrow_down2.svg', width: 12, height: 8),
borderRadius: BorderRadius.circular(20),
dropdownColor: appTheme.lightGray0,
@@ -35,6 +47,7 @@ class CustomDropDownButton<T> extends StatelessWidget {
initialValue: value,
items: items,
onChanged: onChanged,
selectedItemBuilder: selectedItemBuilder,
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:share_plus/share_plus.dart';
void showMarketingBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => Container(
margin: EdgeInsets.only(left: 8, right: 8, bottom: MediaQuery.of(context).viewInsets.bottom + MediaQuery.of(context).padding.bottom + 20),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: appTheme.whiteA700),
child: const MarketingBottomSheet(),
),
);
}
class MarketingBottomSheet extends StatelessWidget {
const MarketingBottomSheet({super.key});
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsetsGeometry.all(4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Container(
height: 180,
width: double.infinity,
decoration: const BoxDecoration(
image: DecorationImage(image: AssetImage('assets/images/logo_bg.png'), fit: BoxFit.cover),
),
child: Center(child: SvgPicture.asset('assets/icons/logo_2.svg', height: 90)),
),
),
const SizedBox(height: 16),
Text(s.congratulations, style: TextStyles.textFS24FW700.copyWith(color: appTheme.blueA400)),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Container(
decoration: BoxDecoration(color: appTheme.lightGray0, borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
children: [
const Spacer(),
Container(
decoration: BoxDecoration(shape: BoxShape.circle, color: appTheme.blueA400),
padding: const EdgeInsets.all(8),
child: Text('14', style: TextStyles.textFS24FW700.copyWith(color: appTheme.whiteA700)),
),
const SizedBox(width: 12),
RichText(
text: TextSpan(
text: s.youWithUs,
style: TextStyles.textSMedium.copyWith(color: appTheme.gray60005),
children: [
TextSpan(
text: s.fourteenDays,
style: TextStyles.textLMedium.copyWith(color: appTheme.black),
),
],
),
),
const Spacer(),
],
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
s.weAreLookingFor,
textAlign: TextAlign.center,
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
Text(
s.letsInvite,
textAlign: TextAlign.center,
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
child: CustomOutlinedButton(
text: s.invite,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
leftIcon: Padding(
padding: const EdgeInsets.only(right: 12),
child: Transform.rotate(
angle: 3.14,
child: SvgPicture.asset('assets/icons/share.svg', width: 14, height: 14, colorFilter: ColorFilter.mode(appTheme.whiteA700, BlendMode.srcIn)),
),
),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () async {
SharePlus.instance.share(ShareParams(uri: Uri.parse('https://promo.eatvio.ru/')));
},
),
),
Text(
s.eatvioIsAHelthyHabbit,
textAlign: TextAlign.center,
style: TextStyles.textSMedium.copyWith(color: appTheme.blueA400),
),
const SizedBox(height: 16),
],
),
);
}
}

View File

@@ -9,9 +9,10 @@ import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class OptionWidget extends StatelessWidget {
const OptionWidget({super.key, required this.option, this.disableDuration = false});
const OptionWidget({super.key, required this.option, this.disableDuration = false, required this.duration});
final PacketOptions? option;
final bool disableDuration;
final int duration;
@override
Widget build(BuildContext context) {
if (option == null) {
@@ -34,8 +35,12 @@ class OptionWidget extends StatelessWidget {
),
const SizedBox(height: 12),
if (!disableDuration) ...[
const _OptionDurationWidget(duration: 7),
const SizedBox(height: 16),
_OptionDurationWidget(duration: duration),
SizedBox(height: option?.hasPersonalDiet ?? false ? 8 : 12),
],
if ((option?.hasPersonalDiet ?? false) && !disableDuration) ...[
const _OptionHasPersonalDietWidget(),
const SizedBox(height: 12),
],
const CustomDivider(),
const SizedBox(height: 16),
@@ -52,7 +57,6 @@ class OptionWidget extends StatelessWidget {
}
}
class _OptionDurationWidget extends StatelessWidget {
const _OptionDurationWidget({required this.duration});
final int duration;
@@ -92,3 +96,43 @@ class _OptionDurationWidget extends StatelessWidget {
);
}
}
class _OptionHasPersonalDietWidget extends StatelessWidget {
const _OptionHasPersonalDietWidget();
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: appTheme.transparentGreen),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Container(
width: 4,
height: 20,
decoration: BoxDecoration(
color: appTheme.green,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(100),
bottomRight: Radius.circular(100),
),
),
),
),
const SizedBox(
width: 12,
),
Text(
s.personalDietCreating,
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
),
const SizedBox(
width: 12,
),
],
),
);
}
}

View File

@@ -14,7 +14,7 @@ abstract class YandexProfileModel with _$YandexProfileModel {
@JsonKey(name: 'real_name') required String realName,
@JsonKey(name: 'first_name') required String firstName,
@JsonKey(name: 'last_name') required String lastName,
@JsonKey(name: 'sex') required String sex,
@JsonKey(name: 'sex') String? sex,
@JsonKey(name: 'default_email') required String defaultEmail,
@JsonKey(name: 'default_phone') required YandexPhoneData defaultPhone,
}) = _YandexProfileModel;

View File

@@ -1,14 +1,18 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OAuthCubit extends Cubit<OAuthState> {
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository;
OAuthCubit({required this.authRepository}) : super(const OAuthState());
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
this.prefs = prefs;
}
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
try {
@@ -63,6 +67,7 @@ class OAuthCubit extends Cubit<OAuthState> {
clientId: clientId,
);
TokenStorage.saveTokenInStorage(token);
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e) {
emit(OAuthState.error(errorMessage: e.toString()));

View File

@@ -337,7 +337,7 @@ class _AuthScreenState extends State<AuthScreen> {
children: [
const Text('Нет аккаунта? '),
GestureDetector(
onTap: () => GoRouter.of(context).goNamed('registrationScreen'),
onTap: () => GoRouter.of(context).pushNamed('registrationScreenV2'),
child: Text(
'Зарегистрироваться',
style: TextStyle(color: appTheme.blueA400, decorationColor: appTheme.blueA400, decoration: TextDecoration.underline),
@@ -411,7 +411,7 @@ class _AuthScreenState extends State<AuthScreen> {
_isLoading = false;
});
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
@@ -426,8 +426,8 @@ class _AuthScreenState extends State<AuthScreen> {
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: state is AuthStateLoading
backgroundColor: state is AuthStateLoading || _isLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: state is AuthStateLoading || _isLoading
? null
: () async {
await showDialog(
@@ -487,7 +487,7 @@ class _AuthScreenState extends State<AuthScreen> {
},
);
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: const Icon(Icons.qr_code_scanner_rounded, color: Colors.white),
),

View File

@@ -11,7 +11,9 @@ class BlogPostCubit extends Cubit<BlogPostState> {
Future<void> getBlogPost(int id) async {
try {
emit(state.copyWith(isLoading: true));
final postModel = await feedRepository.getBlogPost(id: id);
emit(state.copyWith(blogPostModel: postModel, isLoading: false));
} catch (e, st) {
log(e.toString(), stackTrace: st);

View File

@@ -18,6 +18,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class BlogPostScreen extends StatefulWidget {
final int id;
@@ -198,7 +199,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: Column(
children: [
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author),
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
@@ -231,10 +232,35 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Html(
data: blogPostState.blogPostModel?.description.replaceAll('\n', '<br>'),
style: {"*": Style(color: Colors.black87)},
data: md.markdownToHtml(blogPostState.blogPostModel!.description.replaceAll('\n', '<br>')),
style: {
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
const SizedBox(height: 8),

View File

@@ -16,7 +16,7 @@ class BlogPostCreateCubit extends BaseCubit<BlogPostCreateState> {
return true;
} catch (error, stackTrace) {
handleError(error, stackTrace);
emit(const BlogPostCreateState.loaded());
emit(BlogPostCreateState.error(errorMessage: error.toString()));
return false;
}
}
@@ -29,7 +29,7 @@ class BlogPostCreateCubit extends BaseCubit<BlogPostCreateState> {
return true;
} catch (error, stackTrace) {
handleError(error, stackTrace);
emit(const BlogPostCreateState.loaded());
emit(BlogPostCreateState.error(errorMessage: error.toString()));
return false;
}
}

View File

@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/app_decoration.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
@@ -24,10 +26,7 @@ import 'package:permission_handler/permission_handler.dart';
class BlogPostCreateScreen extends StatefulWidget {
final FeedBlogPostModel? feedBlogPostModel;
const BlogPostCreateScreen({
super.key,
this.feedBlogPostModel,
});
const BlogPostCreateScreen({super.key, this.feedBlogPostModel});
@override
State<BlogPostCreateScreen> createState() => _BlogPostCreateScreenState();
@@ -42,6 +41,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
final descriptionController = TextEditingController();
final videoUrlController = TextEditingController();
bool isLoading = false;
bool enableMdPanel = false;
bool isPreviewMode = false;
final FocusNode descriptionFocusNode = FocusNode();
@override
void initState() {
@@ -50,6 +52,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
keyboardSubscription = keyboardVisibilityController.onChange.listen((bool visible) {
setState(() {
shrinkImage = visible;
enableMdPanel = visible;
});
});
if (widget.feedBlogPostModel != null) {
@@ -62,9 +65,326 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
@override
void dispose() {
keyboardSubscription.cancel();
descriptionFocusNode.dispose();
super.dispose();
}
bool _hasFormatting(String prefix, String suffix) {
final text = descriptionController.text;
final selection = descriptionController.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
return selectedText.startsWith(prefix) && selectedText.endsWith(suffix);
} else {
final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
final beforeCursor = text.substring(cursorPosition - prefix.length, cursorPosition);
final afterCursor = text.substring(cursorPosition, cursorPosition + suffix.length);
return beforeCursor == prefix && afterCursor == suffix;
}
}
return false;
}
void _removeFormatting(String prefix, String suffix) {
final text = descriptionController.text;
final selection = descriptionController.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final unformattedText = selectedText.substring(prefix.length, selectedText.length - suffix.length);
final newText = text.replaceRange(selection.start, selection.end, unformattedText);
descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + unformattedText.length);
descriptionController.selection = newSelection;
} else {
final cursorPosition = selection.baseOffset;
if (cursorPosition >= prefix.length && text.length >= cursorPosition + suffix.length) {
final beforeCursor = text.substring(0, cursorPosition - prefix.length);
final afterCursor = text.substring(cursorPosition + suffix.length);
final newText = beforeCursor + afterCursor;
descriptionController.text = newText;
final newCursorPosition = cursorPosition - prefix.length;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
}
}
}
void _applyMarkdownFormat(String prefix, String suffix) {
final text = descriptionController.text;
final selection = descriptionController.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final formattedText = '$prefix$selectedText$suffix';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + formattedText.length);
descriptionController.selection = newSelection;
} else {
final cursorPosition = selection.baseOffset;
final newText = '${text.substring(0, cursorPosition)}$prefix$suffix${text.substring(cursorPosition)}';
descriptionController.text = newText;
final newCursorPosition = cursorPosition + prefix.length;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
}
}
void _applyHeading() {
if (_hasFormatting('# ', '')) {
_removeFormatting('# ', '');
} else {
_applyMarkdownFormat('# ', '');
}
}
void _applyBold() {
if (_hasFormatting('**', '**')) {
_removeFormatting('**', '**');
} else {
_applyMarkdownFormat('**', '**');
}
}
void _applyItalic() {
if (_hasFormatting('*', '*')) {
_removeFormatting('*', '*');
} else {
_applyMarkdownFormat('*', '*');
}
}
void _applyNumberedList() {
if (_hasFormatting('1. ', '')) {
_removeFormatting('1. ', '');
} else {
_applyMarkdownFormat('1. ', '');
}
}
void _applyBulletedList() {
if (_hasFormatting('- ', '')) {
_removeFormatting('- ', '');
} else {
_applyMarkdownFormat('- ', '');
}
}
void _applyLink() {
final text = descriptionController.text;
final selection = descriptionController.selection;
if (selection.isValid && !selection.isCollapsed) {
final selectedText = text.substring(selection.start, selection.end);
final linkRegex = RegExp(r'^\[(.*?)\]\((.*?)\)$');
if (linkRegex.hasMatch(selectedText)) {
final match = linkRegex.firstMatch(selectedText);
final linkText = match?.group(1) ?? selectedText;
final newText = text.replaceRange(selection.start, selection.end, linkText);
descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start, extentOffset: selection.start + linkText.length);
descriptionController.selection = newSelection;
} else {
final formattedText = '[$selectedText](url)';
final newText = text.replaceRange(selection.start, selection.end, formattedText);
descriptionController.text = newText;
final newSelection = TextSelection(baseOffset: selection.start + selectedText.length + 2, extentOffset: selection.start + selectedText.length + 5);
descriptionController.selection = newSelection;
}
} else {
_applyMarkdownFormat('[', '](url)');
}
}
void _applyHorizontalRule() {
final cursorPosition = descriptionController.selection.baseOffset;
final text = descriptionController.text;
final newText = '${text.substring(0, cursorPosition)}\n---\n${text.substring(cursorPosition)}';
descriptionController.text = newText;
final newCursorPosition = cursorPosition + 5;
descriptionController.selection = TextSelection.collapsed(offset: newCursorPosition);
}
void _togglePreview() {
setState(() {
isPreviewMode = !isPreviewMode;
});
if (!isPreviewMode) {
Future.delayed(const Duration(milliseconds: 100), () {
descriptionFocusNode.requestFocus();
});
} else {
descriptionFocusNode.unfocus();
}
}
Widget _buildMarkdownPreview() {
return Container(
height: 120,
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: SingleChildScrollView(child: _parseMarkdown(descriptionController.text)),
);
}
Widget _parseMarkdown(String text) {
if (text.isEmpty) {
return const Text(
'Описание',
style: TextStyle(fontSize: 16, color: Color(0xFF323F4B), fontWeight: FontWeight.w500),
);
}
final lines = text.split('\n');
final widgets = <Widget>[];
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.trim().isEmpty) {
widgets.add(const SizedBox(height: 8));
continue;
}
if (line.startsWith('# ')) {
widgets.add(
Text(
line.substring(2),
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('## ')) {
widgets.add(
Text(
line.substring(3),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('### ')) {
widgets.add(
Text(
line.substring(4),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (line.startsWith('- ') || line.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(2))),
],
),
),
);
} else if (RegExp(r'^\d+\. ').hasMatch(line)) {
final match = RegExp(r'^(\d+)\. ').firstMatch(line);
final number = match?.group(1) ?? '1';
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$number. ', style: const TextStyle(fontSize: 16)),
Expanded(child: _parseInlineMarkdown(line.substring(number.length + 2))),
],
),
),
);
} else if (line.trim() == '---') {
widgets.add(const Divider(color: Colors.grey, thickness: 1));
} else {
widgets.add(_parseInlineMarkdown(line));
}
if (i < lines.length - 1) {
widgets.add(const SizedBox(height: 4));
}
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: widgets);
}
Widget _parseInlineMarkdown(String text) {
final spans = <TextSpan>[];
final regex = RegExp(r'(\*\*.*?\*\*|\*.*?\*|\[.*?\]\(.*?\))');
int lastEnd = 0;
for (final match in regex.allMatches(text)) {
if (match.start > lastEnd) {
spans.add(
TextSpan(
text: text.substring(lastEnd, match.start),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
final matchedText = match.group(0)!;
if (matchedText.startsWith('**') && matchedText.endsWith('**')) {
spans.add(
TextSpan(
text: matchedText.substring(2, matchedText.length - 2),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black),
),
);
} else if (matchedText.startsWith('*') && matchedText.endsWith('*')) {
spans.add(
TextSpan(
text: matchedText.substring(1, matchedText.length - 1),
style: const TextStyle(fontSize: 16, fontStyle: FontStyle.italic, color: Colors.black),
),
);
} else if (matchedText.startsWith('[') && matchedText.contains('](')) {
final linkMatch = RegExp(r'\[(.*?)\]\((.*?)\)').firstMatch(matchedText);
if (linkMatch != null) {
final linkText = linkMatch.group(1)!;
spans.add(
TextSpan(
text: linkText,
style: const TextStyle(fontSize: 16, color: Colors.blue, decoration: TextDecoration.underline),
),
);
}
}
lastEnd = match.end;
}
if (lastEnd < text.length) {
spans.add(
TextSpan(
text: text.substring(lastEnd),
style: const TextStyle(fontSize: 16, color: Colors.black),
),
);
}
return RichText(text: TextSpan(children: spans));
}
@override
Widget build(BuildContext context) {
return BlocProvider<BlogPostCreateCubit>(
@@ -102,13 +422,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
context: context,
);
},
child: const Row(
children: [
Icon(Icons.photo_camera),
SizedBox(width: 8),
Text('Фото'),
],
),
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
),
CupertinoActionSheetAction(
onPressed: () async {
@@ -126,96 +440,102 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
context: context,
);
},
child: const Row(
children: [
Icon(Icons.photo),
SizedBox(width: 8),
Text('Галерея'),
],
),
child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]),
),
],
cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(),
isDestructiveAction: true,
child: const Text('Отмена'),
),
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
);
},
);
}
Widget _mdButton(IconData icon, Function() onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
child: Icon(icon),
),
),
);
}
Widget _mdTextButton(String text, Function() onTap) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
height: 36,
decoration: BoxDecoration(color: const Color(0xFFF1F2F6), borderRadius: BorderRadius.circular(10)),
child: Center(
child: Text(
text,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.black87),
),
),
),
),
);
}
Scaffold _buildBlogPostCreate(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: SizedBox(
width: MediaQuery.of(context).size.width - 32,
child: FloatingActionButton.extended(
heroTag: 'createBlogPost',
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
backgroundColor: appTheme.blueA400,
label: isLoading
? const CircularProgressIndicator()
: const Text(
'Создать',
style: TextStyle(
color: Colors.white,
floatingActionButton: enableMdPanel || isPreviewMode
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isPreviewMode) ...[
Container(
width: MediaQuery.of(context).size.width - 76,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0)),
],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
],
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0x00000000).withValues(alpha: 0.2), spreadRadius: 0, blurRadius: 32, offset: const Offset(0, 0))],
),
child: Row(children: [_mdButton(isPreviewMode ? Icons.edit : Icons.visibility, _togglePreview)]),
),
onPressed: isLoading
? null
: () async {
setState(() {
isLoading = true;
});
if (widget.feedBlogPostModel != null) {
final result = await blogPostCubit.updateBlogPost(
id: widget.feedBlogPostModel!.id,
title: titleController.text,
description: descriptionController.text,
videoUrl: videoUrlController.text,
image: image,
oldImageUrl: widget.feedBlogPostModel!.image,
);
setState(() {
isLoading = false;
});
if (!result) {
if (!context.mounted) return;
showErrorDialog(context, title: S.current.could_not_update_blog_post);
} else {
if (!context.mounted) return;
context.pop();
}
} else {
final result = await blogPostCubit.createBlogPost(
title: titleController.text,
description: descriptionController.text,
videoUrl: videoUrlController.text,
image: image,
);
setState(() {
isLoading = false;
});
if (!result) {
if (!context.mounted) return;
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
} else {
if (!context.mounted) return;
context.pop();
}
}
},
),
),
],
)
: null,
appBar: AppBar(),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
@@ -224,17 +544,19 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
const SizedBox(height: 16),
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(
controller: titleController,
),
CustomTextFormField(key: AppKeys.blogPostCreateScreenTitleTextField, controller: titleController),
const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
CustomTextFormField(
controller: descriptionController,
maxLines: 6,
minLines: 4,
),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
),
const SizedBox(height: 16),
if (videoUrlController.text.isEmpty) const Text('Изображение поста'),
if (videoUrlController.text.isEmpty) const SizedBox(height: 8),
@@ -243,16 +565,75 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
if (image == null && widget.feedBlogPostModel?.image == null) const Text('Ссылка на видео'),
if (image == null && widget.feedBlogPostModel?.image == null) const SizedBox(height: 8),
if (image == null && widget.feedBlogPostModel?.image == null)
CustomTextFormField(
controller: videoUrlController,
),
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
const SizedBox(height: 80),
_createBlogPostButton(),
],
),
),
);
}
Widget _createBlogPostButton() {
return InkWell(
onTap: isLoading
? null
: () async {
setState(() {
isLoading = true;
});
if (widget.feedBlogPostModel != null) {
final result = await blogPostCubit.updateBlogPost(
id: widget.feedBlogPostModel!.id,
title: titleController.text,
description: descriptionController.text,
videoUrl: videoUrlController.text,
image: image,
oldImageUrl: widget.feedBlogPostModel!.image,
);
setState(() {
isLoading = false;
});
if (!result) {
if (!mounted) return;
showErrorDialog(context, title: S.current.could_not_update_blog_post);
} else {
if (!mounted) return;
context.pop();
}
} else {
final result = await blogPostCubit.createBlogPost(
title: titleController.text,
description: descriptionController.text,
videoUrl: videoUrlController.text,
image: image,
);
setState(() {
isLoading = false;
});
if (!result) {
if (!mounted) return;
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
} else {
if (!mounted) return;
context.pop();
}
}
},
borderRadius: BorderRadius.circular(16),
child: isLoading
? const Center(child: Loader())
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
child: const Center(
child: Text('Создать', style: TextStyle(color: Colors.white)),
),
),
);
}
GestureDetector _buildAddPhoto() {
return GestureDetector(
onTap: () {
@@ -265,22 +646,14 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
height: shrinkImage ? 52 : 128,
curve: Curves.easeIn,
duration: const Duration(milliseconds: 150),
decoration: AppDecoration.fillGray.copyWith(
borderRadius: BorderRadius.circular(16),
),
decoration: AppDecoration.fillGray.copyWith(borderRadius: BorderRadius.circular(16)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.photo_library,
color: appTheme.blueGray300,
),
Icon(Icons.photo_library, color: appTheme.blueGray300),
const SizedBox(width: 8),
Text(
"Добавить фото",
selectionColor: appTheme.blueA400,
),
Text("Добавить фото", selectionColor: appTheme.blueA400),
],
),
),
@@ -289,13 +662,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image(
image: FileImage(
File(
image!.path,
),
),
),
child: Image(image: FileImage(File(image!.path))),
),
),
if (widget.feedBlogPostModel?.image != null && image == null)
@@ -303,9 +670,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
width: shrinkImage ? 52 : MediaQuery.of(context).size.width - 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image(
image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}'),
),
child: Image(image: NetworkImage('${sl<AppConfig>().baseUrl}/storage/${widget.feedBlogPostModel!.image}')),
),
),
],

View File

@@ -18,7 +18,6 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
@@ -115,13 +114,21 @@ class _ChatScreenState extends State<ChatScreen> {
ChosenPlanStateEnum.statePendingPayment,
ChosenPlanStateEnum.stateInCoordination,
].contains(chosenPlan?.chosenPlanState);
PacketOptions? chosenOption;
if (chosenPlan?.optionType == 'options1') {
chosenOption = chosenPlan?.plan.packet1Options;
} else if (chosenPlan?.optionType == 'options2') {
chosenOption = chosenPlan?.plan.packet2Options;
} else if (chosenPlan?.optionType == 'options3') {
chosenOption = chosenPlan?.plan.packet3Options;
}
final showAboutOrderButton =
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
BannerType? bannerType;
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
bannerType = null;
} else if (amIConsultant && !state.hasCard) {
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.card;
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
bannerType = BannerType.date;
@@ -132,16 +139,10 @@ class _ChatScreenState extends State<ChatScreen> {
bannerType = BannerType.collectCharge;
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
bannerType = BannerType.finishPlan;
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
bannerType = BannerType.setPersonalDiet;
}
PacketOptions? chosenOption;
if (chosenPlan?.optionType == 'options1') {
chosenOption = chosenPlan?.plan.packet1Options;
} else if (chosenPlan?.optionType == 'options2') {
chosenOption = chosenPlan?.plan.packet2Options;
} else if (chosenPlan?.optionType == 'options3') {
chosenOption = chosenPlan?.plan.packet3Options;
}
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
@@ -150,15 +151,7 @@ class _ChatScreenState extends State<ChatScreen> {
centerTitle: false,
title: GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: state.chatRoom.users.receiver.slug,
authorName: state.chatRoom.users.receiver.name,
authorProfileIcon: state.chatRoom.users.receiver.profilePhotoUrl ?? '',
authorIsMe: false,
),
);
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
},
child: Row(
children: [
@@ -326,6 +319,7 @@ class _ChatScreenState extends State<ChatScreen> {
chosenPlanId: chosenPlan?.id,
planName: chosenPlan?.plan.name,
planPrice: chosenOption?.price.toDouble(),
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
),
),
],
@@ -453,7 +447,7 @@ class _ChatScreenState extends State<ChatScreen> {
final blockedUsersCubit = sl<BlockedUsersCubit>();
await blockedUsersCubit.getBlockedUsers();
final blockedState = blockedUsersCubit.state;
if (blockedState is BlockedUsersStateLoaded) {
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
if (isBlocked) {
@@ -468,7 +462,7 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
}
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
chatRoomCubit.sendMessage(
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
@@ -503,6 +497,12 @@ class _ChatScreenState extends State<ChatScreen> {
left: 16,
child: AboutOrderButton(
onTap: () async {
if (amIConsultant) {
GoRouter.of(
context,
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
return;
}
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
},
),

View File

@@ -6,7 +6,7 @@ import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -16,12 +16,9 @@ import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
class EmptyChatScreen extends StatefulWidget {
final Author author;
final ProfileModel user;
const EmptyChatScreen({
super.key,
required this.author,
});
const EmptyChatScreen({super.key, required this.user});
@override
State<EmptyChatScreen> createState() => _EmptyChatScreenState();
@@ -40,34 +37,19 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
centerTitle: false,
title: GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: widget.author.authorSlug,
authorName: widget.author.authorName,
authorProfileIcon: widget.author.authorProfileIcon,
authorIsMe: false,
),
);
GoRouter.of(context).pushNamed('profileScreen', extra: widget.user.slug);
},
child: Row(
children: [
CircleAvatar(
radius: 20,
foregroundImage: NetworkImage(widget.author.authorProfileIcon),
foregroundImage: NetworkImage(widget.user.profilePhotoUrl),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.author.authorName,
style: CustomTextStyles.titleSmallBold.copyWith(
color: PrimaryColors().black,
),
),
],
children: [Text(widget.user.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black))],
),
],
),
@@ -84,18 +66,14 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
children: [
const SizedBox(width: 8),
InkWell(
onTap: () => showImagePicker(widget.author.authorSlug),
onTap: () => showImagePicker(widget.user.slug),
customBorder: const CircleBorder(),
child: Ink(
height: 40,
width: 40,
child: Transform.rotate(
angle: pi / 4,
child: const Icon(
Icons.attach_file_rounded,
size: 30,
color: Color(0xFF858E99),
),
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
),
),
),
@@ -117,8 +95,10 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
hintText: 'Сообщение',
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
filled: true,
border:
OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(100),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
@@ -139,40 +119,29 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
final blockedUsersCubit = sl<BlockedUsersCubit>();
await blockedUsersCubit.getBlockedUsers();
final blockedState = blockedUsersCubit.state;
if (blockedState is BlockedUsersStateLoaded) {
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug);
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.user.slug);
if (isBlocked) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
backgroundColor: Colors.red,
),
const SnackBar(content: Text('Вы не можете отправить сообщение заблокированному пользователю'), backgroundColor: Colors.red),
);
}
return;
}
}
if (textEditingController.text.isNotEmpty) {
await sl<ChatRepository>().sendMessage(
data: textEditingController.text.trim(),
slug: widget.author.authorSlug,
);
await sl<ChatRepository>().sendMessage(data: textEditingController.text.trim(), slug: widget.user.slug);
if (mounted) {
setState(() {
textEditingController.clear();
});
}
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
'chatScreen',
extra: {
'uuid': chatIdOrError,
},
);
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -201,53 +170,36 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
CupertinoActionSheetAction(
onPressed: () async {
Navigator.of(context).pop();
// Проверяем, заблокирован ли пользователь
final blockedUsersCubit = sl<BlockedUsersCubit>();
await blockedUsersCubit.getBlockedUsers();
final blockedState = blockedUsersCubit.state;
if (blockedState is BlockedUsersStateLoaded) {
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.author.authorSlug);
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == widget.user.slug);
if (isBlocked) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
backgroundColor: Colors.red,
),
const SnackBar(content: Text('Вы не можете отправить сообщение заблокированному пользователю'), backgroundColor: Colors.red),
);
}
return;
}
}
final picker = ImagePicker();
List<XFile>? images = await picker.pickMultiImage();
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(
files: files,
slug: widget.author.authorSlug,
);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
'chatScreen',
extra: {
'uuid': chatIdOrError,
},
);
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
child: const Row(
children: [
Icon(Icons.photo_library_rounded),
SizedBox(width: 8),
Text('Галерея'),
],
),
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
),
CupertinoActionSheetAction(
onPressed: () async {
@@ -256,28 +208,14 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
List<XFile>? images = await picker.pickMultiImage();
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(
files: files,
slug: widget.author.authorSlug,
);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
'chatScreen',
extra: {
'uuid': chatIdOrError,
},
);
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
child: const Row(
children: [
Icon(Icons.photo_camera),
SizedBox(width: 8),
Text('Фото'),
],
),
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
),
CupertinoActionSheetAction(
onPressed: () async {
@@ -308,35 +246,17 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
);
if (result != null) {
List<File> files = result.paths.map((path) => File(path!)).toList();
await sl<ChatRepository>().sendMessage(
files: files,
slug: widget.author.authorSlug,
);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.author.authorSlug);
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed(
'chatScreen',
extra: {
'uuid': chatIdOrError,
},
);
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
child: const Row(
children: [
Icon(Icons.file_present_rounded),
SizedBox(width: 8),
Text('Файлы'),
],
),
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
),
],
cancelButton: CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(),
isDestructiveAction: true,
child: const Text('Отмена'),
),
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
),
);
}

View File

@@ -9,17 +9,28 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_portal/flutter_portal.dart';
import 'package:flutter_svg/flutter_svg.dart';
class ChosePlanDateWidget extends StatefulWidget {
const ChosePlanDateWidget({super.key, required this.chatCubit});
const ChosePlanDateWidget({super.key, required this.chatCubit, required this.hasPersonalDiet});
final ChatRoomCubit chatCubit;
final bool hasPersonalDiet;
@override
State<ChosePlanDateWidget> createState() => _ChosePlanDateWidget();
}
class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
final startDate = [DateTime.now()];
late final List<DateTime> startDate;
bool visible = false;
@override
void initState() {
super.initState();
if (widget.hasPersonalDiet) {
startDate = [DateTime.now().add(const Duration(days: 2))];
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -27,64 +38,129 @@ class _ChosePlanDateWidget extends State<ChosePlanDateWidget> {
if (widget.chatCubit.state is ChatRoomStateLoaded) {
planName = (widget.chatCubit.state as ChatRoomStateLoaded).chatRoom.chosenPlan?.plan.name;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
const BottomSheetHandlerBar(),
const SizedBox(height: 12),
Text(
planName ?? '',
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
return GestureDetector(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 4),
const BottomSheetHandlerBar(),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
planName ?? '',
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
),
if (widget.hasPersonalDiet) ...[
const SizedBox(
width: 8,
),
PortalTarget(
closeDuration: const Duration(milliseconds: 20),
anchor: const Aligned(
shiftToWithinBound: AxisFlag(x: true, y: true),
follower: Alignment.topRight,
target: Alignment.bottomLeft,
),
portalFollower: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: visible ? appTheme.whiteA700 : Colors.transparent,
boxShadow: visible
? [
BoxShadow(
color: appTheme.black900.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 4),
),
]
: null,
),
child: AnimatedDefaultTextStyle(
style: TextStyles.textMMediumAuto.copyWith(
color: visible ? appTheme.black : Colors.transparent,
),
duration: const Duration(milliseconds: 300),
child: Text(
s.chosenPlanStartDateDescr,
),
),
),
),
child: GestureDetector(
onTap: () {
setState(() {
visible = !visible;
});
},
child: SvgPicture.asset(
'assets/icons/question_mark.svg',
width: 22,
height: 22,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
)
]
],
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25.0),
child: Text(
s.chose_plan_start_date_2,
textAlign: TextAlign.center,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25.0),
child: Text(
s.chose_plan_start_date_2,
textAlign: TextAlign.center,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
),
),
const SizedBox(height: 12),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: CustomDivider(),
),
CalendarWidget(
startDate: startDate,
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
child: CustomOutlinedButton(
onPressed: () async {
final result = await widget.chatCubit.choseStartPlanDate(startDate[0]);
if (result) {
showSuccessToast('Дата установлена');
if (!context.mounted) return;
Navigator.pop(context);
} else {
showErrorToast('Не удалось установить дату');
if (!context.mounted) return;
Navigator.pop(context);
}
},
height: 60,
text: s.confirm,
buttonTextStyle: TextStyles.textLSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
const SizedBox(height: 12),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: CustomDivider(),
),
CalendarWidget(
startDate: startDate,
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
child: CustomOutlinedButton(
onPressed: () async {
final result = await widget.chatCubit.choseStartPlanDate(startDate[0]);
if (result) {
showSuccessToast('Дата установлена');
if (!context.mounted) return;
Navigator.pop(context);
} else {
showErrorToast('Не удалось установить дату');
if (!context.mounted) return;
Navigator.pop(context);
}
},
height: 60,
text: s.confirm,
buttonTextStyle: TextStyles.textLSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
side: const WidgetStatePropertyAll(BorderSide.none),
),
),
),
),
],
],
),
);
}
}

View File

@@ -9,6 +9,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -17,12 +18,12 @@ import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
class NotificationBanner extends StatefulWidget {
const NotificationBanner({super.key, required this.bannerType, this.planPrice, this.planName, this.chosenPlanId});
const NotificationBanner({super.key, required this.bannerType, this.planPrice, this.planName, this.chosenPlanId, this.hasPersonalDiet = false});
final BannerType bannerType;
final double? planPrice;
final String? planName;
final int? chosenPlanId;
final bool hasPersonalDiet;
@override
State<NotificationBanner> createState() => _NotificationBannerState();
}
@@ -68,7 +69,8 @@ class _NotificationBannerState extends State<NotificationBanner> {
BannerType.date => [
SizedBox(
width: size.width * 0.6,
child: Text(s.choose_plan_start_date, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
child: Text(widget.hasPersonalDiet == true ? s.choose_plan_start_date_with_personal_diet : s.choose_plan_start_date,
style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
@@ -78,6 +80,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
context: context,
builder: (context) => ChosePlanDateWidget(
chatCubit: cubit,
hasPersonalDiet: widget.hasPersonalDiet,
),
);
},
@@ -155,6 +158,20 @@ class _NotificationBannerState extends State<NotificationBanner> {
),
const SizedBox(height: 8),
],
BannerType.setPersonalDiet => [
SizedBox(
width: size.width * 0.6,
child: Text(s.createPersonalDiet, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(MyDietsScreen.name);
},
child: Text(s.goToMyDiets, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
),
const SizedBox(height: 16),
],
},
),
),
@@ -242,4 +259,4 @@ class _StarIcon extends StatelessWidget {
}
}
enum BannerType { date, card, payment, collectCharge, finishPlan }
enum BannerType { date, card, payment, collectCharge, finishPlan, setPersonalDiet }

View File

@@ -22,6 +22,7 @@ abstract class ConsultationModel with _$ConsultationModel {
@JsonKey(name: 'end_date', fromJson: parseDate) required DateTime endDate,
@JsonKey(name: 'consultant_id') required int consultantId,
required List<DiaryModel> diaries,
@JsonKey(name: 'personal_diet_id') required int? personalDietId,
}) = _ConsultationModel;
factory ConsultationModel.fromJson(Map<String, dynamic> json) => _$ConsultationModelFromJson(json);

View File

@@ -63,25 +63,28 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: widget.consultationsModel.userSlug),
child: Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
),
),
const SizedBox(height: 12),
GestureDetector(

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -54,6 +55,8 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final scaffoldKey = GlobalKey<ScaffoldState>();
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
@override
void initState() {
@@ -76,9 +79,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(
title: state.dietData.name ?? 'Диета',
),
child: SuccessPublishedDietOrPlanBottomSheet(title: state.dietData.name ?? 'Диета'),
);
});
} else if (state.drafted) {
@@ -87,15 +88,15 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
context.pop();
showBottomSheetWithBlur(
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(
title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках',
),
child: SuccessPublishedDietOrPlanBottomSheet(title: '${state.dietData.name ?? 'Диета'} сохраненна в черновиках'),
);
});
}
return Scaffold(
backgroundColor: appTheme.gray100,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -124,34 +125,23 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
context.read<CreateDietCubit>().setName(value);
},
),
if (state.dietNameError != null) ...[
const SizedBox(
height: 4,
),
ValidationErrorWidget(error: state.dietNameError ?? ''),
],
if (state.dietNameError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietNameError ?? '')],
const SizedBox(height: 12),
SizedBox(
height: 132,
child: CustomTextFormField(
hasError: state.dietDescriptionError != null,
controller: descriptionController,
textAlignVertical: TextAlignVertical.top,
maxLines: null,
minLines: null,
expands: true,
hintText: s.enter_diet_description,
onChanged: (value) {
context.read<CreateDietCubit>().setDetails(value);
},
),
MarkdownEditor(
key: _mdKey,
initialText: descriptionController.text,
hintText: s.enter_diet_description,
hasError: state.dietDescriptionError != null,
minLines: 6,
maxLines: 8,
showInlineToolbar: false,
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
onChanged: (value) {
descriptionController.text = value;
context.read<CreateDietCubit>().setDetails(value);
},
),
if (state.dietDescriptionError != null) ...[
const SizedBox(
height: 4,
),
ValidationErrorWidget(error: state.dietDescriptionError ?? ''),
],
if (state.dietDescriptionError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.dietDescriptionError ?? '')],
const SizedBox(height: 12),
const DietDurationDropDownWidget(),
const SizedBox(height: 12),
@@ -171,9 +161,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
child: Center(
child: Text(
'${s.diet_plan} (${state.dietData.duration ?? 0} ${getDayForm(state.dietData.duration ?? 0, s)})',
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
),
@@ -196,9 +184,7 @@ class _CreateDietScreenContentState extends State<CreateDietScreenContent> {
onPressed: () async {
context.read<CreateDietCubit>().setDietState(DietState.draft);
if (widget.diet != null) {
final result = await context.read<CreateDietCubit>().updateDiet(
widget.diet!.id,
);
final result = await context.read<CreateDietCubit>().updateDiet(widget.diet!.id);
if (result == 'error') {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_update_diet} ${state.dietData.name}');

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/locator_service.dart';
@@ -47,6 +48,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
type['veg'] = diet.type.veg ?? false;
type['carb'] = diet.type.carb ?? false;
type['light'] = diet.type.light ?? false;
final appConfig = sl<AppConfig>();
emit(
state.copyWith.dietData(
dailySchedule: dailySchedule,
@@ -55,7 +57,8 @@ class CreateDietCubit extends Cubit<CreateDietState> {
name: diet.name,
duration: diet.duration,
type: type,
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${diet.image}',
// Avoid network image URLs during tests to prevent HTTP fetch attempts
imageUrl: appConfig.buildType == AppBuildType.tests ? null : '${appConfig.baseUrl}/storage/${diet.image}',
),
);
emit(state.copyWith(dietState: diet.state, durationIndex: durationValues.indexOf(diet.duration)));
@@ -76,11 +79,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
final dailySchedule = state.dietData.dailySchedule;
if (currentDaysValue != null && dailySchedule != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
if (currentDaysValue > selectedDaysValue) {
final difference = currentDaysValue - selectedDaysValue;
@@ -93,28 +92,16 @@ class CreateDietCubit extends Cubit<CreateDietState> {
deepCopyOfDailySchedule.add([[], [], [], [], []]);
}
}
emit(
state.copyWith(
dietData: state.dietData.copyWith(
dailySchedule: deepCopyOfDailySchedule,
),
),
);
emit(state.copyWith(dietData: state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule)));
} else {
final List<List<List<DailySchedule>>> dailySchedule = List.generate(selectedDaysValue, (index) => [[], [], [], [], []]);
emit(
state.copyWith(
dietData: state.dietData.copyWith(dailySchedule: dailySchedule),
),
);
emit(state.copyWith(dietData: state.dietData.copyWith(dailySchedule: dailySchedule)));
}
emit(
state.copyWith(
durationIndex: index,
dietData: state.dietData.copyWith(
duration: durationValues[index],
),
dietData: state.dietData.copyWith(duration: durationValues[index]),
dietDurationError: null,
),
);
@@ -125,7 +112,12 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
void setImage(XFile? image, {String? imageUrl}) {
emit(state.copyWith(dietData: state.dietData.copyWith(image: image, imageUrl: imageUrl), dietImageError: null));
emit(
state.copyWith(
dietData: state.dietData.copyWith(image: image, imageUrl: imageUrl),
dietImageError: null,
),
);
}
void setDailySchedule(List<List<List<DailySchedule>>> dailySchedule) {
@@ -133,27 +125,25 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
void setDietState(DietState dietState) {
emit(state.copyWith(dietData: state.dietData.copyWith(state: dietState), dietState: dietState, dietStateError: null));
emit(
state.copyWith(
dietData: state.dietData.copyWith(state: dietState),
dietState: dietState,
dietStateError: null,
),
);
}
void setDietType({required String key, required bool value}) {
final type = Map<String, bool>.from(state.dietData.type);
type[key] = value;
emit(
state.copyWith(
dietData: state.dietData.copyWith(type: type),
),
);
emit(state.copyWith(dietData: state.dietData.copyWith(type: type)));
}
void addPortion({required int dayIndex, required int mealIndex, required DailySchedule portion}) {
final dailySchedule = state.dietData.dailySchedule;
if (dailySchedule != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
deepCopyOfDailySchedule[dayIndex][mealIndex].add(portion);
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
@@ -162,18 +152,10 @@ class CreateDietCubit extends Cubit<CreateDietState> {
}
}
void removePortion({
required int day,
required int meal,
required int portionIndex,
}) {
void removePortion({required int day, required int meal, required int portionIndex}) {
final dailySchedule = state.dietData.dailySchedule;
if (dailySchedule != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
deepCopyOfDailySchedule[day][meal].removeAt(portionIndex);
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
@@ -184,11 +166,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
void updatePortion({required int dayIndex, required int mealIndex, required int portionIndex, required DailySchedule portion}) {
final dailySchedule = state.dietData.dailySchedule;
if (dailySchedule != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
deepCopyOfDailySchedule[dayIndex][mealIndex][portionIndex] = portion;
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
@@ -199,11 +177,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
void copyDay(int dayIndex) {
final dailySchedule = state.dietData.dailySchedule;
if (dailySchedule != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
final copiedDay = deepCopyOfDailySchedule[dayIndex];
emit(state.copyWith(copiedDay: copiedDay));
}
@@ -213,11 +187,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
final dailySchedule = state.dietData.dailySchedule;
final copiedDay = state.copiedDay;
if (dailySchedule != null && copiedDay != null) {
final deepCopyOfDailySchedule = dailySchedule
.map(
(day) => day.map((meal) => [...meal]).toList(),
)
.toList();
final deepCopyOfDailySchedule = dailySchedule.map((day) => day.map((meal) => [...meal]).toList()).toList();
deepCopyOfDailySchedule[dayIndex] = copiedDay;
final newDietData = state.dietData.copyWith(dailySchedule: deepCopyOfDailySchedule);
emit(state.copyWith(dietData: newDietData));
@@ -268,14 +238,7 @@ class CreateDietCubit extends Cubit<CreateDietState> {
final dailyNutrition = <DailyNutrition>[];
if (carbs != null && calories != null && fats != null && proteins != null) {
for (int i = 0; i < carbs.length; i++) {
dailyNutrition.add(
DailyNutrition(
carbohydrates: carbs[i],
energy: calories[i],
fats: fats[i],
proteins: proteins[i],
),
);
dailyNutrition.add(DailyNutrition(carbohydrates: carbs[i], energy: calories[i], fats: fats[i], proteins: proteins[i]));
}
}
final dailySchedule = <int, Map<int, Map<int, DailySchedule>>>{};
@@ -339,7 +302,10 @@ class CreateDietCubit extends Cubit<CreateDietState> {
Future<String?> createDiet() async {
emit(state.copyWith(isDietPublishing: true));
if (!_isFromValid()) return 'validation';
if (!_isFromValid()) {
emit(state.copyWith(isDietPublishing: false));
return 'validation';
}
try {
File? file;
if (state.dietData.imageUrl != null) {

View File

@@ -1,13 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/enums/product_type.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/core/utils/string_extentions.dart';
import 'package:eatvio_app/core/widgets/bookmark_button.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/info_row.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart';
import 'package:eatvio_app/core/widgets/cpfc_container.dart';
@@ -36,20 +31,11 @@ class DietPreviewScreen extends StatefulWidget {
class _DietPreviewScreenState extends State<DietPreviewScreen> {
final ScrollController scrollController = ScrollController();
final textEditingController = TextEditingController();
bool isMoreOptionsVisible = false;
late final Uint8List imageData;
@override
void initState() {
super.initState();
imageData = base64.decode(widget.diet.image);
scrollController.addListener(() {
if (isMoreOptionsVisible) {
setState(() {
isMoreOptionsVisible = false;
});
}
});
}
@override
@@ -58,86 +44,6 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
return Scaffold(
backgroundColor: appTheme.gray100,
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: BookmarkButton(
isFavorite: widget.diet.isFavorite,
onTap: () {},
width: 52,
height: 52,
size: 24,
shape: BoxShape.circle,
color: appTheme.gray50,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: SizedBox(
child: TextFormField(
textCapitalization: TextCapitalization.sentences,
onChanged: (_) {
setState(() {});
},
controller: textEditingController,
maxLines: 5,
minLines: 1,
keyboardType: TextInputType.multiline,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
hintText: s.comment.capitalize(),
hintStyle: TextStyles.textMMedium.copyWith(color: appTheme.lightGray600),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : appTheme.gray50, shape: BoxShape.circle),
height: 32,
width: 32,
child: Icon(Icons.send_rounded, size: 18, color: textEditingController.text.length > 2 ? appTheme.whiteA700 : appTheme.black),
),
),
],
),
],
),
),
),
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -148,15 +54,7 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
actions: [
CustomPopup(
isAuthor: widget.diet.author.authorIsMe,
path: 'diet/${widget.diet.slug}',
complainedContentType: ComplainedContent.diet,
userSlug: widget.diet.author.authorSlug,
),
const SizedBox(width: 16),
],
actions: const [],
),
body: SingleChildScrollView(
controller: scrollController,

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -28,16 +29,21 @@ class CreatePlanScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<CreatePlanCubit>(),
child: const CreatePlanScreenContent(),
);
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
}
}
class CreatePlanScreenContent extends StatelessWidget {
class CreatePlanScreenContent extends StatefulWidget {
const CreatePlanScreenContent({super.key});
@override
State<CreatePlanScreenContent> createState() => _CreatePlanScreenContentState();
}
class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -51,15 +57,14 @@ class CreatePlanScreenContent extends StatelessWidget {
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedDietOrPlanBottomSheet(
title: state.planData.name ?? 'План',
isPlan: true,
),
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
);
});
}
return Scaffold(
backgroundColor: appTheme.gray100,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -104,10 +109,7 @@ class CreatePlanScreenContent extends StatelessWidget {
},
maxLength: 150,
),
if (state.validationState.titleHasError) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols),
],
if (state.validationState.titleHasError) ...[const SizedBox(height: 4), ValidationErrorWidget(error: s.atLeast3Symbols)],
],
);
},
@@ -140,20 +142,17 @@ class CreatePlanScreenContent extends StatelessWidget {
builder: (context, state) {
return Column(
children: [
SizedBox(
height: 132,
child: CustomTextFormField(
hasError: state.validationState.descriptionHasError,
textAlignVertical: TextAlignVertical.top,
maxLength: 1000,
maxLines: null,
minLines: null,
expands: true,
hintText: s.plan_desc_example,
onChanged: (value) {
context.read<CreatePlanCubit>().setDetails(value);
},
),
MarkdownEditor(
key: _mdKey,
hintText: s.plan_desc_example,
hasError: state.validationState.descriptionHasError,
minLines: 6,
maxLines: 8,
showInlineToolbar: false,
onToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
onChanged: (value) {
context.read<CreatePlanCubit>().setDetails(value);
},
),
if (state.validationState.descriptionHasError) ...[
const SizedBox(height: 4),
@@ -253,10 +252,7 @@ class CreatePlanScreenContent extends StatelessWidget {
}
return const Padding(
padding: EdgeInsets.only(bottom: 24.0),
child: Align(
alignment: Alignment.bottomCenter,
child: SelectExactOptionWidget(),
),
child: Align(alignment: Alignment.bottomCenter, child: SelectExactOptionWidget()),
);
},
),

View File

@@ -67,6 +67,13 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
validateSelectedOptionItems();
}
void togglePersonalizedDietOptionState() {
final options = [...state.planData.options];
options[state.selectedOptionIndex ?? 0] =
options[state.selectedOptionIndex ?? 0].copyWith(hasPersonalDiet: !options[state.selectedOptionIndex ?? 0].hasPersonalDiet);
emit(state.copyWith(planData: state.planData.copyWith(options: options)));
}
void setDuration(int index) {
emit(
state.copyWith(
@@ -418,18 +425,21 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
},
'packet2_options': {
'short': state.planData.options[1].short,
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
'description_md': state.planData.options[1].description,
'items': Map.fromIterable(state.planData.options[1].items),
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
},
'packet3_options': {
'short': state.planData.options[2].short,
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
'description_md': state.planData.options[2].description,
'items': Map.fromIterable(state.planData.options[2].items),
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
},
});
} else {
@@ -440,6 +450,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
},
});
}

View File

@@ -90,7 +90,7 @@ class _OptionWidgetState extends State<OptionWidget> {
2 => thirdOptionCustomerPriceController,
_ => secondOptionCustomerPriceController,
};
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
@@ -255,33 +255,31 @@ class _OptionWidgetState extends State<OptionWidget> {
),
const SizedBox(height: 16),
const CustomDivider(),
// const SizedBox(height: 16),
// Row(
// children: [
// Checkbox(
// value: isChecked,
// activeColor: appTheme.gray,
// checkColor: appTheme.gray,
// fillColor: const WidgetStatePropertyAll(Colors.transparent),
// side: WidgetStateBorderSide.resolveWith(
// (Set<WidgetState> states) {
// return BorderSide(width: 1, color: appTheme.gray);
// },
// ),
// onChanged: (bool? value) {
// setState(() {
// isChecked = value ?? false;
// });
// },
// ),
// Expanded(
// child: Text(
// s.option_personal_diet_desc,
// style: TextStyles.textMMedium.copyWith(color: appTheme.black),
// ),
// ),
// ],
// ),
const SizedBox(height: 16),
Row(
children: [
Checkbox(
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
activeColor: appTheme.gray,
checkColor: appTheme.gray,
fillColor: const WidgetStatePropertyAll(Colors.transparent),
side: WidgetStateBorderSide.resolveWith(
(Set<WidgetState> states) {
return BorderSide(width: 1, color: appTheme.gray);
},
),
onChanged: (bool? value) {
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
},
),
Expanded(
child: Text(
s.option_personal_diet_desc,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
),
),
],
),
],
),
);

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
@@ -7,6 +8,7 @@ import 'package:eatvio_app/core/widgets/success_published_recipe_bottom_sheet.da
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/info_about_recipe_widget.dart';
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/ingredients_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/widgets/steps_widget.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
@@ -18,10 +20,7 @@ import 'package:go_router/go_router.dart';
class RecipeCreateScreen extends StatefulWidget {
final Recipe? recipe;
const RecipeCreateScreen({
super.key,
this.recipe,
});
const RecipeCreateScreen({super.key, this.recipe});
static const name = 'recipeCreateScreen';
static const path = '/recipe-create-screen';
@@ -32,7 +31,9 @@ class RecipeCreateScreen extends StatefulWidget {
class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
bool _isRecipeBeingPublished = false;
@override
void initState() {
super.initState();
@@ -52,20 +53,16 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
child: BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (BuildContext context, CreateRecipeState state) {
if (state.loading) {
return const Scaffold(
body: Loader(),
);
return const Scaffold(body: Loader());
} else {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
centerTitle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(12),
),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(12))),
),
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
@@ -75,61 +72,64 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
children: [
InfoAboutRecipeWidget(
createRecipeCubit: createRecipeCubit,
markdownEditorKey: _mdKey,
onMarkdownToolbarVisibilityChanged: (v) => setState(() => _mdToolbarVisible = v),
),
const IngredientsWidget(),
StepsWidget(
createRecipeCubit: createRecipeCubit,
),
StepsWidget(createRecipeCubit: createRecipeCubit),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(
title: state.entity.title ?? 'Рецепт',
return _isRecipeBeingPublished
? const Center(child: Loader())
: InkWell(
key: AppKeys.recipeCreateScreenPublishButton,
onTap: () async {
setState(() {
_isRecipeBeingPublished = true;
});
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
setState(() {
_isRecipeBeingPublished = false;
});
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
setState(() {
_isRecipeBeingPublished = false;
});
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
),
);
);
},
);
},

View File

@@ -1,4 +1,5 @@
import 'package:easy_debounce/easy_debounce.dart';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
@@ -12,13 +13,7 @@ import 'package:eatvio_app/features/glossary/models/glossary_model.dart';
import 'package:flutter/material.dart';
class AddPortionWidget extends StatefulWidget {
const AddPortionWidget({
super.key,
required this.createRecipeCubit,
this.portionIndex,
this.productNameControllerText,
this.quantityControllerText,
});
const AddPortionWidget({super.key, required this.createRecipeCubit, this.portionIndex, this.productNameControllerText, this.quantityControllerText});
final CreateRecipeCubit createRecipeCubit;
final String? productNameControllerText;
final String? quantityControllerText;
@@ -43,76 +38,65 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16) +
EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
padding: const EdgeInsets.all(16) + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Добавление блюда',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
),
const SizedBox(height: 16),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.max,
children: [
SizedBox(
width: MediaQuery.of(context).size.width * 0.8 - 32,
Expanded(
flex: 4,
child: CustomTextFormField(
key: AppKeys.recipeCreateScreenIngredientNameField,
controller: productNameController,
autofocus: true,
hintText: 'Введите название продукта/блюда',
onChanged: (query) {
EasyDebounce.debounce(
'glossary-search',
const Duration(milliseconds: 250),
() async {
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null;
setState(() {});
return;
}
glossariesList = await widget.createRecipeCubit.getGlossaryListByQuery(query);
setState(() {});
},
);
return;
}
glossariesList = await widget.createRecipeCubit.getGlossaryListByQuery(query);
setState(() {});
});
},
),
),
const SizedBox(width: 4),
SizedBox(
width: MediaQuery.of(context).size.width * 0.2 - 4,
Expanded(
flex: 1,
child: CustomTextFormField(
key: AppKeys.recipeCreateScreenIngredientQuantityField,
controller: quantityController,
keyboardType: TextInputType.number,
hintText: selectedGlossary == null
? '100'
: selectedGlossary!.grInPortion == 0
? '100'
: '1',
? '100'
: '1',
suffixText: selectedGlossary == null
? 'гр'
: selectedGlossary?.grInPortion == 0
? 'гр'
: 'шт',
? 'гр'
: 'шт',
),
),
],
),
glossariesList.isEmpty ? const SizedBox(height: 16) : const SizedBox.shrink(),
Container(
constraints: const BoxConstraints(
maxHeight: 256,
),
constraints: const BoxConstraints(maxHeight: 256),
child: ListView.separated(
physics: const BouncingScrollPhysics(),
shrinkWrap: true,
@@ -127,28 +111,12 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
setState(() {});
FocusManager.instance.primaryFocus?.nextFocus();
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
glossariesList[i].name,
style: const TextStyle(
height: 1,
),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(glossariesList[i].name, style: const TextStyle(height: 1)),
subtitle: glossariesList[i].grInPortion == 0
? null
: Text(
'${glossariesList[i].grInPortion} г',
style: TextStyle(
height: 1,
color: Colors.grey.shade600,
),
),
trailing: Icon(
Icons.add_rounded,
color: Colors.grey.shade400,
),
: Text('${glossariesList[i].grInPortion} г', style: TextStyle(height: 1, color: Colors.grey.shade600)),
trailing: Icon(Icons.add_rounded, color: Colors.grey.shade400),
contentPadding: const EdgeInsets.only(left: 12),
);
},
@@ -170,34 +138,23 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
child: Ink(
width: 52,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0xFFF6F6F6),
),
child: const Icon(
Icons.delete_outline_rounded,
color: Colors.red,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
child: const Icon(Icons.delete_outline_rounded, color: Colors.red),
),
),
if (widget.portionIndex != null) const SizedBox(width: 8),
InkWell(
onTap: () => addPortion(context),
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: Center(
child: Text(
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
Expanded(
child: InkWell(
key: AppKeys.recipeCreateScreenIngredientAddButton,
onTap: () => addPortion(context),
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Center(
child: Text(
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
),
),
@@ -215,8 +172,8 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
var measurementUnits = selectedGlossary?.grInPortion == null
? Portion.pcs
: selectedGlossary?.grInPortion == 0
? Portion.grams
: Portion.pcs;
? Portion.grams
: Portion.pcs;
final glossary = selectedGlossary;
if (glossary != null) {
final [calories, carbohydrates, proteins, fats] = _getCPFC(glossary, measurementUnits);
@@ -229,12 +186,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
);
if (portionIndex == null) {
widget.createRecipeCubit.addIngredient(
Ingredient(
name: portion.name,
unit: portion.portion.getValue(),
portion: portion.quantity.toString(),
productId: glossary.id,
),
Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: glossary.id),
);
if (context.mounted) {
Navigator.of(context).pop();
@@ -247,13 +199,8 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
useRootNavigator: true,
context: context,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: GlossaryModerationRequestModalWidget(
productName: productNameController.text,
diaryCubit: sl<DiaryCubit>(),
),
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: sl<DiaryCubit>()),
),
);
if (newGlossary != null) {
@@ -268,12 +215,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
);
if (portionIndex == null) {
widget.createRecipeCubit.addIngredient(
Ingredient(
name: portion.name,
unit: portion.portion.getValue(),
portion: portion.quantity.toString(),
productId: newGlossary.id,
),
Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: newGlossary.id),
);
if (context.mounted) {
Navigator.of(context).pop();
@@ -281,12 +223,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
} else {
widget.createRecipeCubit.updateIngredient(
index: portionIndex,
ingredient: Ingredient(
name: portion.name,
unit: portion.portion.getValue(),
portion: portion.quantity.toString(),
productId: newGlossary.id,
),
ingredient: Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: newGlossary.id),
);
}
}

View File

@@ -1,3 +1,5 @@
import 'package:eatvio_app/common/widgets/markdown_editor.dart';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/data/selectionPopupModel/selection_popup_model.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_drop_down.dart';
@@ -15,8 +17,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class InfoAboutRecipeWidget extends StatefulWidget {
final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState>? markdownEditorKey;
final void Function(bool visible)? onMarkdownToolbarVisibilityChanged;
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit});
const InfoAboutRecipeWidget({super.key, required this.createRecipeCubit, this.markdownEditorKey, this.onMarkdownToolbarVisibilityChanged});
@override
State<InfoAboutRecipeWidget> createState() => _InfoAboutRecipeWidgetState();
@@ -46,125 +50,126 @@ class _InfoAboutRecipeWidgetState extends State<InfoAboutRecipeWidget> {
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Название'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
builder: (context, state) {
return Column(
children: [
CustomTextFormField(
hintText: 'Например: тар-тар из говядины',
hasError: state.titleValidationError != null,
initialValue: state.entity.title,
onChanged: (value) {
widget.createRecipeCubit.replaceTitle(value);
if (state.titleValidationError != null) {
widget.createRecipeCubit.validateTitle();
}
},
),
if (state.titleValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.titleValidationError!,
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Название'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
bloc: widget.createRecipeCubit,
buildWhen: (previous, current) => previous.titleValidationError != current.titleValidationError,
builder: (context, state) {
return Column(
children: [
CustomTextFormField(
key: AppKeys.recipeCreateScreenTitleField,
hintText: 'Например: тар-тар из говядины',
hasError: state.titleValidationError != null,
initialValue: state.entity.title,
onChanged: (value) {
widget.createRecipeCubit.replaceTitle(value);
if (state.titleValidationError != null) {
widget.createRecipeCubit.validateTitle();
}
},
),
if (state.titleValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.titleValidationError!)],
],
],
);
},
),
const SizedBox(height: 12),
const Text('Тип блюда'),
const SizedBox(height: 4),
CustomDropDown(
items: items,
value: selectedType,
onChanged: (newSelectedType) {
setState(() {
selectedType = newSelectedType;
});
widget.createRecipeCubit.changeType(
TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other,
);
},
),
const SizedBox(height: 12),
const TagsWidget(),
const SizedBox(height: 12),
const PortionWidget(),
const SizedBox(height: 12),
const TimeOfCookWidget(),
const SizedBox(height: 12),
const Text('Описание'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
buildWhen: (previous, current) => previous.descriptionValidationError != current.descriptionValidationError,
builder: (context, state) {
return Column(
children: [
CustomTextFormField(
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
minLines: 5,
initialValue: state.entity.description,
maxLines: 5,
hasError: state.descriptionValidationError != null,
onChanged: (value) {
widget.createRecipeCubit.replaceDescription(value);
if (state.descriptionValidationError != null) {
widget.createRecipeCubit.validateDescription();
}
},
),
if (state.descriptionValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.descriptionValidationError!,
);
},
),
const SizedBox(height: 12),
const Text('Тип блюда'),
const SizedBox(height: 4),
CustomDropDown(
key: AppKeys.recipeCreateScreenTypeDropdown,
items: items,
value: selectedType,
onChanged: (newSelectedType) {
setState(() {
selectedType = newSelectedType;
});
widget.createRecipeCubit.changeType(TypeOfRecipe.fromString(selectedType.title) ?? TypeOfRecipe.other);
},
),
const SizedBox(height: 12),
const TagsWidget(),
const SizedBox(height: 12),
const PortionWidget(),
const SizedBox(height: 12),
const TimeOfCookWidget(),
const SizedBox(height: 12),
const Text('Описание'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
buildWhen: (previous, current) => previous.descriptionValidationError != current.descriptionValidationError,
builder: (context, state) {
return Column(
children: [
KeyedSubtree(
key: AppKeys.recipeCreateScreenDescriptionField,
child: MarkdownEditor(
key: widget.markdownEditorKey,
hintText: 'Расскажите, почему вы выбрали именно этот рецепт? Опишите, каким будет готовое блюдо и в чём его достоинства.',
minLines: 5,
maxLines: 5,
initialText: state.entity.description,
hasError: state.descriptionValidationError != null,
showInlineToolbar: false,
onToolbarVisibilityChanged: widget.onMarkdownToolbarVisibilityChanged,
onChanged: (value) {
widget.createRecipeCubit.replaceDescription(value);
if (state.descriptionValidationError != null) {
widget.createRecipeCubit.validateDescription();
}
},
),
),
if (state.descriptionValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionValidationError!),
],
],
],
);
},
),
const SizedBox(height: 12),
const Text('Фото обложка'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
buildWhen: (previous, current) => previous.imageValidationError != current.imageValidationError || previous.entity.image != current.entity.image,
builder: (context, state) {
return Column(
children: [
Container(
decoration: state.imageValidationError != null
? BoxDecoration(border: Border.all(width: 1, color: appTheme.red), borderRadius: BorderRadius.circular(8))
: null,
child: ImageWidget(image: state.entity.image),
),
if (state.imageValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.imageValidationError!,
);
},
),
const SizedBox(height: 12),
const Text('Фото обложка'),
const SizedBox(height: 4),
BlocBuilder<CreateRecipeCubit, CreateRecipeState>(
buildWhen: (previous, current) =>
previous.imageValidationError != current.imageValidationError || previous.entity.image != current.entity.image,
builder: (context, state) {
return Column(
children: [
Container(
decoration: state.imageValidationError != null
? BoxDecoration(
border: Border.all(width: 1, color: appTheme.red),
borderRadius: BorderRadius.circular(8),
)
: null,
child: ImageWidget(key: AppKeys.recipeCreateScreenImageWidget, image: state.entity.image),
),
if (state.imageValidationError != null) ...[const SizedBox(height: 4), ValidationErrorWidget(error: state.imageValidationError!)],
],
],
);
},
),
const SizedBox(height: 12),
const Text('Ссылка на видео (если есть)'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Прикрепите ссылку на видео',
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
onChanged: (value) {
widget.createRecipeCubit.replaceVideoUrl(value);
},
),
],
);
},
),
const SizedBox(height: 12),
const Text('Ссылка на видео (если есть)'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Прикрепите ссылку на видео',
initialValue: widget.createRecipeCubit.state.entity.videoUrl,
onChanged: (value) {
widget.createRecipeCubit.replaceVideoUrl(value);
},
),
],
),
),
),
);

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_state.dart';
@@ -40,11 +41,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Ингредиенты ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
),
),
ReorderableListView(
@@ -63,46 +60,29 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
key: Key('$index'),
leading: Icon(
Icons.drag_handle_rounded,
color: Colors.grey.shade600,
),
leading: Icon(Icons.drag_handle_rounded, color: Colors.grey.shade600),
title: Text(state.entity.ingredients[index].name),
subtitle: Text('${state.entity.ingredients[index].portion} ${state.entity.ingredients[index].unit == 'gr' ? 'гр' : 'шт'}'),
trailing: IconButton(
onPressed: () {
createRecipeCubit.removeIngredient(index);
},
icon: Icon(
Icons.delete_outline_rounded,
color: Colors.grey.shade600,
),
icon: Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600),
),
),
],
),
ListTile(
key: AppKeys.recipeCreateScreenAddIngredientButton,
onTap: () {
nameController.clear();
gramsController.clear();
pcsController.clear();
_modalBottomSheet(
createRecipeCubit: createRecipeCubit,
);
_modalBottomSheet(createRecipeCubit: createRecipeCubit);
},
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Text(
'Добавить ингредиент',
style: TextStyle(
color: appTheme.blueA400,
),
),
leading: Icon(
Icons.add_rounded,
color: appTheme.blueA400,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: Text('Добавить ингредиент', style: TextStyle(color: appTheme.blueA400)),
leading: Icon(Icons.add_rounded, color: appTheme.blueA400),
),
],
),
@@ -121,9 +101,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
return showModalBottomSheet(
useRootNavigator: true,
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
isScrollControlled: true,
builder: (context) {
return AddPortionWidget(

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
@@ -39,12 +40,13 @@ class _PortionWidgetState extends State<PortionWidget> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Кол-во порций'),
const Text('Количество порций'),
const SizedBox(height: 4),
Column(
children: [
CustomTextFormField(
hintText: '1',
key: AppKeys.recipeCreateScreenPortionsField,
hintText: 'Например: 1',
hasError: state.servingsValidationError != null,
initialValue: state.entity.countOfPortions?.toString(),
textInputType: TextInputType.number,
@@ -57,9 +59,7 @@ class _PortionWidgetState extends State<PortionWidget> {
),
if (state.servingsValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.servingsValidationError!,
),
ValidationErrorWidget(error: state.servingsValidationError!),
],
],
),
@@ -72,11 +72,12 @@ class _PortionWidgetState extends State<PortionWidget> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Ккал в одной порции'),
const Text('Калорийность'),
const SizedBox(height: 4),
Column(
children: [
CustomTextFormField(
key: AppKeys.recipeCreateScreenCaloriesField,
hintText: '100',
hasError: state.energyPerServingValidationError != null,
initialValue: state.entity.energyInOnePortion?.toString(),
@@ -91,9 +92,7 @@ class _PortionWidgetState extends State<PortionWidget> {
),
if (state.energyPerServingValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.energyPerServingValidationError!,
),
ValidationErrorWidget(error: state.energyPerServingValidationError!),
],
],
),

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
@@ -22,107 +23,92 @@ class _StepsWidgetState extends State<StepsWidget> {
bloc: widget.createRecipeCubit,
builder: (BuildContext context, CreateRecipeState state) {
return Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Пошаговая инструкция',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Пошаговая инструкция',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)),
),
),
const SizedBox(height: 12),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Шаг ${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
GestureDetector(
onTap: () {
context.read<CreateRecipeCubit>().removeStep(state.entity.steps[index]);
},
child: const Icon(Icons.delete_outline_rounded),
),
],
),
const SizedBox(height: 12),
const Text('Описание'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Например: подготовьте ингредиенты',
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
maxLines: 5,
minLines: 3,
onChanged: (value) {
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
},
),
const SizedBox(height: 12),
const Text('Фото шага'),
const SizedBox(height: 4),
StepImageWidget(
index: index,
createRecipeCubit: widget.createRecipeCubit,
),
],
),
itemCount: state.entity.steps.length,
separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24),
),
InkWell(
onTap: () {
context.read<CreateRecipeCubit>().addStep();
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(
color: appTheme.blueA400,
borderRadius: BorderRadius.circular(20),
),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const Text(
'Добавить шаг',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w600,
const SizedBox(height: 12),
InkWell(
key: AppKeys.recipeCreateScreenAddStepButton,
onTap: () {
context.read<CreateRecipeCubit>().addStep();
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: const Text(
'Добавить шаг',
style: TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
),
),
),
),
],
const SizedBox(height: 12),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(8)),
child: Text(
'Шаг ${index + 1}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600),
),
),
GestureDetector(
onTap: () {
context.read<CreateRecipeCubit>().removeStep(state.entity.steps[index]);
},
child: const Icon(Icons.delete_outline_rounded),
),
],
),
const SizedBox(height: 12),
const Text('Описание'),
const SizedBox(height: 4),
CustomTextFormField(
key: AppKeys.recipeCreateScreenStepDescriptionField,
hintText: 'Например: подготовьте ингредиенты',
initialValue: widget.createRecipeCubit.state.entity.steps[index].description,
maxLines: 5,
minLines: 3,
onChanged: (value) {
widget.createRecipeCubit.replaceDescriptionInStep(value, index);
},
),
const SizedBox(height: 12),
const Text('Фото шага'),
const SizedBox(height: 4),
StepImageWidget(
key: Key('${AppKeys.recipeCreateScreenStepImageWidget}_$index'),
index: index,
createRecipeCubit: widget.createRecipeCubit,
),
],
),
itemCount: state.entity.steps.length,
separatorBuilder: (BuildContext context, int index) => const SizedBox(height: 24),
),
],
),
),
),
);

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart';
@@ -26,9 +27,11 @@ class _TagsWidgetState extends State<TagsWidget> {
const Text('Теги'),
const SizedBox(height: 4),
CustomTextFormField(
key: AppKeys.recipeCreateScreenTagField,
hintText: 'Например: без майонеза',
controller: tagController,
suffix: IconButton(
key: AppKeys.recipeCreateScreenAddTagButton,
onPressed: () {
if (tagController.text.isNotEmpty) {
context.read<CreateRecipeCubit>().addTag(tagController.text);
@@ -37,10 +40,7 @@ class _TagsWidgetState extends State<TagsWidget> {
});
}
},
icon: Icon(
Icons.done,
color: appTheme.blueA400,
),
icon: Icon(Icons.done, color: appTheme.blueA400),
),
),
const SizedBox(height: 12),
@@ -56,22 +56,13 @@ class _TagsWidgetState extends State<TagsWidget> {
borderRadius: BorderRadius.circular(16),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.grey.shade200,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: Colors.grey.shade200),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.entity.tags[i],
),
Text(state.entity.tags[i]),
const SizedBox(width: 4),
Icon(
Icons.delete_outline_rounded,
color: Colors.grey.shade600,
size: 20,
),
Icon(Icons.delete_outline_rounded, color: Colors.grey.shade600, size: 20),
],
),
),

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
@@ -43,20 +44,12 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
builder: (context) => const AlertDialog(
content: Text(
'Общее время приготовления - это период времени от начала приготовления блюда до подачи его на стол.',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Color(0xFF323F4B),
height: 1.2,
),
style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2),
),
),
);
},
child: Icon(
Icons.help_outline_rounded,
color: appTheme.blueA400,
size: 20,
),
child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20),
),
],
),
@@ -66,9 +59,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Часы'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Часов',
key: AppKeys.recipeCreateScreenTotalHoursField,
hintText: '',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookHours?.toString(),
@@ -83,9 +80,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
),
if (state.cookingTimeValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.cookingTimeValidationError!,
),
ValidationErrorWidget(error: state.cookingTimeValidationError!),
],
],
),
@@ -93,9 +88,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Минуты'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Минут',
key: AppKeys.recipeCreateScreenTotalMinutesField,
hintText: '',
hasError: state.cookingTimeValidationError != null,
textInputType: TextInputType.number,
initialValue: state.entity.totalCookMinutes?.toString(),
@@ -110,9 +109,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
),
if (state.cookingTimeValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.cookingTimeValidationError!,
),
ValidationErrorWidget(error: state.cookingTimeValidationError!),
],
],
),
@@ -131,20 +128,12 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
builder: (context) => const AlertDialog(
content: Text(
'Активное время приготовления - это период времени, требующий активного участия в приготовлении блюда. Оно включает мытье и очистку продуктов, нарезку, шинковку, взбивание, перемешивание, размешивание, перекладывание и т.п.',
style: TextStyle(
fontWeight: FontWeight.w500,
color: Color(0xFF323F4B),
height: 1.2,
),
style: TextStyle(fontWeight: FontWeight.w500, color: Color(0xFF323F4B), height: 1.2),
),
),
);
},
child: Icon(
Icons.help_outline_rounded,
color: appTheme.blueA400,
size: 20,
),
child: Icon(Icons.help_outline_rounded, color: appTheme.blueA400, size: 20),
),
],
),
@@ -154,9 +143,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Часы'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Часов',
key: AppKeys.recipeCreateScreenActiveHoursField,
hintText: '',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookHours?.toString(),
textInputType: TextInputType.number,
@@ -172,9 +165,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
),
if (state.activeTimeValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.activeTimeValidationError!,
),
ValidationErrorWidget(error: state.activeTimeValidationError!),
],
],
),
@@ -182,9 +173,13 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Минуты'),
const SizedBox(height: 4),
CustomTextFormField(
hintText: 'Минут',
key: AppKeys.recipeCreateScreenActiveMinutesField,
hintText: '',
hasError: state.activeTimeValidationError != null,
initialValue: state.entity.activeCookMinutes?.toString(),
textInputType: TextInputType.number,
@@ -200,9 +195,7 @@ class _TimeOfCookWidgetState extends State<TimeOfCookWidget> {
),
if (state.activeTimeValidationError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(
error: state.activeTimeValidationError!,
),
ValidationErrorWidget(error: state.activeTimeValidationError!),
],
],
),

View File

@@ -44,16 +44,24 @@ class UserDiaryScreen extends StatelessWidget {
}
}
class DiaryScreenContent extends StatelessWidget {
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay});
final int? diaryId;
final int? currentPlanDay;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: diaryId)),
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
@@ -63,13 +71,13 @@ class DiaryScreenContent extends StatelessWidget {
}
if (state is DiaryStateError) {
return Scaffold(
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: diaryId)),
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
);
}
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: diaryId == null
appBar: widget.diaryId == null
? AppBar(toolbarHeight: 0)
: AppBar(
title: Text(
@@ -83,7 +91,7 @@ class DiaryScreenContent extends StatelessWidget {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
if (diaryId == null) const CalendarWidget(),
if (widget.diaryId == null) const CalendarWidget(),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(
@@ -116,7 +124,23 @@ class DiaryScreenContent extends StatelessWidget {
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FinishDayButtonWidget(diaryDay: state.diary),
child: _isDiaryFinishing
? const Center(child: Loader())
: FinishDayButtonWidget(
onPressed: () async {
setState(() {
_isDiaryFinishing = true;
});
await context.read<DiaryCubit>().finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
setState(() {
_isDiaryFinishing = false;
});
},
),
),
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
const SizedBox(height: 16),

View File

@@ -1,13 +1,10 @@
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FinishDayButtonWidget extends StatefulWidget {
final DiaryDayEntity diaryDay;
final Future<void> Function() onPressed;
const FinishDayButtonWidget({super.key, required this.diaryDay});
const FinishDayButtonWidget({super.key, required this.onPressed});
@override
State<FinishDayButtonWidget> createState() => _FinishDayButtonWidgetState();
@@ -17,11 +14,7 @@ class _FinishDayButtonWidgetState extends State<FinishDayButtonWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => context.read<DiaryCubit>().finishDiaryDay(
diaryId: widget.diaryDay.id,
wakeUp: widget.diaryDay.wakeup.toString(),
retire: widget.diaryDay.retire.toString(),
),
onTap: widget.onPressed,
borderRadius: BorderRadius.circular(20),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 8),

View File

@@ -4,22 +4,24 @@ import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart'
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
class AuthorCard extends StatelessWidget {
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle});
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
final Author author;
final String title;
final Color? backgroundColor;
final TextStyle? titleStyle;
final ProfileModel? customerProfile;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: author);
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
},
child: Container(
width: MediaQuery.sizeOf(context).width,
@@ -27,7 +29,7 @@ class AuthorCard extends StatelessWidget {
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24),
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
child: Column(
children: [
ListTile(
@@ -39,24 +41,51 @@ class AuthorCard extends StatelessWidget {
shape: BoxShape.circle,
image: DecorationImage(
image: NetworkImage(
author.authorProfileIcon.contains('https') ? author.authorProfileIcon : sl<AppConfig>().baseUrl + author.authorProfileIcon,
customerProfile != null
? customerProfile!.profilePhotoUrl.contains('https')
? customerProfile!.profilePhotoUrl
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
: author.authorProfileIcon.contains('https')
? author.authorProfileIcon
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
),
fit: BoxFit.cover,
),
),
),
subtitle: Text(author.authorName, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
subtitle: Text(
customerProfile != null ? customerProfile!.name : author.authorName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
),
Row(
children: [
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
const SizedBox(width: 10),
if (author.authorPlaceInRating != null)
InfoBouble(
radius: 12,
backgroundColor: appTheme.whiteA700,
children: [Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
if (customerProfile == null) ...[
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
const SizedBox(width: 10),
if (author.authorPlaceInRating != null)
InfoBouble(
radius: 12,
backgroundColor: appTheme.cardBackground,
children: [
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
],
),
] else
Expanded(
child: GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
},
child: InfoBouble(
radius: 12,
backgroundColor: appTheme.cardBackground,
mainAxisAlignment: MainAxisAlignment.center,
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
),
),
),
],
),

View File

@@ -13,8 +13,15 @@ class CalendarWidget extends StatefulWidget {
class _CalendarWidgetState extends State<CalendarWidget> {
CalendarFormat _calendarFormat = CalendarFormat.week;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay = DateTime.now();
late DateTime _focusedDay;
DateTime? _selectedDay;
@override
void initState() {
super.initState();
_selectedDay = widget.startDate.isEmpty ? DateTime.now() : widget.startDate[0];
_focusedDay = widget.startDate.isEmpty ? DateTime.now() : widget.startDate[0];
}
@override
Widget build(BuildContext context) {
return Column(
@@ -23,7 +30,7 @@ class _CalendarWidgetState extends State<CalendarWidget> {
Padding(
padding: const EdgeInsets.only(top: 5),
child: TableCalendar(
firstDay: DateTime.now(),
firstDay: widget.startDate.isEmpty ? DateTime.now() : widget.startDate[0],
lastDay: DateTime.utc(2030, 3, 14),
locale: 'ru_RU',
startingDayOfWeek: StartingDayOfWeek.monday,

View File

@@ -2,20 +2,25 @@ import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
class InfoBouble extends StatelessWidget {
const InfoBouble({super.key, required this.children, this.backgroundColor, this.radius = 100, this.padding});
const InfoBouble({
super.key,
required this.children,
this.backgroundColor,
this.radius = 100,
this.padding,
this.mainAxisAlignment = MainAxisAlignment.start,
});
final List<Widget> children;
final Color? backgroundColor;
final double radius;
final EdgeInsetsGeometry? padding;
final MainAxisAlignment mainAxisAlignment;
@override
Widget build(BuildContext context) {
return Container(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 8.5),
decoration: BoxDecoration(
color: backgroundColor ?? appTheme.gray50,
borderRadius: BorderRadius.circular(radius),
),
child: Row(mainAxisSize: MainAxisSize.min, children: children),
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray50, borderRadius: BorderRadius.circular(radius)),
child: Row(mainAxisSize: MainAxisSize.min, mainAxisAlignment: mainAxisAlignment, children: children),
);
}
}

View File

@@ -67,11 +67,19 @@ abstract class PacketOptions with _$PacketOptions {
@JsonKey(name: "price") required dynamic price,
@JsonKey(name: "short") required String short,
@JsonKey(name: "description_md") required String description,
@JsonKey(name: "has_personal_diet", fromJson: _parseHasPersonalDietField) required bool hasPersonalDiet,
}) = _PacketOptions;
factory PacketOptions.fromJson(Map<String, dynamic> json) => _$PacketOptionsFromJson(json);
factory PacketOptions.empty() => PacketOptions(items: [], price: double.parse('1200'), short: '', description: '');
factory PacketOptions.empty() => PacketOptions(items: [], price: double.parse('1200'), short: '', description: '', hasPersonalDiet: false);
}
bool _parseHasPersonalDietField(dynamic json) {
if (json == null) return false;
if (json == '1') return true;
if (json == '0') return false;
return json as bool;
}
List<String> _parseItems(dynamic json) {

View File

@@ -1,8 +1,11 @@
import 'package:easy_debounce/easy_debounce.dart';
import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
import 'package:eatvio_app/core/widgets/add_item_button.dart';
import 'package:eatvio_app/core/widgets/custom_search_bar.dart';
import 'package:eatvio_app/core/widgets/marketing_bottom_sheet.dart';
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/consultants_cubit/consultants_cubit.dart';
@@ -70,6 +73,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
void initState() {
chipsLabelList = hidePlans ? ['Все', 'Рецепты', 'Диеты', 'Консультанты'] : ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты'];
consultantsScrollController.addListener(_onScrollConsultants);
_checkRegistrationDate(context);
super.initState();
}
@@ -77,6 +81,15 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
if (consultantsScrollController.offset >= consultantsScrollController.position.maxScrollExtent && !consultantsScrollController.position.outOfRange) {}
}
void _checkRegistrationDate(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
final needToShowMarketingBottomSheet = await context.read<MarketingCubit>().checkRegistrationDate();
if (needToShowMarketingBottomSheet && context.mounted) {
showMarketingBottomSheet(context);
}
});
}
@override
void dispose() {
blogPostsScrollController.dispose();
@@ -215,7 +228,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
3 when !hidePlans => AddItemButton(
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null,
),
_ => const AddItemButton(),
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
},
],
),

View File

@@ -8,7 +8,6 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
@@ -18,6 +17,7 @@ import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class BlogPostWidget extends StatefulWidget {
final int index;
@@ -58,17 +58,7 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
children: [
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: state.feedBlogPostList[widget.index].author.authorSlug,
authorName: state.feedBlogPostList[widget.index].author.authorName,
authorProfileIcon: state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
? state.feedBlogPostList[widget.index].author.authorProfileIcon
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
authorIsMe: state.feedBlogPostList[widget.index].author.authorIsMe,
),
);
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
},
child: CircleAvatar(
radius: 20,
@@ -134,14 +124,39 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
const SizedBox(height: 8),
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Html(
data:
(state.feedBlogPostList[widget.index].description.length > 200
? state.feedBlogPostList[widget.index].description.substring(0, 200)
: state.feedBlogPostList[widget.index].description)
.replaceAll('\n', '<br>'),
style: {"*": Style(fontSize: FontSize(16), color: Colors.black87)},
data: md.markdownToHtml(
(state.feedBlogPostList[widget.index].description.length > 200
? state.feedBlogPostList[widget.index].description.substring(0, 200).replaceAll('\n', '<br>')
: state.feedBlogPostList[widget.index].description.replaceAll('\n', '<br>')),
),
style: {
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
if (state.feedBlogPostList[widget.index].description.length > 200)

View File

@@ -8,7 +8,6 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/prof
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
import 'package:eatvio_app/features/feed/models/comment_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -96,12 +95,7 @@ class _CommentsWidgetState extends State<CommentsWidget> {
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: comment.authorSlug,
authorName: comment.authorName,
authorProfileIcon: comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon,
authorIsMe: isMe,
),
extra: comment.authorSlug,
);
},
child: CircleAvatar(

View File

@@ -2,9 +2,6 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/profile/models/badge_model.dart';
import 'package:flutter/material.dart';
@@ -22,12 +19,7 @@ class ConsultantWidget extends StatelessWidget {
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: consultant.slug,
authorName: consultant.name,
authorProfileIcon: consultant.photo,
authorIsMe: (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == consultant.slug ? true : false,
),
extra: consultant.slug,
);
},
borderRadius: BorderRadius.circular(20),

View File

@@ -17,15 +17,12 @@ import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class DietsWidget extends StatefulWidget {
class DietsWidget extends StatefulWidget {
final Diet diet;
final int index;
const DietsWidget({
super.key,
required this.diet,
required this.index,
});
const DietsWidget({super.key, required this.diet, required this.index});
@override
State<DietsWidget> createState() => _DietsWidgetState();
@@ -33,18 +30,11 @@ class DietsWidget extends StatefulWidget {
class _DietsWidgetState extends State<DietsWidget> {
bool isMoreOptionsVisible = false;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => GoRouter.of(context).pushNamed(
'aboutDietScreen',
extra: {
'slug': widget.diet.slug,
'index': widget.index,
'navigateToComments': false,
},
),
onTap: () => GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'index': widget.index, 'navigateToComments': false}),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -104,12 +94,7 @@ class _DietsWidgetState extends State<DietsWidget> {
bottom: 16,
child: SizedBox(
width: MediaQuery.sizeOf(context).width,
child: InfoRow(
diet: widget.diet,
itemsBackgroundColor: appTheme.whiteA700,
disableDescriptionOfTypes: true,
type: ProductType.diet,
),
child: InfoRow(diet: widget.diet, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.diet),
),
),
],
@@ -120,26 +105,48 @@ class _DietsWidgetState extends State<DietsWidget> {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.diet.name,
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
child: Text(widget.diet.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black)),
),
if (widget.diet.details.isNotEmpty) ...[
const SizedBox(
height: 12,
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200).replaceAll('\n', '<br>')}...' : widget.diet.details.replaceAll('\n', '<br>'),
data: md.markdownToHtml(
widget.diet.details.length > 200
? '${widget.diet.details.substring(0, 200).replaceAll('\n', '<br>')}...'
: widget.diet.details.replaceAll('\n', '<br>'),
),
style: {
"*": Style(
"body": Style(
margin: Margins.zero,
color: appTheme.black,
padding: HtmlPaddings.zero,
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
fontWeight: TextStyles.textSMedium.fontWeight ?? FontWeight.w400,
lineHeight: const LineHeight(1.5),
),
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
@@ -149,12 +156,7 @@ class _DietsWidgetState extends State<DietsWidget> {
padding: EdgeInsets.only(left: 16, top: 8),
child: Text(
'Показать ещё',
style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
],
@@ -172,18 +174,14 @@ class _DietsWidgetState extends State<DietsWidget> {
await context.read<DietsCubit>().likeDiet(widget.diet.id, widget.index);
},
),
const SizedBox(
width: 12,
),
const SizedBox(width: 12),
FeedIconButton(
icon: Icons.chat_bubble_outline,
text: widget.diet.totalComments.toString(),
onTap: () =>
GoRouter.of(context).pushNamed('aboutDietScreen', extra: {'slug': widget.diet.slug, 'index': widget.index, 'navigateToComments': true}),
),
const SizedBox(
width: 12,
),
const SizedBox(width: 12),
FeedIconButton(
svgIcon: SvgPicture.asset('assets/icons/repost.svg'),
onTap: () async {

View File

@@ -8,7 +8,6 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/info_row.dart';
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -20,16 +19,13 @@ import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class PlanWidget extends StatefulWidget {
final PlanModel plan;
final int index;
const PlanWidget({
super.key,
required this.plan,
required this.index,
});
const PlanWidget({super.key, required this.plan, required this.index});
@override
State<PlanWidget> createState() => _PlanWidgetState();
@@ -42,14 +38,7 @@ class _PlanWidgetState extends State<PlanWidget> {
Widget build(BuildContext context) {
final s = S.of(context);
return InkWell(
onTap: () => GoRouter.of(context).pushNamed(
'aboutPlanScreen',
extra: {
'slug': widget.plan.slug,
'index': widget.index,
'navigateToComments': false,
},
),
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -110,12 +99,7 @@ class _PlanWidgetState extends State<PlanWidget> {
bottom: 16,
child: SizedBox(
width: MediaQuery.sizeOf(context).width,
child: InfoRow(
plan: widget.plan,
itemsBackgroundColor: appTheme.whiteA700,
disableDescriptionOfTypes: true,
type: ProductType.plan,
),
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan),
),
),
],
@@ -126,26 +110,43 @@ class _PlanWidgetState extends State<PlanWidget> {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.plan.name,
style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18),
),
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)),
),
if (widget.plan.details.isNotEmpty) ...[
const SizedBox(
height: 12,
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200).replaceAll('\n', '<br>')}...' : widget.plan.details.replaceAll('\n', '<br>'),
data: md.markdownToHtml(
widget.plan.details.length > 200
? '${widget.plan.details.substring(0, 200).replaceAll('\n', '<br>')}...'
: widget.plan.details.replaceAll('\n', '<br>'),
),
style: {
"*": Style(
margin: Margins.zero,
color: appTheme.black,
fontSize: FontSize(16),
fontWeight: TextStyles.textSMedium.fontWeight ?? FontWeight.w400,
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
@@ -154,12 +155,7 @@ class _PlanWidgetState extends State<PlanWidget> {
padding: EdgeInsets.only(left: 16, top: 8),
child: Text(
'Показать ещё',
style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
],
@@ -168,15 +164,7 @@ class _PlanWidgetState extends State<PlanWidget> {
const SizedBox(height: 16),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: widget.plan.author.authorSlug,
authorName: widget.plan.author.authorName,
authorProfileIcon: widget.plan.author.authorProfileIcon,
authorIsMe: widget.plan.author.authorIsMe,
),
);
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
},
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
@@ -187,10 +175,7 @@ class _PlanWidgetState extends State<PlanWidget> {
height: 46,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(
image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon),
fit: BoxFit.cover,
),
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover),
),
),
subtitle: Row(
@@ -200,12 +185,7 @@ class _PlanWidgetState extends State<PlanWidget> {
backgroundColor: appTheme.gray50,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
children: [
Text(
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.blueA400,
),
),
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)),
],
),
const Spacer(),
@@ -218,21 +198,14 @@ class _PlanWidgetState extends State<PlanWidget> {
),
title: Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(
widget.plan.author.authorName,
style: TextStyles.textSSemibold.copyWith(
color: appTheme.black,
),
),
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
),
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(
left: 16,
),
padding: const EdgeInsets.only(left: 16),
child: Row(
children: [
FeedIconButton(
@@ -244,20 +217,17 @@ class _PlanWidgetState extends State<PlanWidget> {
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index);
},
),
const SizedBox(
width: 12,
),
const SizedBox(width: 12),
FeedIconButton(
icon: Icons.chat_bubble_outline,
text: widget.plan.totalComments.toString(),
onTap: () {
return GoRouter.of(context)
.pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
onTap: () {
return GoRouter.of(
context,
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
},
),
const SizedBox(
width: 12,
),
const SizedBox(width: 12),
FeedIconButton(
svgIcon: SvgPicture.asset('assets/icons/repost.svg'),
onTap: () async {

View File

@@ -20,16 +20,13 @@ import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class RecipesWidget extends StatefulWidget {
final int index;
final Recipe recipe;
const RecipesWidget({
super.key,
required this.recipe,
required this.index,
});
const RecipesWidget({super.key, required this.recipe, required this.index});
@override
State<RecipesWidget> createState() => _RecipesWidgetState();
@@ -56,14 +53,8 @@ class _RecipesWidgetState extends State<RecipesWidget> {
}
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed(
'aboutRecipesScreen',
extra: {
'recipe': widget.recipe.slug,
'index': widget.index,
'navigateToComments': false,
},
),
onTap: () =>
GoRouter.of(context).pushNamed('aboutRecipesScreen', extra: {'recipe': widget.recipe.slug, 'index': widget.index, 'navigateToComments': false}),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -139,10 +130,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.recipe.title ?? '',
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
child: Text(widget.recipe.title ?? '', style: TextStyles.textMSemibold.copyWith(color: appTheme.black)),
),
if (widget.recipe.videoUrl != null && widget.recipe.videoUrl!.isNotEmpty) ...[
const SizedBox(height: 12),
@@ -152,10 +140,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
children: [
SvgPicture.asset('assets/icons/play_icon.svg', width: 16, height: 16),
const SizedBox(width: 8),
Text(
s.there_is_a_video,
style: TextStyles.textXSSemibold.copyWith(color: appTheme.black),
),
Text(s.there_is_a_video, style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)),
],
),
),
@@ -165,17 +150,41 @@ class _RecipesWidgetState extends State<RecipesWidget> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: widget.recipe.description.length > 200
? widget.recipe.description.substring(0, 200).replaceAll('\n', '<br>')
: widget.recipe.description.replaceAll('\n', '<br>'),
data: md.markdownToHtml(
widget.recipe.description.length > 200
? widget.recipe.description.substring(0, 200).replaceAll('\n', '<br>')
: widget.recipe.description.replaceAll('\n', '<br>'),
),
style: {
"*": Style(
color: appTheme.black,
margin: Margins.all(0),
fontFamily: TextStyles.textSMedium.fontFamily,
"body": Style(
margin: Margins.zero,
padding: HtmlPaddings.zero,
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
fontWeight: TextStyles.textSMedium.fontWeight,
lineHeight: const LineHeight(1.5),
),
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
@@ -184,12 +193,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
padding: EdgeInsets.only(left: 16, top: 8),
child: Text(
'Показать ещё',
style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 12),
@@ -211,14 +215,9 @@ class _RecipesWidgetState extends State<RecipesWidget> {
FeedIconButton(
icon: Icons.chat_bubble_outline,
text: widget.recipe.commentsCount.toString(),
onTap: () => GoRouter.of(context).pushNamed(
'aboutRecipesScreen',
extra: {
'recipe': widget.recipe.slug,
'index': widget.index,
'navigateToComments': true,
},
),
onTap: () => GoRouter.of(
context,
).pushNamed('aboutRecipesScreen', extra: {'recipe': widget.recipe.slug, 'index': widget.index, 'navigateToComments': true}),
),
const SizedBox(width: 8),
FeedIconButton(
@@ -239,19 +238,11 @@ class _RecipesWidgetState extends State<RecipesWidget> {
},
),
const Spacer(),
Icon(
Icons.visibility_rounded,
size: 16,
color: appTheme.gray500,
),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
widget.recipe.viewsCount.toString(),
style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
@@ -268,26 +259,17 @@ class _RecipesWidgetState extends State<RecipesWidget> {
Container _iconButton(String iconPath, [String? text]) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: appTheme.whiteA700,
borderRadius: BorderRadius.circular(16),
),
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(16)),
height: 32,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
iconPath,
),
SvgPicture.asset(iconPath),
if (text != null) const SizedBox(width: 8),
if (text != null)
Text(
text,
style: const TextStyle(
fontSize: 12,
color: Colors.black,
fontWeight: FontWeight.w500,
),
style: const TextStyle(fontSize: 12, color: Colors.black, fontWeight: FontWeight.w500),
),
],
),

View File

@@ -1,6 +1,5 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:flutter/material.dart';
@@ -21,13 +20,7 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
return InkWell(
onTap: () => GoRouter.of(context).pushNamed(
'profileScreen',
extra: Author(
authorSlug: widget.profile.slug,
authorName: widget.profile.name,
authorProfileIcon: widget.profile.profilePhotoUrl,
authorIsMe: false,
isEntitySubscribedByMe: false,
),
extra: widget.profile.slug,
),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),

View File

@@ -17,6 +17,7 @@ import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_cubit.d
import 'package:eatvio_app/features/my_diets/presentation/cubit/my_diets_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_deleted_bottom_sheet.dart';
import 'package:eatvio_app/features/my_diets/presentation/widgets/diet_drafetd_bottom_sheet.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -24,14 +25,12 @@ import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class MyDietWidget extends StatefulWidget {
final Diet diet;
const MyDietWidget({
super.key,
required this.diet,
});
const MyDietWidget({super.key, required this.diet});
@override
State<MyDietWidget> createState() => _MyDietWidgetState();
@@ -42,10 +41,7 @@ class _MyDietWidgetState extends State<MyDietWidget> {
Widget build(BuildContext context) {
final s = S.of(context);
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@@ -78,26 +74,48 @@ class _MyDietWidgetState extends State<MyDietWidget> {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.diet.name,
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
child: Text(widget.diet.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black)),
),
if (widget.diet.details.isNotEmpty) ...[
const SizedBox(
height: 12,
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: widget.diet.details.length > 200 ? '${widget.diet.details.substring(0, 200).replaceAll('\n', '<br>')}...' : widget.diet.details.replaceAll('\n', '<br>'),
data: md.markdownToHtml(
widget.diet.details.length > 200
? '${widget.diet.details.substring(0, 200).replaceAll('\n', '<br>')}...'
: widget.diet.details.replaceAll('\n', '<br>'),
),
style: {
"*": Style(
"body": Style(
margin: Margins.zero,
color: appTheme.black,
padding: HtmlPaddings.zero,
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
fontWeight: TextStyles.textSMedium.fontWeight ?? FontWeight.w400,
lineHeight: const LineHeight(1.5),
),
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),
@@ -129,6 +147,7 @@ class _MyDietWidgetState extends State<MyDietWidget> {
showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время');
}
} else if (state.selectedStateIndex == 2) {
GoRouter.of(context).pushNamed(PersonalDietSuggestionScreen.name, extra: widget.diet.id);
} else {
GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: widget.diet);
}
@@ -230,11 +249,7 @@ class _MyDietWidgetState extends State<MyDietWidget> {
},
height: 52,
width: 52,
leftIcon: SvgPicture.asset(
'assets/icons/trash_can.svg',
width: 20,
height: 20,
),
leftIcon: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 20),
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.red),
buttonStyle: ButtonStyle(
shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),

View File

@@ -18,6 +18,7 @@ import 'package:flutter_html/flutter_html.dart';
// Removed SVG icon usage after switching to text buttons
import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart';
import 'package:markdown/markdown.dart' as md;
class MyPlanWidget extends StatefulWidget {
final PlanModel plan;
@@ -43,7 +44,11 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'navigateToComments': false}),
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}', fit: BoxFit.fitWidth, width: MediaQuery.of(context).size.width),
child: CachedNetworkImage(
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
),
),
),
Positioned(
@@ -68,16 +73,41 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Html(
data: widget.plan.details.length > 200
? '${widget.plan.details.substring(0, 200).replaceAll('\n', '<br>')}...'
: widget.plan.details.replaceAll('\n', '<br>'),
data: md.markdownToHtml(
widget.plan.details.length > 200
? '${widget.plan.details.substring(0, 200).replaceAll('\n', '<br>')}...'
: widget.plan.details.replaceAll('\n', '<br>'),
),
style: {
"*": Style(
"body": Style(
margin: Margins.zero,
color: appTheme.black,
padding: HtmlPaddings.zero,
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
fontWeight: TextStyles.textSMedium.fontWeight ?? FontWeight.w400,
lineHeight: const LineHeight(1.5),
),
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
"h1": Style(fontSize: FontSize(24)),
"h2": Style(fontSize: FontSize(20)),
"h3": Style(fontSize: FontSize(18)),
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
"em, i": Style(fontStyle: FontStyle.italic),
"code": Style(
backgroundColor: Colors.grey.shade100,
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
fontFamily: 'monospace',
),
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
"blockquote": Style(
padding: HtmlPaddings.only(left: 16),
margin: Margins.only(left: 8, bottom: 12),
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
},
),
),

View File

@@ -316,7 +316,7 @@ class ChosenPlanFinishedToConsultantNotification implements BaseNotificationMode
type: type,
planName: json['plan_name'],
customerName: json['client_name'],
planId: json['plan_id'],
planId: json['chosen_plan_id'],
isRead: isRead ?? false,
createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()),
);
@@ -1695,6 +1695,35 @@ class DiaryReminderForUsersNotification implements BaseNotificationModel {
}
}
class PersonalDietIsDefinedToCustomerNotification implements BaseNotificationModel {
@override
final NotificationType type;
@override
final bool isRead;
@override
final DateTime createdAt;
final String dietName;
final int dietId;
PersonalDietIsDefinedToCustomerNotification({
required this.type,
required this.isRead,
required this.createdAt,
required this.dietName,
required this.dietId,
});
factory PersonalDietIsDefinedToCustomerNotification.fromJson(Map<String, dynamic> json, NotificationType type, {bool? isRead, String? createdAt}) {
return PersonalDietIsDefinedToCustomerNotification(
type: type,
isRead: isRead ?? false,
createdAt: parseDate(createdAt ?? json['created_at'] ?? DateTime.now().toIso8601String()),
dietName: json['diet_name'] ?? '',
dietId: json['diet_id'],
);
}
}
enum NotificationType {
// Diet related
dietCancelled,
@@ -1726,6 +1755,7 @@ enum NotificationType {
userDailyReportCompleted,
chosenPlanFinishedToConsultant,
stayOnTrackToCustomer,
personalDietIsDefinedToCustomer,
// Comments
entityCommented,
@@ -1834,6 +1864,7 @@ enum NotificationType {
'chosen-plan-finished-to-consultant' => NotificationType.chosenPlanFinishedToConsultant,
'stay-on-track-to-customer' => NotificationType.stayOnTrackToCustomer,
'diary-reminder-for-users' => NotificationType.diaryReminderForUsers,
'personal-diet-is-defined-to-customer' => NotificationType.personalDietIsDefinedToCustomer,
// Legacy mapping
'App\\Notifications\\Plan\\PlanSubscriptionStartedToAuthor' => NotificationType.planSubscriptionStartedToAuthor,

View File

@@ -261,6 +261,12 @@ class NotificationsCubit extends Cubit<NotificationsState> {
createdAt: createdAt,
),
NotificationType.diaryReminderForUsers => DiaryReminderForUsersNotification.fromJson(notificationJson, type, isRead: isRead, createdAt: createdAt),
NotificationType.personalDietIsDefinedToCustomer => PersonalDietIsDefinedToCustomerNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type),
};
} catch (e) {

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
@@ -113,7 +114,11 @@ class NotificationWidget extends StatelessWidget {
notification: notification as ProductStatusUpdatedForAuthorNotification,
),
DiaryReminderForUsersNotification() => _DiaryReminderForUsersWidget(notification: notification as DiaryReminderForUsersNotification),
PersonalDietIsDefinedToCustomerNotification() => _PersonalDietIsDefinedToCustomerWidget(
notification: notification as PersonalDietIsDefinedToCustomerNotification,
),
UnknownNotification() => _UnknownNotificationWidget(notification: notification as UnknownNotification),
// TODO: Handle this case.
};
}
}
@@ -364,34 +369,38 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
// CardSettingsScreen
class _BaseNotificationWidget extends StatelessWidget {
const _BaseNotificationWidget({required this.notificationsText, required this.isRead, required this.createdAt});
const _BaseNotificationWidget({required this.notificationsText, required this.isRead, required this.createdAt, this.onTap});
final String notificationsText;
final bool isRead;
final DateTime createdAt;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(notificationsText, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
if (!isRead)
Container(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
width: 8,
height: 8,
return GestureDetector(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(notificationsText, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
),
],
),
const SizedBox(height: 4),
Text(
'${createdAt.day} ${_getMonthName(createdAt.month)} ${createdAt.hour.toString().padLeft(2, '0')}:${createdAt.minute.toString().padLeft(2, '0')}',
style: TextStyles.textSMedium.copyWith(color: appTheme.gray600),
),
],
if (!isRead)
Container(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
width: 8,
height: 8,
),
],
),
const SizedBox(height: 4),
Text(
'${createdAt.day} ${_getMonthName(createdAt.month)} ${createdAt.hour.toString().padLeft(2, '0')}:${createdAt.minute.toString().padLeft(2, '0')}',
style: TextStyles.textSMedium.copyWith(color: appTheme.gray600),
),
],
),
);
}
@@ -1053,3 +1062,17 @@ class _DiaryReminderForUsersWidget extends StatelessWidget {
return _BaseNotificationWidget(isRead: notification.isRead, notificationsText: 'Напоминание о заполнении дневника', createdAt: notification.createdAt);
}
}
class _PersonalDietIsDefinedToCustomerWidget extends StatelessWidget {
const _PersonalDietIsDefinedToCustomerWidget({required this.notification});
final PersonalDietIsDefinedToCustomerNotification notification;
@override
Widget build(BuildContext context) {
return _BaseNotificationWidget(
isRead: notification.isRead,
onTap: () => GoRouter.of(context).push(RouteConstants.aboutDietScreen, extra: {'id': notification.dietId, 'navigateToComments': false}),
notificationsText: 'Персональная диета "${notification.dietName}" установлена',
createdAt: notification.createdAt,
);
}
}

View File

@@ -407,6 +407,12 @@ class NotificationsService {
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.personalDietIsDefinedToCustomer => PersonalDietIsDefinedToCustomerNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt)),
};
} catch (e) {

View File

@@ -1,12 +1,13 @@
import 'dart:developer';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class OrderInfoCubit extends Cubit<OrderInfoState> {
OrderInfoCubit({required this.chatRepository}) : super(OrderInfoState());
OrderInfoCubit({required this.chatRepository, required this.profileRepository}) : super(OrderInfoState());
final ChatRepository chatRepository;
final ProfileRepository profileRepository;
Future<String?> getPaymentLink() async {
try {
@@ -20,10 +21,14 @@ class OrderInfoCubit extends Cubit<OrderInfoState> {
}
}
Future<void> getChosenPlan(int id) async {
Future<void> getChosenPlan({required int id, String? customerSlug}) async {
try {
emit(state.copyWith(isLoading: true));
final chosenPlan = await chatRepository.getChosenPlan(id: id);
if (customerSlug != null) {
final profile = await profileRepository.getProfileBySlug(customerSlug);
emit(state.copyWith(customerProfile: profile));
}
emit(state.copyWith(isLoading: false, chosenPlan: chosenPlan));
} catch (e) {
log(e.toString());

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'order_info_state.freezed.dart';
@@ -9,6 +10,7 @@ abstract class OrderInfoState with _$OrderInfoState {
factory OrderInfoState({
@Default(true) bool isLoading,
@Default(null) ChosenPlanModel? chosenPlan,
@Default(null) ProfileModel? customerProfile,
}) = _OrderInfoState;
}

View File

@@ -24,31 +24,24 @@ import 'package:go_router/go_router.dart';
class InfoByOrderScreen extends StatelessWidget {
static const name = 'infoByOrderScreen';
static const path = '/info-by-order-screen';
const InfoByOrderScreen({super.key, this.successfulPayment, required this.planId});
const InfoByOrderScreen({super.key, this.successfulPayment, required this.planId, this.customerSlug});
final bool? successfulPayment;
final int planId;
final String? customerSlug;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(planId),
child: InfoByOrderScreenContent(
successfulPayment: successfulPayment,
planId: planId,
),
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug),
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
);
}
}
class InfoByOrderScreenContent extends StatefulWidget {
static const name = 'infoByOrderScreen';
static const path = '/info-by-order-screen';
const InfoByOrderScreenContent({
super.key,
required this.planId,
required this.successfulPayment,
});
class InfoByOrderScreenContent extends StatefulWidget {
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
final int planId;
final bool? successfulPayment;
final String? customerSlug;
@override
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
}
@@ -64,39 +57,30 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
if (state.isLoading) {
return Scaffold(
appBar: AppBar(
title: Text(
s.infoAboutOrder,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
title: Text(s.infoAboutOrder, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
}),
),
body: const Center(
child: Loader(),
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
),
body: const Center(child: Loader()),
);
}
if (plan == null) {
return Scaffold(
appBar: AppBar(
title: Text(
s.infoAboutOrder,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
title: Text(s.infoAboutOrder, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
}),
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
),
body: Center(
child: Text(
s.sorry_we_did_not_find_the_plan,
style: TextStyles.textSSemibold.copyWith(color: appTheme.black),
),
child: Text(s.sorry_we_did_not_find_the_plan, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
),
);
}
@@ -122,58 +106,35 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
return Scaffold(
backgroundColor: appTheme.gray100,
appBar: AppBar(
title: Text(
s.infoAboutOrder,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
title: Text(s.infoAboutOrder, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
}),
icon: const Icon(Icons.arrow_back),
onPressed: () {
GoRouter.of(context).pop();
},
),
),
body: SingleChildScrollView(
child: Column(
children: [
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[
CollectChargeBanner(
chosenPlan: plan,
price: chosenOption?.price ?? 0,
)
],
if (widget.successfulPayment != null) ...[
const SizedBox(height: 8),
PaymentBanner(
successfulPayment: widget.successfulPayment ?? false,
),
],
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
const SizedBox(height: 8),
AuthorCard(
author: plan.plan.author,
title: s.plan_seller,
title: state.customerProfile != null ? s.customer : s.plan_seller,
titleStyle: TextStyles.textSSemibold.copyWith(color: appTheme.gray),
backgroundColor: appTheme.whiteA700,
customerProfile: state.customerProfile,
),
const SizedBox(height: 8),
PlanProps(
planDuration: plan.plan.duration,
planPrice: chosenOption?.price.toDouble() ?? 0,
planStatus: planStatus),
PlanProps(planDuration: plan.plan.duration, planPrice: chosenOption?.price.toDouble() ?? 0, planStatus: planStatus),
const SizedBox(height: 8),
PlanInfo(
title: plan.plan.name,
description: plan.plan.details,
imagePath: plan.plan.image,
),
PlanInfo(title: plan.plan.name, description: plan.plan.details, imagePath: plan.plan.image),
const SizedBox(height: 8),
OptionWidget(
option: chosenOption,
disableDuration: true,
),
OptionWidget(option: chosenOption, disableDuration: true, duration: plan.plan.duration),
const SizedBox(height: 8),
PlanState(
state: plan.chosenPlanState,
),
PlanState(state: plan.chosenPlanState, hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,),
const SizedBox(height: 20),
if (widget.successfulPayment == null)
CustomOutlinedButton(
@@ -184,13 +145,10 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
onPressed: () async {
showModalBottomSheet(
context: context,
builder: (context) => CancelPlanBottomSheet(
enableForm: plan.chosenPlanState.isStateAfterPaidState(),
planId: plan.id,
),
builder: (context) => CancelPlanBottomSheet(enableForm: plan.chosenPlanState.isStateAfterPaidState(), planId: plan.id),
);
},
)
),
],
),
),

View File

@@ -11,11 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class PaymentBanner extends StatefulWidget {
const PaymentBanner({
super.key,
required this.successfulPayment,
});
const PaymentBanner({super.key, required this.successfulPayment});
final bool successfulPayment;
@override
@@ -29,8 +25,9 @@ class _PaymentBannerState extends State<PaymentBanner> {
final s = S.of(context);
final size = MediaQuery.sizeOf(context);
return MainBackgroundBouble(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Stack(children: [
padding: const EdgeInsets.symmetric(vertical: 16),
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
@@ -38,8 +35,9 @@ class _PaymentBannerState extends State<PaymentBanner> {
child: Container(
width: 4,
decoration: BoxDecoration(
color: widget.successfulPayment ? appTheme.blueA400 : appTheme.strongRed,
borderRadius: const BorderRadius.only(topRight: Radius.circular(20), bottomRight: Radius.circular(20))),
color: widget.successfulPayment ? appTheme.blueA400 : appTheme.strongRed,
borderRadius: const BorderRadius.only(topRight: Radius.circular(20), bottomRight: Radius.circular(20)),
),
),
),
Padding(
@@ -50,78 +48,65 @@ class _PaymentBannerState extends State<PaymentBanner> {
? [
SizedBox(
width: size.width * 0.6,
child: Text(
s.cunsutation_was_paid_successfully,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
),
const SizedBox(
height: 8,
child: Text(s.cunsutation_was_paid_successfully, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
),
const SizedBox(height: 8),
SizedBox(
width: size.width * 0.6,
child: Text(
s.wait_for_start_day,
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
const SizedBox(
height: 16,
child: Text(s.wait_for_start_day, style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black)),
),
const SizedBox(height: 16),
isLoading
? const Center(child: Loader())
: CustomOutlinedButton(
text: s.go_to_diary,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle:
ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
GoRouter.of(context).pushReplacement('/diary-screen');
},
),
const SizedBox(height: 10),
isLoading
? const Center(child: Loader())
: CustomOutlinedButton(
text: s.back_to_chat,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle:
ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () {
GoRouter.of(context).pop();
},
)
),
]
: [
SizedBox(
width: size.width * 0.6,
child: Text(
s.smth_went_wrong_payment_failed,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
),
const SizedBox(
height: 8,
child: Text(s.smth_went_wrong_payment_failed, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
),
const SizedBox(height: 8),
SizedBox(
width: size.width * 0.6,
child: Text(
s.try_again_2,
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
const SizedBox(
height: 16,
child: Text(s.try_again_2, style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black)),
),
const SizedBox(height: 16),
isLoading
? const Center(child: Loader())
: CustomOutlinedButton(
text: s.pay,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle:
ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
final planId = context.read<OrderInfoCubit>().state.chosenPlan?.id;
setState(() {
@@ -133,16 +118,15 @@ class _PaymentBannerState extends State<PaymentBanner> {
});
if (paymentLink != null && planId != null) {
if (!context.mounted) return;
GoRouter.of(context).pushReplacement(
PaymentScreen.path,
extra: {'url': paymentLink, 'planId': planId},
);
GoRouter.of(context).pushReplacement(PaymentScreen.path, extra: {'url': paymentLink, 'planId': planId});
}
},
)
),
],
),
)
]));
),
],
),
);
}
}

View File

@@ -5,42 +5,65 @@ import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class PlanState extends StatelessWidget {
PlanState({super.key, required this.state});
class PlanState extends StatefulWidget {
const PlanState({super.key, required this.state, required this.hasPersonalDiet});
final ChosenPlanStateEnum state;
final List<List<ChosenPlanStateEnum>> states = [
[
ChosenPlanStateEnum.stateInited,
ChosenPlanStateEnum.stateInCoordination,
],
[
ChosenPlanStateEnum.stateStartDateHasBeenSet,
ChosenPlanStateEnum.statePendingPayment,
],
[
ChosenPlanStateEnum.statePaid,
],
[
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
],
[
ChosenPlanStateEnum.stateComplained,
ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.stateChargeWasCollected,
]
];
final bool hasPersonalDiet;
@override
State<PlanState> createState() => _PlanStateState();
}
class _PlanStateState extends State<PlanState> {
late final List<List<ChosenPlanStateEnum>> states;
@override
void initState() {
super.initState();
if (widget.hasPersonalDiet) {
states = [
[ChosenPlanStateEnum.stateInited, ChosenPlanStateEnum.stateInCoordination],
[ChosenPlanStateEnum.stateStartDateHasBeenSet, ChosenPlanStateEnum.statePendingPayment],
[ChosenPlanStateEnum.statePaid],
[ChosenPlanStateEnum.personalDietSet],
[
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
],
[ChosenPlanStateEnum.stateComplained, ChosenPlanStateEnum.stateDone, ChosenPlanStateEnum.stateChargeWasCollected],
];
} else {
states = [
[ChosenPlanStateEnum.stateInited, ChosenPlanStateEnum.stateInCoordination],
[ChosenPlanStateEnum.stateStartDateHasBeenSet, ChosenPlanStateEnum.statePendingPayment],
[ChosenPlanStateEnum.statePaid],
[
ChosenPlanStateEnum.stateStarted,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
],
[ChosenPlanStateEnum.stateComplained, ChosenPlanStateEnum.stateDone, ChosenPlanStateEnum.stateChargeWasCollected],
];
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
final titles = [s.order_created, s.startDateHasBeenSet, s.order_paid, s.in_progress, s.done];
final List<String> titles;
if (widget.hasPersonalDiet) {
titles = [s.order_created, s.startDateHasBeenSet, s.order_paid, s.personal_diet_set, s.in_progress, s.done];
} else {
titles = [s.order_created, s.startDateHasBeenSet, s.order_paid, s.in_progress, s.done];
}
return MainBackgroundBouble(
padding: const EdgeInsets.all(16),
child: Column(
children: List.generate(states.length, (index) {
final color = index <= states.indexWhere((element) => element.contains(state)) ? appTheme.greenA700 : appTheme.lightGray00;
final color = index <= states.indexWhere((element) => element.contains(widget.state)) ? appTheme.greenA700 : appTheme.lightGray00;
final text = titles[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -51,29 +74,16 @@ class PlanState extends StatelessWidget {
width: 24,
height: 24,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
child: Center(
child: Icon(
Icons.check,
color: appTheme.whiteA700,
size: 14,
),
),
child: Center(child: Icon(Icons.check, color: appTheme.whiteA700, size: 14)),
),
const SizedBox(width: 12),
Text(
text,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
),
Text(text, style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
],
),
Padding(
padding: const EdgeInsets.only(left: 12),
child: Container(
height: 12,
width: 1,
color: color,
),
)
child: Container(height: 12, width: 1, color: color),
),
],
);
}),

View File

@@ -0,0 +1,67 @@
import 'dart:developer';
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PersonalDietSuggestionCubit extends Cubit<PersonalDietSuggestionState> {
final DietRepository dietRepository;
final PersonalDietSuggestionRepository personalDietSuggestionService;
late final int dietId;
PersonalDietSuggestionCubit({required this.dietRepository, required this.personalDietSuggestionService}) : super(PersonalDietSuggestionState());
Future<void> setDietName(int dietId) async {
try {
emit(state.copyWith(isLoading: true, error: null));
final diet = await dietRepository.getDiet(dietId);
emit(state.copyWith(isLoading: false, dietName: diet.name));
} catch (e, st) {
log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> getConsultationWithPersonalDiets(int dietId) async {
try {
this.dietId = dietId;
emit(state.copyWith(isLoading: true, error: null));
setDietName(dietId);
final consultations = await personalDietSuggestionService.getConsultationWithPersonalDiets();
final consultationWithDiet = consultations.where((consultation) => consultation.personalDietId != null).toList();
final consultationWithoutDiet = consultations.where((consultation) => consultation.personalDietId == null).toList();
emit(state.copyWith(
isLoading: false,
consultationsWithDiet: consultationWithDiet,
consultationsWithoutDiet: consultationWithoutDiet,
));
} catch (e, st) {
log(e.toString(), stackTrace: st);
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<bool> attachDietToConsultation({
required int consultationId,
}) async {
try {
await personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
return true;
} catch (e, st) {
log(e.toString(), stackTrace: st);
return false;
}
}
Future<bool> reattachDietToConsultation({
required int consultationId,
}) async {
try {
await personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
return true;
} catch (e, st) {
log(e.toString(), stackTrace: st);
return false;
}
}
}

View File

@@ -0,0 +1,16 @@
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'personal_diet_suggestion_state.freezed.dart';
@freezed
abstract class PersonalDietSuggestionState with _$PersonalDietSuggestionState {
factory PersonalDietSuggestionState({
@Default([]) List<ConsultationModel> consultationsWithDiet,
@Default([]) List<ConsultationModel> consultationsWithoutDiet,
@Default('') String dietName,
@Default(false) bool isLoading,
@Default(null) String? error,
}) = _PersonalDietSuggestionState;
}

View File

@@ -0,0 +1,358 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class ConsultationWidget extends StatelessWidget {
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
final ConsultationModel consultationModel;
final String dietName;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: appTheme.whiteA700,
),
child: Column(
children: [
if (consultationModel.personalDietId == null)
Row(
children: [
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
consultationModel.userName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(
consultationModel.planTitle,
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
),
Text(
'Старт: ${consultationModel.userName}',
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
),
],
)
],
)
else
Row(
children: [
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
),
const SizedBox(width: 12),
Column(
mainAxisSize: MainAxisSize.min,
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
consultationModel.userName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
Text(
consultationModel.planTitle,
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
),
],
)
],
),
const SizedBox(
height: 12,
),
if (consultationModel.personalDietId != null)
CustomOutlinedButton(
text: s.changeDiet,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
onPressed: () async {
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestDietBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
onTap: () async {
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
AppRouter.navigatorKey.currentContext?.pop();
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionSuccessBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
return;
}
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionFailureBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
},
),
);
},
)
else
CustomOutlinedButton(
text: s.suggestDiet,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () async {
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestDietBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
onTap: () async {
AppRouter.navigatorKey.currentContext?.pop();
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionSuccessBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
return;
}
if (!context.mounted) return;
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: null,
child: SuggestionFailureBottomSheet(
userName: consultationModel.userName,
dietName: dietName,
),
);
},
),
);
},
)
],
),
);
}
}
class SuggestDietBottomSheet extends StatefulWidget {
const SuggestDietBottomSheet({
super.key,
required this.userName,
required this.dietName,
required this.onTap,
});
final String userName;
final String dietName;
final Future<void> Function() onTap;
@override
State<SuggestDietBottomSheet> createState() => _SuggestDietBottomSheet();
}
class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
),
Text(
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
CustomOutlinedButton(
text: s.toOffer,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: widget.onTap,
),
const SizedBox(
height: 40,
),
],
),
);
}
}
class SuggestionSuccessBottomSheet extends StatefulWidget {
const SuggestionSuccessBottomSheet({
super.key,
required this.userName,
required this.dietName,
});
final String userName;
final String dietName;
@override
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
}
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
),
Text(
'«${widget.dietName}» предложена пользователю ${widget.userName}',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () async {
AppRouter.navigatorKey.currentContext?.pop();
},
),
const SizedBox(
height: 40,
),
],
),
);
}
}
class SuggestionFailureBottomSheet extends StatefulWidget {
const SuggestionFailureBottomSheet({
super.key,
required this.userName,
required this.dietName,
});
final String userName;
final String dietName;
@override
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
}
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
),
Text(
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: () async {
AppRouter.navigatorKey.currentContext?.pop();
},
),
const SizedBox(
height: 40,
),
],
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_state.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/presentation/consultation_widget.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class PersonalDietSuggestionScreen extends StatelessWidget {
final int dietId;
const PersonalDietSuggestionScreen({super.key, required this.dietId});
static const path = '/personal-diet-suggestion';
static const name = 'personalDietSuggestion';
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => sl<PersonalDietSuggestionCubit>()..getConsultationWithPersonalDiets(dietId),
child: const PersonalDietSuggestionScreenContent(),
);
}
}
class PersonalDietSuggestionScreenContent extends StatelessWidget {
const PersonalDietSuggestionScreenContent({super.key});
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocBuilder<PersonalDietSuggestionCubit, PersonalDietSuggestionState>(
builder: (context, state) {
return Scaffold(
backgroundColor: appTheme.gray100,
appBar: AppBar(
centerTitle: true,
title: Text(
'${s.dietSuggestion}\n«${state.dietName}»',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
body: state.isLoading
? const Center(child: Loader())
: SingleChildScrollView(
child: Column(
children: [
...List.generate(state.consultationsWithDiet.length, (index) {
return ConsultationWidget(
consultationModel: state.consultationsWithDiet[index],
dietName: state.dietName,
);
}),
const SizedBox(
height: 12,
),
...List.generate(state.consultationsWithoutDiet.length, (index) {
return ConsultationWidget(
consultationModel: state.consultationsWithoutDiet[index],
dietName: state.dietName,
);
})
],
),
),
);
},
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
class PersonalDietSuggestionRepository {
final PersonalDietSuggestionService personalDietSuggestionService;
PersonalDietSuggestionRepository({required this.personalDietSuggestionService});
Future<List<ConsultationModel>> getConsultationWithPersonalDiets() async{
try {
return (await personalDietSuggestionService.getConsultationWithPersonalDiets()).data;
} catch (e) {
rethrow;
}
}
Future<void> attachPersonalDiet({required int consultationId, required int dietId}) {
try {
return personalDietSuggestionService.attachPersonalDiet(consultationId: consultationId, dietId: dietId);
} catch (e) {
rethrow;
}
}
Future<void> reattachPersonalDiet({required int consultationId, required int dietId}) {
try {
return personalDietSuggestionService.reattachPersonalDiet(consultationId: consultationId, dietId: dietId);
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,36 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
class PersonalDietSuggestionService {
final Dio dio;
PersonalDietSuggestionService(this.dio);
Future<ConsultationPaginationModel> getConsultationWithPersonalDiets() async {
try {
final response = await dio.get('/api/consultations', queryParameters: {
'filters[plan][has_personal_diet][\$personal_diet]': '1',
'filters[last_state][\$notIn][]': ['done', 'charge_was_collected', 'complained', 'last_day_finished', 'canceled_by_the_initiator', 'inited'],
'sort[]': 'start_date:desc',
});
return ConsultationPaginationModel.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<void> attachPersonalDiet({required int consultationId, required int dietId}) async {
try {
await dio.post('/api/chosen_plans/$consultationId/attach_diet', data: {'diet_id': dietId});
} catch (e) {
rethrow;
}
}
Future<void> reattachPersonalDiet({required int consultationId, required int dietId}) async {
try {
await dio.post('/api/chosen_plans/$consultationId/re_attach_diet', data: {'diet_id': dietId});
} catch (e) {
rethrow;
}
}
}

View File

@@ -27,6 +27,8 @@ enum ChosenPlanStateEnum {
stateComplained,
@JsonValue('done')
stateDone,
@JsonValue('personal_diet_setted')
personalDietSet,
@JsonValue('charge_was_collected')
stateChargeWasCollected;
@@ -40,6 +42,7 @@ enum ChosenPlanStateEnum {
ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.stateInProgress,
ChosenPlanStateEnum.personalDietSet,
];
}
@@ -64,7 +67,8 @@ enum ChosenPlanStateEnum {
ChosenPlanStateEnum.stateLastDayStarted,
ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateComplained,
ChosenPlanStateEnum.stateDone
ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.personalDietSet
];
}
}

View File

@@ -505,12 +505,15 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
switch (state.chosenOptionIndex) {
1 => OptionWidget(
option: state.planModel?.packet2Options,
duration: state.planModel?.duration ?? 0,
),
2 => OptionWidget(
option: state.planModel?.packet3Options,
duration: state.planModel?.duration ?? 0,
),
_ => OptionWidget(
option: state.planModel?.packet1Options,
duration: state.planModel?.duration ?? 0,
),
},
const SizedBox(

View File

@@ -5,7 +5,6 @@ import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
@@ -24,9 +23,11 @@ class ProfileCubit extends Cubit<ProfileState> {
emit(const ProfileState.loading());
try {
final profile = await profileRepository.getProfileBySlug(slug);
final myProfile = await profileRepository.getMyProfile();
emit(
ProfileState.loaded(
profile: profile,
isMyProfile: myProfile.slug == profile.slug,
profileDetails: await profileRepository.getProfileDetailsBySlug(slug),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: slug),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(slug) : null,
@@ -46,6 +47,7 @@ class ProfileCubit extends Cubit<ProfileState> {
emit(
ProfileState.loaded(
profile: profile,
isMyProfile: true,
profileDetails: await profileRepository.getMyProfileDetails(),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: profile.slug),
weightPoints: await profileRepository.getWeight(period: period),
@@ -74,6 +76,7 @@ class ProfileCubit extends Cubit<ProfileState> {
weightPoints: (state as ProfileStateLoaded).weightPoints,
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
),
);
}
@@ -100,11 +103,12 @@ class ProfileCubit extends Cubit<ProfileState> {
}
}
Future<String?> getChatRoomIdBySlug({required Author author}) async {
Future<String?> getChatRoomIdBySlug({required String slug}) async {
try {
final chatIdOrError = await chatRepository.getChatByUserSlug(author.authorSlug);
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug);
if (chatIdOrError == '404') {
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: author);
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else if (chatIdOrError == 'error') {
showErrorToast('Ошибка создания чата');
} else {

Some files were not shown because too many files have changed in this diff Show More