Compare commits

...

205 Commits

Author SHA1 Message Date
misha
1bbb08f2a2 fix: ci/cd 2026-10-14 13:16:57 +03:00
misha
ef4df92f58 fix: analyzer fixes 2026-10-14 10:53:38 +03:00
misha
dcd343e765 feat(push_notifications): issue-660, fix: issue-908 2026-10-14 08:16:20 +03:00
misha
98c175df44 fix: test fixed 2026-10-12 11:19:18 +03:00
misha
cb312037b2 feat(UGPC): issue-777 2026-10-12 08:50:33 +03:00
misha
f8e06b6d7a version changed 2026-10-08 07:20:00 +03:00
misha
866ea2fe3b Merge remote-tracking branch 'upstream/main' into issue-1062 2026-10-08 07:18:53 +03:00
d73f68e24b Merge pull request 'video_calls_fixes' (#1066) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1066
2026-10-05 11:56:40 +00:00
misha
61cded0081 version cnhanged 2026-10-05 13:13:25 +03:00
misha
df634fedd8 feat(video_calls): incommig call handling fix for ios and anroid 2026-10-05 13:12:55 +03:00
misha
603a158cf2 feat(diary): editing of previous and current finished days 2026-10-01 10:44:52 +03:00
a1bae68307 Merge pull request 'feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix' (#1064) from misha/eatvio_app:video_calls_fixes into main
Reviewed-on: eatvio/eatvio_app#1064
2026-09-28 09:01:24 +00:00
misha
2dce28b196 feat(video_calls, welcome_screen): ios mic fix, welcome scrin layoute fix 2026-09-28 11:20:52 +03:00
ae4f9d2e9b Merge pull request 'fix(chat): ws manager' (#1060) from apoloz/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1060
Reviewed-by: misha <misha@noreply.localhost>
2026-09-25 07:31:28 +00:00
Anatoliy
a00aae36cf update version 2026-09-16 13:50:12 +03:00
Anatoliy
27e98bdb44 fix(chat): ws manager 2026-09-16 00:10:23 +03:00
Anatoliy
8ec6d5b799 refactor(chat): dedup 2026-09-15 23:20:00 +03:00
Anatoliy
077cfb3a67 refactor(chat): message dedup 2026-09-15 23:06:12 +03:00
c4f5cae36d Merge pull request 'fix: issue-942, issue-967, fixed images positioning for blog posts' (#1055) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1055
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-09-15 19:31:06 +00:00
misha
b040c6e020 fix: tests fix 2026-09-02 15:42:17 +03:00
misha
745e77f948 fix: analyer 2026-09-02 09:34:25 +03:00
misha
c6ea1d414d fix: issue-372, issue-227, issue-406 2026-09-02 09:32:14 +03:00
misha
10af279d87 fix: issue-942, issue-967, fixed images positioning for blog posts 2026-08-28 10:05:40 +03:00
d0879e556f Merge pull request 'version changed' (#1054) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1054
2026-08-27 07:21:39 +00:00
misha
8f933118a3 version changed 2026-08-27 08:58:09 +03:00
7dc7eab774 Merge pull request 'issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993' (#1053) from misha/eatvio_app:1-3-42_bug_fixes into main
Reviewed-on: eatvio/eatvio_app#1053
2026-08-26 08:07:39 +00:00
misha
16a13488d1 version changed 2026-08-26 10:27:48 +03:00
misha
de7bf8dc19 fix: issue-1049, issue-1039, issue-1035, issue-1032, issue-995, issue-993 2026-08-26 08:42:47 +03:00
eeb261df0a Merge pull request 'feat(image_validation)' (#1052) from misha/eatvio_app:issue-1037 into main
Reviewed-on: eatvio/eatvio_app#1052
2026-08-24 12:04:37 +00:00
misha
1b03a0d5e1 version changed 2026-08-24 11:55:39 +03:00
misha
0208ed753a feat(image_validation): image validation fix 2026-08-24 11:38:01 +03:00
184f0417a7 Merge pull request 'version changed' (#1051) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1051
2026-08-21 11:22:11 +00:00
misha
6265e865e9 version changed 2026-08-21 14:20:54 +03:00
1550b7ea0b Merge pull request 'fix: fixed domain matching for deeplinks' (#1050) from misha/eatvio_app:marketing_domains into main
Reviewed-on: eatvio/eatvio_app#1050
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-21 10:55:11 +00:00
misha
db10895ebb fix: removed path matching to handle links like https://{domain} 2026-08-21 10:25:09 +03:00
misha
a7361d3ab1 fix: fixed domain matching for deeplinks 2026-08-20 13:45:16 +03:00
1c54063a35 Merge pull request 'feat(chat,consultant_registration, portion_input)' (#1045) from chat_messages into main
Reviewed-on: eatvio/eatvio_app#1045
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-08-14 11:51:01 +00:00
efae7fc958 Merge pull request 'portion_input' (#1044) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1044
2026-08-14 10:48:00 +00:00
misha
90ac4e750d feat(portion_input): fixed portion input sizes 2026-08-14 13:46:00 +03:00
misha
6d7419c283 version changed 2026-08-14 12:59:04 +03:00
caf45a9050 Merge pull request 'Scoring validation' (#1043) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1043
2026-08-14 09:20:03 +00:00
misha
0a5e7d95c4 Merge branch 'chat_messages' of ssh://10.55.5.123:222/misha/eatvio_app into chat_messages 2026-08-14 12:16:46 +03:00
misha
85482173ad feat(consultant_scoring): added validation for last first and middle name for scoring 2026-08-14 12:16:03 +03:00
65f7992d40 Merge pull request 'feat(chat_messages): fixed data race for chat mesages state' (#1042) from misha/eatvio_app:chat_messages into chat_messages
Reviewed-on: eatvio/eatvio_app#1042
2026-08-14 08:07:56 +00:00
misha
52a01bee12 feat(chat_messages): fixed data race for chat mesages state 2026-08-14 11:05:03 +03:00
179fc0ad99 Merge pull request 'pasport_data_validation' (#1041) from misha/eatvio_app:pasport_data_validation into main
Reviewed-on: eatvio/eatvio_app#1041
2026-08-13 12:18:35 +00:00
misha
8494e61eec version changed 2026-08-13 12:29:18 +03:00
misha
5c2458ecad feat(consultant_refistration): added explicit validation for scoring data 2026-08-13 12:28:34 +03:00
b63bab8bf5 Merge pull request 'feat(post_creation_with_images)' (#1026) from post_creation_with_images into main
Reviewed-on: eatvio/eatvio_app#1026
2026-08-05 14:07:55 +00:00
7790014726 Merge pull request 'Version changed' (#1029) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1029
2026-08-05 13:33:20 +00:00
misha
0d588bcd57 Version changed 2026-08-05 16:32:14 +03:00
b679e90ba2 Merge pull request 'fix: analyzer fix' (#1027) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1027
2026-08-05 12:34:27 +00:00
misha
6b5442eb87 fix: analyzer fix 2026-08-05 15:33:27 +03:00
2474c5bea3 Merge pull request 'post_creation_with_images' (#1025) from misha/eatvio_app:post_creation_with_images into post_creation_with_images
Reviewed-on: eatvio/eatvio_app#1025
2026-08-05 12:31:23 +00:00
misha
478a53674a feat(post_creatin): fixed animation widget for profile feed 2026-08-05 15:21:23 +03:00
7f278d0f59 Merge pull request 'fix: fixed script run time location' (#1024) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1024
2026-08-04 15:36:07 +00:00
misha
53426c858c fix: fixed script run time location 2026-08-04 18:35:32 +03:00
88f0cde88f Merge pull request 'fix: added pods updating' (#1023) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1023
2026-08-04 15:19:44 +00:00
misha
a5fcf62524 fix: added pods updating 2026-08-04 18:19:10 +03:00
a2e41a2ff5 Merge pull request 'fix: removed SPM' (#1022) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1022
2026-08-04 15:13:32 +00:00
misha
5b1beafbab fix: removed SPM 2026-08-04 18:12:55 +03:00
4eadecd6f2 Merge pull request 'fix: removed spm flag' (#1021) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1021
2026-08-04 14:52:33 +00:00
misha
6292db07a1 fix: removed spm flag 2026-08-04 17:51:58 +03:00
ab29b6b329 Merge pull request 'fix: ci/cd fix' (#1020) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1020
2026-08-04 14:48:54 +00:00
misha
af5d182a79 fix: ci/cd fix 2026-08-04 17:48:08 +03:00
misha
04de956769 feat(post_creation): added multiple image posts creation and validation 2026-08-04 17:47:43 +03:00
191bb74aa4 Merge pull request 'Version changed' (#1018) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1018
2026-08-04 14:35:33 +00:00
misha
3210f38d4a Version changed 2026-08-04 14:22:33 +03:00
27a98a496d Merge pull request 'promo_codes' (#1017) from promo_codes into main
Reviewed-on: eatvio/eatvio_app#1017
2026-08-04 11:14:47 +00:00
d8ecc7f3b1 Merge pull request 'feat(promo_codes)' (#1015) from misha/eatvio_app:promo_codes into promo_codes
Reviewed-on: eatvio/eatvio_app#1015
2026-08-04 11:13:45 +00:00
063da03d7e merge upstream 2026-08-04 10:17:32 +00:00
6b5927a99e Merge pull request 'sync with main' (#1016) from main into promo_codes
Reviewed-on: eatvio/eatvio_app#1016
2026-08-04 10:17:26 +00:00
afab72f7bc merge upstream 2026-08-04 10:16:20 +00:00
2770ff0e80 Merge pull request 'feat(consultant_scoring)' (#1012) from consultant_scoring into main
Reviewed-on: eatvio/eatvio_app#1012
2026-08-04 10:16:09 +00:00
1200021bb6 Merge pull request 'personalized_feed' (#1004) from personalized_feed into main
Reviewed-on: eatvio/eatvio_app#1004
2026-08-04 10:15:25 +00:00
misha
9e8b8b89fc feat(promo_codes): added promo codes handling to app 2026-08-03 18:12:05 +03:00
a24d052193 Merge pull request 'fix: test fixed' (#1014) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1014
2026-08-03 12:32:07 +00:00
misha
9de0efa28e fix: test fixed 2026-08-03 15:30:54 +03:00
37504aa292 Merge pull request 'fix: removed unused import' (#1013) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1013
2026-08-03 08:46:16 +00:00
c8588a2e1f Merge pull request 'consultant_scoring' (#1011) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1011
2026-07-31 10:45:21 +00:00
misha
e0cab89126 Merge branch 'consultant_scoring' of ssh://10.55.5.123:222/misha/eatvio_app into consultant_scoring 2026-07-30 17:42:30 +03:00
misha
954d4955da feat(consultant_scoring): added validation errors, fixed scoring flow, updating flow 2026-07-30 17:41:30 +03:00
e5086d7fbc Merge pull request 'feat(consultant_scoring)' (#1005) from misha/eatvio_app:consultant_scoring into consultant_scoring
Reviewed-on: eatvio/eatvio_app#1005
2026-07-29 11:09:30 +00:00
0d93768dc3 Merge branch 'consultant_scoring' into consultant_scoring 2026-07-29 11:09:10 +00:00
misha
8c5fc8d827 feat(consultant_scoring): fixed registration update flow 2026-07-29 14:08:13 +03:00
misha
2d6b042d45 fix: removed unused import 2026-07-28 12:00:03 +03:00
d56aa45c92 Merge pull request 'personalized_feed' (#1009) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1009
2026-07-23 13:37:00 +00:00
misha
b3d0a26667 Merge remote-tracking branch 'upstream/main' into personalized_feed 2026-07-23 15:40:57 +03:00
8835d65944 Merge pull request 'version changed' (#1007) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1007
2026-07-23 12:37:37 +00:00
misha
cc9aa6bffb version changed 2026-07-23 15:36:02 +03:00
a15e8b78a5 Merge pull request 'Info plist + version fix' (#1006) from misha/eatvio_app:main into main
Reviewed-on: eatvio/eatvio_app#1006
2026-07-23 12:32:14 +00:00
misha
0667ab81a8 Merge remote-tracking branch 'upstream/main' 2026-07-23 15:31:23 +03:00
misha
1dc60eec64 version changed 2026-07-23 15:29:47 +03:00
misha
69f0f1e919 fix: removed unused info plist key 2026-07-23 15:27:49 +03:00
misha
a6fe6afb80 feat(personalized_feed): fixed photos rendering and fixed profile posts rendering 2026-07-23 15:17:01 +03:00
misha
c8c8ade083 Merge remote-tracking branch 'upstream/main' into personalized_feed 2026-07-23 15:15:59 +03:00
misha
037f153273 feat(consultant_scoring): adjusted data for new registration, added sbp banks endpoint 2026-07-22 17:58:02 +03:00
6a17bf3a95 Merge pull request 'feat(personalized_feed): added video and multiple images handling to detailed blog post screen' (#1003) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1003
2026-07-22 11:53:41 +00:00
misha
2cccc9e253 feat(personalized_feed): added video and multiple images handling to detailed blog post screen 2026-07-22 14:41:26 +03:00
dcf7b25989 Merge pull request 'пофиксили отображение коунтера карусели при переключении картинок, при посте с одной картинкой и одним видео. добавили таймер на видео.' (#1002) from Hasan/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1002
2026-07-22 07:26:28 +00:00
163f56ff61 Merge pull request 'feat(vidoe_calls)' (#983) from misha/eatvio_app:issue-975 into main
Reviewed-on: eatvio/eatvio_app#983
2026-07-22 07:25:32 +00:00
d6b57ef34d пофиксили отображение коунтера карусели при переключении картинок, при посте с одной картинкой и одним видео. добавили таймер на видео. 2026-07-21 16:50:06 +03:00
45f20cc903 Merge pull request 'personalized_feed' (#1) from eatvio/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: Hasan/eatvio_app#1
2026-07-21 12:43:51 +00:00
746b9b6d28 Merge pull request 'feat(personalized_feed): added vk player state handling, separeted player logic' (#1001) from misha/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1001
2026-07-21 12:41:33 +00:00
misha
2dbd4a455f feat(personalized_feed): added vk player state handling, separeted player logic 2026-07-21 15:15:28 +03:00
c7514e2cbf Merge pull request 'fix fixtures' (#1000) from Hasan/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#1000
2026-07-21 11:26:44 +00:00
f1abea70c5 fix fixtures 2026-07-21 14:22:45 +03:00
e4d397b478 Merge pull request 'personalized_feed with video and multiple images' (#999) from apoloz/eatvio_app:personalized_feed into personalized_feed
Reviewed-on: eatvio/eatvio_app#999
2026-07-21 08:08:19 +00:00
9c5c6df751 refactor(video,webview): webview lib replaced on feed 2026-07-20 18:15:25 +03:00
misha
48a984f5e9 feat(personalized_feed): added fixtures for new feed, added new feed parsing and layout, added webview player for post videos 2026-07-20 16:47:19 +03:00
misha
5dc1f48fd1 feat(ci/cd): added api key for tf 2026-07-15 17:31:32 +03:00
misha
5ec877ef72 version changed 2026-07-15 15:19:56 +03:00
misha
dbaf639ac6 fix: analyzer fix 2026-07-15 15:18:53 +03:00
misha
2a07ed596b Merge remote-tracking branch 'upstream/main' into issue-975 2026-07-15 15:17:44 +03:00
bd2de07b8f Merge pull request 'sbp-alfa-bank-payment' (#998) from misha/eatvio_app:sbp_payments into main
Reviewed-on: eatvio/eatvio_app#998
2026-07-15 12:06:00 +00:00
misha
eee912f05b fix: pub cache cleaning 2026-07-14 16:57:32 +03:00
misha
e316bf1e0e version changed 2026-07-14 16:12:43 +03:00
misha
de19af14bf fix: fixed analyzer and test 2026-07-14 16:11:56 +03:00
Anatoliy
b064c7016e feat(payment, chat): promo code mocked BottomSheet added 2026-07-14 15:50:44 +03:00
misha
e94681266a feat(sbp_payments): added new layoute for sbp banks and rederict to chosen bank, and added tests for layoute 2026-07-14 13:43:46 +03:00
misha
0ca2d1a1cc feat(sbp_payments): added banks deep link handling to chat screen 2026-07-14 13:09:18 +03:00
misha
be924265a1 feat(sbp_payments): added cubit layer for sbp payments with tests 2026-07-09 18:35:09 +03:00
a0edfe942b feat(payments): phase 3 test added 2026-07-09 16:21:53 +03:00
misha
51397003b5 feat(sbp_payments): added tests for new chosen_plan_state fixtures and new tests for chat repo and service 2026-07-08 15:07:36 +03:00
misha
e10610d93c feat(sbp_payments): added new chosen_plan fixtures with chosen_plan states and fixed chat tests 2026-07-08 14:28:08 +03:00
Anatoliy Poloz
d21cc46558 feat(payments): phase #1 tests and part of implementation 2026-07-08 13:31:55 +03:00
Anatoliy Poloz
2ab26bbda5 feat(chat): reorganize test fixtures 2026-07-08 12:31:06 +03:00
misha
41e556d172 feat(sbp_payments): fixtures and dio excp type added 2026-07-08 12:20:11 +03:00
Anatoliy Poloz
6d920857f6 fix(gradle) 2026-07-08 11:02:58 +03:00
Anatoliy
7183fc5d7d feat(sbp-payin): fixtures and banks assets added 2026-07-08 10:50:12 +03:00
Anatoliy
3852a06ab4 feat(payment): new sbp payment fixture added 2026-07-07 12:22:05 +03:00
misha
219908684f feat(video_calls): fixed naviagtion from push notification to call and added wake lock 2026-07-06 11:18:05 +03:00
misha
9ce7b035ff feat(video_calls): fixed call ended push handling 2026-06-30 18:48:45 +03:00
misha
c8ad600d97 fix: analyzer 2026-06-30 17:17:58 +03:00
misha
f493e07f9f feat(video_calls): call ended push, recall button for message 2026-06-30 16:11:18 +03:00
misha
c0c7e2e658 version changed 2026-06-29 12:15:34 +03:00
misha
f913ae60fa feat(vide_calls): added tokens clearing 2026-06-29 12:12:53 +03:00
misha
2100e5ca02 fix: analyzer fix 2026-06-26 11:30:49 +03:00
misha
10b36117a6 version changed 2026-06-26 08:57:31 +03:00
misha
bde4eea7d1 feat(video_calls): fixed recinnecting logic, added consultant bage 2026-06-26 08:56:57 +03:00
misha
59f42c9b1e fix: test fixed 2026-06-24 09:35:04 +03:00
misha
c5bf5acfe7 feat(video_calls): fixed online indicator and in app notifications for video call 2026-06-24 08:26:16 +03:00
7db9af9894 merge upstream 2026-06-23 08:56:24 +00:00
misha
8ff826fa3e fix: analyzer fix 2026-06-23 09:08:04 +03:00
misha
1e29cda65c Version changed 2026-06-23 08:43:40 +03:00
misha
ed72ed9799 feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes 2026-06-23 08:42:55 +03:00
misha
8920fc280f feat(ci/cd): version changed, anroind action runs only on main branch 2026-06-18 15:02:53 +03:00
misha
7493870a3d feat(video_calls): removed system ui, fixed wrong app initializing 2026-06-18 15:01:21 +03:00
misha
e3c22f8ac8 Revert "feat(video_calls): added system ui for call and navigation to the app"
This reverts commit ea5fced456.
2026-06-18 14:41:56 +03:00
misha
ea5fced456 feat(video_calls): added system ui for call and navigation to the app 2026-06-17 16:47:51 +03:00
misha
e3dd8162fa fix: ci/cd error fixed 2026-06-17 11:33:17 +03:00
misha
90e5c7e58d fix: ci/cd error fixed 2026-06-17 11:17:37 +03:00
misha
65d529b0af feat(video_calls): fixed navigation from voip push 2026-06-17 11:06:22 +03:00
misha
95c1d40c79 feat(video_calls): fixed nullable overlay state handling 2026-06-16 22:57:17 +03:00
misha
895443e747 feat(video_calls): added more logs for sentry 2026-06-16 21:11:45 +03:00
misha
367753d409 Version change 2026-06-16 19:30:01 +03:00
misha
e82866f936 feat(video_calls): voip push handling fix 2026-06-16 19:28:16 +03:00
misha
b13ff74220 fix: added NSLocationAlwaysAndWhenInUseUsageDescription key description 2026-06-16 18:33:21 +03:00
misha
e0d697e8d0 feat(video_calls): fixed ios push UI 2026-06-16 18:30:16 +03:00
misha
705538ac85 fix: added NSLocationWhenInUseUsageDescription key description 2026-06-16 17:56:59 +03:00
misha
03f037518e feat(video_calls): fixed ios and HMS pushes for calls 2026-06-16 15:01:46 +03:00
misha
a07565fca9 feat(ci/cd): removed pods updating 2026-06-15 16:47:17 +03:00
misha
b9cb4eeb84 feat(video_calls): app delegate fix 2026-06-15 16:27:24 +03:00
misha
4d531036fa Version changed 2026-06-15 14:12:37 +03:00
misha
1cafb4b05a feat(video_calls): changed chat button layoute 2026-06-15 14:07:56 +03:00
misha
5daa187045 feat(ci/cd): fixed app delegate for ios 2026-06-11 12:14:42 +03:00
misha
f4f884484d feat(ci/cd): separeted code sign from build_app 2026-06-11 11:55:43 +03:00
misha
4dd32e1a42 feat(ci/cd): fixed team id 2026-06-10 17:21:40 +03:00
misha
3e89bb5ca9 feat(ci/cd): added team id to build_app for ios 2026-06-10 17:14:15 +03:00
misha
885c6f66f9 feat(ci/cd): added gitea token for fastlane url 2026-06-10 16:39:06 +03:00
misha
9dc568ceab feat(ci/cd): removed flutter updating 2026-06-10 14:35:20 +03:00
misha
6fb151ef32 feat(ci/cd): added pods updating 2026-06-10 14:19:21 +03:00
misha
5fa024a7a8 feat(ci/cd): changed repositories for ci/cd 2026-06-10 14:09:54 +03:00
misha
de795c5274 feat(video_calls): added system android pip for calls 2026-06-10 09:32:43 +03:00
misha
27fe4afd81 feat(video_calls): added new incomng push impl, fixed mic and video sharing while connecting to call 2026-06-09 09:55:20 +03:00
misha
8e2b7147d5 Merge branch 'issue-975' of ssh://10.55.5.123:222/misha/eatvio_app into issue-975 2026-06-04 14:39:30 +03:00
misha
f51a68324b feat(video_calls): ios voip pushes handling 2026-06-04 14:38:27 +03:00
8a0d51add9 merge upstream 2026-06-02 11:43:59 +00:00
misha
634d83fd61 feat(tests): fixed tests 2026-05-28 12:11:29 +03:00
misha
9e9f2a9f45 feat(analyzer): fixed analyzer issue 2026-05-28 10:56:07 +03:00
misha
a495f98f30 feat(ci/cd): api key for matching 2026-05-27 16:32:33 +03:00
misha
9f8f9a7aaa feat(ci/cd): location fixes 2026-05-27 13:13:42 +03:00
misha
af1980930b feat(ci/cd): pods 403 error fix 2026-05-27 13:11:09 +03:00
misha
1c262da81a feat(ci/cd): make fstlane to update the certificates and profiles 2026-05-27 13:03:34 +03:00
misha
8a617509de version changed 2026-05-25 08:10:10 +03:00
misha
73cec1a01e feat(vide_calls): gms calls handling 2026-05-25 08:07:20 +03:00
fb99a42115 Merge pull request 'feat(video_calls)' (#980) from misha/eatvio_app:issue-974 into main
Reviewed-on: eatvio/eatvio_app#980
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
Reviewed-by: mso7nueh <mso7nueh@mail.ru>
2026-05-25 02:22:48 +00:00
misha
1acb158f36 feat(vidoe_calls): global cideo call state, connection, loading and error screens for video calls 2026-05-12 07:47:41 +03:00
01282a35cb merge upstream 2026-04-19 17:51:49 +00:00
0991ac555e merge upstream 2026-04-17 14:13:58 +00:00
b5a889cbb0 merge upstream 2026-04-16 17:22:23 +00:00
985154c863 merge upstream 2026-04-01 16:15:21 +00:00
f1e07b3d86 merge upstream 2026-03-12 14:13:03 +00:00
062079e7f1 merge upstream 2026-03-10 04:16:36 +00:00
24652884ee merge upstream 2026-02-24 11:02:11 +00:00
ea9cdb93d4 merge upstream 2026-02-11 17:26:07 +00:00
7c4010b275 merge upstream 2026-02-06 07:09:12 +00:00
4458aaf74f merge upstream 2026-02-04 07:52:24 +00:00
f20f871c88 merge upstream 2026-01-28 15:45:54 +00:00
f61fb8c6b3 merge upstream 2026-01-23 07:40:43 +00:00
3d14dee4e9 merge upstream 2026-01-19 13:07:28 +00:00
38647b4ac6 merge upstream 2026-01-15 22:01:54 +00:00
c836b600c6 merge upstream 2026-01-13 09:25:36 +00:00
dfe11a6df2 merge upstream 2025-12-12 09:55:45 +00:00
35cad4e05e Merge pull request 'sync upsteam' (#3) from eatvio/eatvio_app:main into main
Reviewed-on: apoloz/eatvio_app#3
2025-11-20 17:24:29 +00:00
383 changed files with 34904 additions and 6735 deletions

View File

@@ -8,18 +8,27 @@ jobs:
build: build:
runs-on: [macos-arm64] runs-on: [macos-arm64]
steps: steps:
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
- name: Add Homebrew to PATH
run: |
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
- name: Fastlane version - name: Fastlane version
run: fastlane --version run: |
git --version
fastlane --version
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v2 uses: https://github.com/actions/checkout@v2
- name: Checkout env repository - name: Checkout env repository
uses: https://github.com/actions/checkout@v2 uses: https://github.com/actions/checkout@v3
with: with:
repository: mso7nueh/eatvio_app_env repository: eatvio/eatvio_app_env
path: env_repo path: env_repo
token: ${{ vars.MSO7NUEH_TOKEN }} # Здесь может быть токен любого юзера с доступом в gitea
token: ${{ vars.READ_USER_GITEA_TOKEN }}
ref: main ref: main
- name: Copy .env file - name: Copy .env file
@@ -57,23 +66,24 @@ jobs:
- name: Flutter build - name: Flutter build
run: | run: |
flutter upgrade flutter --version
flutter config --no-enable-swift-package-manager
rm -rf ios/Podfile.lock
flutter clean flutter clean
flutter pub get flutter pub get
flutter pub upgrade
flutter pub run build_runner build --delete-conflicting-outputs flutter pub run build_runner build --delete-conflicting-outputs
# Configure Flutter for release mode
pod repo update
flutter build ios --config-only --release
cd ios cd ios
pod update pod install --repo-update
cd ..
flutter build ios --config-only --release
cd ios
fastlane beta fastlane beta
env: env:
LANG: en_US.UTF-8 LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8 LC_ALL: en_US.UTF-8
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
FASTLANE_USER: eatvio2023@icloud.com FASTLANE_USER: eatvio2023@icloud.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}

View File

@@ -1,6 +1,9 @@
name: Eatvio App Android Test name: Eatvio App Android Test
on: [pull_request] on:
pull_request:
branches:
- main
env: env:
flutter_version: '3.41.2' flutter_version: '3.41.2'
@@ -152,9 +155,10 @@ jobs:
- name: Get dependencies - name: Get dependencies
run: | run: |
flutter clean flutter clean
flutter pub get flutter pub cache repair
dart run build_runner build --delete-conflicting-outputs flutter pub get
dart run build_runner build --delete-conflicting-outputs
- name: Native Analyze - name: Native Analyze
run: flutter analyze run: flutter analyze
@@ -162,66 +166,66 @@ jobs:
- name: Test - name: Test
run: flutter test --coverage run: flutter test --coverage
- name: Test coverage # - name: Test coverage
run: | # run: |
echo "📊 Analyzing test coverage..." # echo "📊 Analyzing test coverage..."
echo "" # echo ""
awk -F: ' # awk -F: '
/^SF:/ { # /^SF:/ {
file = $0 # file = $0
sub(/^SF:/, "", file) # sub(/^SF:/, "", file)
sub(/^[[:space:]]+/, "", file) # sub(/^[[:space:]]+/, "", file)
is_generated = 0 # is_generated = 0
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) { # if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
is_generated = 1 # is_generated = 1
generated_files++ # generated_files++
} else if (file ~ /\/(build|\.dart_tool)\//) { # } else if (file ~ /\/(build|\.dart_tool)\//) {
is_generated = 1 # is_generated = 1
generated_files++ # generated_files++
} else { # } else {
source_files++ # source_files++
} # }
} # }
/^LF:/ { # /^LF:/ {
if (!is_generated) { # if (!is_generated) {
total += $2 # total += $2
} else { # } else {
generated_lines += $2 # generated_lines += $2
} # }
} # }
/^LH:/ { # /^LH:/ {
if (!is_generated) { # if (!is_generated) {
covered += $2 # covered += $2
} else { # } else {
generated_covered += $2 # generated_covered += $2
} # }
} # }
END { # END {
print "📁 Files:" # print "📁 Files:"
printf " Source files: %d\n", source_files # printf " Source files: %d\n", source_files
printf " Generated files: %d (excluded)\n", generated_files # printf " Generated files: %d (excluded)\n", generated_files
print "" # print ""
print "📝 Lines:" # print "📝 Lines:"
printf " Total lines: %d\n", total # printf " Total lines: %d\n", total
printf " Covered lines: %d\n", covered # printf " Covered lines: %d\n", covered
printf " Uncovered lines: %d\n", total - covered # printf " Uncovered lines: %d\n", total - covered
print "" # print ""
if (total == 0) { # if (total == 0) {
print "⚠️ Coverage: 0%" # print "⚠️ Coverage: 0%"
exit 1 # exit 1
} else { # } else {
percentage = (covered / total) * 100 # percentage = (covered / total) * 100
printf "✅ Coverage: %.2f%%\n", percentage # printf "✅ Coverage: %.2f%%\n", percentage
print "" # print ""
if (percentage < 70) { # if (percentage < 70) {
printf "❌ FAIL: Coverage is below 70%%\n" # printf "❌ FAIL: Coverage is below 70%%\n"
exit 1 # exit 1
} else { # } else {
printf "💚 Excellent: Coverage is above 70%%\n" # printf "💚 Excellent: Coverage is above 70%%\n"
} # }
} # }
} # }
' coverage/lcov.info # ' coverage/lcov.info
- name: Build apk - name: Build apk
run: | run: |

3
.gitignore vendored
View File

@@ -24,4 +24,5 @@ coverage/
backend/ backend/
.fvm/ .fvm/
.fvmrc .fvmrc
/eatvio /eatvio
*.code-workspace

View File

@@ -1,7 +1,7 @@
# Current Context # Current Context
## Project Status ## Project Status
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production. Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
## Recent Activity ## Recent Activity
- Memory Bank initialization completed - Memory Bank initialization completed
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
- Architecture and technology stack catalogued - Architecture and technology stack catalogued
## Current Focus ## Current Focus
Ready for development tasks. No active work in progress. Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
## Known Issues ## Known Issues
None documented at initialization. - 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
связаны с fixtures/маппингом, не с видеоплеером.
## Next Steps ## Next Steps
Awaiting user direction for: Awaiting user direction for:

View File

@@ -10,4 +10,4 @@ build-generated-files:
dart run build_runner build --delete-conflicting-outputs dart run build_runner build --delete-conflicting-outputs
clean-coverage: clean-coverage:
@./scripts/remove_generated_from_coverage.sh @./scripts/remove_generated_from_coverage.sh

View File

@@ -69,6 +69,8 @@ android {
} }
dependencies { dependencies {
implementation platform('com.google.firebase:firebase-bom:34.4.0')
implementation 'com.google.firebase:firebase-messaging'
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
implementation 'com.huawei.hms:push:6.13.0.301' implementation 'com.huawei.hms:push:6.13.0.301'
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8 // Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8

View File

@@ -4,6 +4,12 @@
-keep class org.bouncycastle.** { *; } -keep class org.bouncycastle.** { *; }
-dontwarn org.bouncycastle.** -dontwarn org.bouncycastle.**
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
-dontwarn com.hiennv.flutter_callkit_incoming.**
-keep class com.pravera.flutter_foreground_task.** { *; }
-dontwarn com.pravera.flutter_foreground_task.**
-keep class io.flutter.** { *; } -keep class io.flutter.** { *; }
-dontwarn io.flutter.** -dontwarn io.flutter.**

View File

@@ -1,9 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -13,6 +17,16 @@
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<service
android:name=".EatvioFcmMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service <service
android:name=".HmsPushService" android:name=".HmsPushService"
android:exported="false"> android:exported="false">
@@ -33,7 +47,7 @@
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:supportsPictureInPicture="true"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -64,12 +78,7 @@
<data android:host="eatvio.com" /> <data android:host="eatvio.com" />
<data android:host="www.eatvio.com" /> <data android:host="www.eatvio.com" />
<!-- Development/Subdomains -->
<data android:host="*.eatvio.ru" />
<data android:host="*.eatvio.com" />
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
<data android:pathPattern="/.*" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
@@ -77,6 +86,68 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
Replace it with a bridge that routes to the same accept flow as Answer. -->
<activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<activity
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="microphone"
android:exported="false"
android:stopWithTask="true" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@@ -88,5 +159,168 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- SBP bank packages for СБП payment detection -->
<package android:name="ru.sberbankmobile" />
<package android:name="com.idamob.tinkoff.android" />
<package android:name="ru.vtb24.mobilebanking.android" />
<package android:name="ru.alfabank.mobile.android" />
<package android:name="ru.raiffeisennews" />
<package android:name="com.openbank" />
<package android:name="ru.gazprombank.android.mobilebank.app" />
<package android:name="com.logo.mbanking" />
<package android:name="ru.sovcomcard.halva.v1" />
<package android:name="ru.rosbank.android" />
<package android:name="ru.rshb.dbo" />
<package android:name="ru.mkb.mobile" />
<package android:name="ru.ucb.android" />
<package android:name="ru.unicredit.android" />
<package android:name="ru.skbbank.ib" />
<package android:name="ru.gebank.ib" />
<package android:name="ru.avangard.sbpent" />
<package android:name="ru.elplat.elplat2" />
<package android:name="com.bifit.rncbbeta" />
<package android:name="ru.ftc.faktura.expobank" />
<package android:name="com.bssys.vbrrretail" />
<package android:name="ru.wildberries.razz" />
<package android:name="ru.artsofte.russiafl" />
<package android:name="ru.ftc.faktura.multibank" />
<package android:name="ru.bank_hlynov.xbank" />
<package android:name="ru.ftc.faktura.vesta" />
<package android:name="com.isimplelab.ibank.solidarnost" />
<package android:name="com.bssys.roscapretail" />
<package android:name="ru.ftc.faktura.kbhmb" />
<package android:name="ru.mts.money" />
<package android:name="ru.ftc.faktura.pskb" />
<package android:name="ru.ftc.faktura.nskbl" />
<package android:name="ru.akbars.mobile" />
<package android:name="ru.ftc.faktura.rostfinance" />
<package android:name="ru.ilb.bbmobile" />
<package android:name="ru.mcbankrus.MCBankRus" />
<package android:name="ru.albank.online.aebit" />
<package android:name="com.bifit.mobile.citizen.icbru" />
<package android:name="ru.ftc.faktura.orbank" />
<package android:name="ru.ftc.faktura.agropromkredit" />
<package android:name="com.energobank.digital" />
<package android:name="com.bifit.mobile.citizen.kbnk" />
<package android:name="ru.ftc.faktura.sdm" />
<package android:name="com.bifit.mobile.citizen.mbb" />
<package android:name="ru.ftc.faktura.absolutbank" />
<package android:name="ru.ftc.faktura.akcept" />
<package android:name="ru.faktura.etbank.person" />
<package android:name="ru.ftc.faktura.razvitiestolica" />
<package android:name="ru.ftc.faktura.resokreditbank" />
<package android:name="ru.yoo.money" />
<package android:name="ru.bspb" />
<package android:name="com.isimplelab.ibank.kubankredit" />
<package android:name="com.tochka.bank" />
<package android:name="com.bssys.novikomretail" />
<package android:name="ru.rosdorbank.physical" />
<package android:name="ru.ftc.faktura.primsoc" />
<package android:name="ru.ftc.faktura.banksaratov" />
<package android:name="ru.ftc.faktura.tavrich" />
<package android:name="com.bifit.mobile.citizen.thbank" />
<package android:name="ru.ftc.faktura.kremlevskiy" />
<package android:name="ru.ftc.faktura.tkbbank" />
<package android:name="ru.chelyabinvestbank.investpay" />
<package android:name="ru.ftc.faktura.derzhava" />
<package android:name="ru.nbd.android" />
<package android:name="ru.sngb.dbo.client.android" />
<package android:name="ru.ftc.faktura.united" />
<package android:name="ru.ftc.faktura.venetsbank" />
<package android:name="ru.letobank.Prometheus" />
<package android:name="ru.simpls.brs2.mobbank" />
<package android:name="com.bifit.dvbank" />
<package android:name="ru.ftc.faktura.intesabank" />
<package android:name="com.mmonline.mobile" />
<package android:name="cb.ibank.vuz" />
<package android:name="ru.uralprombank.mobilebanknew.googleplay" />
<package android:name="ru.ftc.faktura.nsbank" />
<package android:name="ru.emb.android" />
<package android:name="ru.ftc.faktura.maritimebank" />
<package android:name="com.credituralbank.CUBmobilenew" />
<package android:name="com.isimplelab.isimpleceo.uglemet" />
<package android:name="com.bssys.stroylesretail" />
<package android:name="ru.ftc.faktura.iturup" />
<package android:name="ru.ftc.faktura.finbank" />
<package android:name="ru.faktura.gaztransbank.person" />
<package android:name="com.isimplelab.ibank.chelind" />
<package android:name="ru.ftc.faktura.nokss" />
<package android:name="ru.ftc.faktura.vlbb" />
<package android:name="ru.ftc.faktura.kbb" />
<package android:name="ru.faktura.avtograd.person" />
<package android:name="ru.ftc.faktura.forshtadt" />
<package android:name="ru.ftc.faktura.solidbank" />
<package android:name="ru.ftc.faktura.alexbank" />
<package android:name="ru.ftc.faktura.akibank" />
<package android:name="ru.ftc.faktura.nipbank" />
<package android:name="ru.ftc.faktura.alefbank" />
<package android:name="com.bifit.vfbank" />
<package android:name="com.bssys.uralfdretail" />
<package android:name="com.snb.online" />
<package android:name="com.bifit.mobile.citizen.okbank" />
<package android:name="com.tatsotsbank.dbomobile" />
<package android:name="ru.vtkbank.android" />
<package android:name="ru.faktura.jtbank.person" />
<package android:name="ru.ftc.faktura.ns" />
<package android:name="ru.ftc.faktura.zemskybank" />
<package android:name="com.bssys.aversretail" />
<package android:name="ru.rusnarbank.correqts.retail" />
<package android:name="ru.ftc.faktura.baikalinvestbank" />
<package android:name="ru.sbi.android" />
<package android:name="com.metkombank.mobile" />
<package android:name="com.isimplelab.ionic.kazan.fl" />
<package android:name="com.intervale.sbpas" />
<package android:name="com.bifit.mobile.citizen.zarech" />
<package android:name="ru.centrinvest.mobilebanking2018" />
<package android:name="com.mifors.izhcombank" />
<package android:name="com.bifit.pmobile.isbank" />
<package android:name="ru.zenit.android" />
<package android:name="ru.briginvest.sense" />
<package android:name="com.bifit.mobile.citizen.cfb" />
<package android:name="com.isimplelab.ionic.gorbank.prod" />
<package android:name="ru.ftc.faktura.moscombank" />
<package android:name="com.timerbank.retail" />
<package android:name="com.mob.android" />
<package android:name="com.citibank.mobile.ru" />
<package android:name="com.bifit.atbbank" />
<package android:name="ru.mw" />
<package android:name="ru.bankuralsib.mb.android" />
<package android:name="com.banksoyuz.artsofte" />
<package android:name="ru.rubank.ubsmobile" />
<package android:name="cb.ibank" />
<package android:name="ru.ipb.ubsmobile" />
<package android:name="com.openwaygroup.ic.panda.poidem" />
<package android:name="com.bifit.mobile.citizen.tenderbank" />
<package android:name="ru.otpbank.mobile" />
<package android:name="ru.krk.ubsmobile" />
<package android:name="ru.homecredit.mycredit" />
<package android:name="cz.bsc.rc" />
<package android:name="com.bifit.mobile.citizen.belsocbank" />
<package android:name="com.hicebank.android" />
<package android:name="com.bpc.crossplatform_trading.bpc_trading" />
<package android:name="com.bifit.nrb" />
<package android:name="com.bifit.mobile.citizen.moskb" />
<package android:name="ru.isfront.android.kt" />
<package android:name="ru.ftc.faktura.novobank" />
<package android:name="com.smp.android" />
<package android:name="com.bifit.mobile.citizen.nkbank" />
<package android:name="com.isimplelab.ibank.toyota" />
<package android:name="com.bifit.mobile.private.bbr" />
<package android:name="ru.ftc.faktura.lanta" />
<package android:name="com.yandex.bank" />
<package android:name="ru.ftc.faktura.bgfbank" />
<package android:name="com.idamobile.android.LockoBank" />
<package android:name="com.bifit.mobile.citizen.MCBank" />
<package android:name="com.cetelem.cetelem_android" />
<package android:name="com.bssys.gutaretail" />
<package android:name="ru.ftc.faktura.siab" />
<package android:name="com.isimplelab.ionic.round.prod" />
<package android:name="com.priobank.prio" />
<package android:name="com.inbank.mobilebank" />
<package android:name="com.isimplelab.isimplemobile.payjet" />
<package android:name="ru.ftc.faktura.agroros" />
<package android:name="com.bifit.mobile.citizen.invb" />
<package android:name="com.compassplus.mobicash.customer" />
<package android:name="ru.finambank.app" />
</queries> </queries>
</manifest> </manifest>

View File

@@ -0,0 +1,53 @@
package com.eatvioapp.app
import android.content.Intent
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
object CallKitAcceptIntentMapper {
fun payloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
) {
return null
}
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
}
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
if (intent == null) return null
val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
?: return null
val action = intent.action ?: return null
if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) &&
!action.contains(CallkitConstants.ACTION_CALL_CALLBACK)
) {
return null
}
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
}

View File

@@ -0,0 +1,38 @@
package com.eatvioapp.app
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.AppUtils
import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver
object CallKitIntentBridge {
fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) {
if (data != null) {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)
}
}
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)
broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
context.sendBroadcast(broadcastIntent)
val shouldLaunchMainActivity =
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT ||
action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK ||
!MainActivity.isInForeground
if (!shouldLaunchMainActivity) {
return
}
val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return
activityIntent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
)
context.startActivity(activityIntent)
}
}

View File

@@ -0,0 +1,29 @@
package com.eatvioapp.app
import android.app.Activity
import android.os.Bundle
import com.hiennv.flutter_callkit_incoming.CallkitConstants
/// Redirects notification body / full-screen intent taps to the same accept path as
/// the Answer button, instead of showing the native Callkit incoming UI.
class EatvioCallkitIncomingBridgeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA)
if (data == null) {
finish()
return
}
CallKitIntentBridge.dispatchCallKitAction(
this,
CallkitConstants.ACTION_CALL_ACCEPT,
data,
)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}

View File

@@ -0,0 +1,25 @@
package com.eatvioapp.app
import android.app.Activity
import android.os.Bundle
/// Replaces plugin TransparentActivity: forwards CallKit actions without relaunching
/// MainActivity when the Flutter app is already in the foreground.
class EatvioCallkitTransparentBridgeActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val action = intent.action
if (action.isNullOrBlank()) {
finish()
return
}
val data = intent.getBundleExtra("data")
CallKitIntentBridge.dispatchCallKitAction(this, action, data)
finish()
@Suppress("DEPRECATION")
overridePendingTransition(0, 0)
}
}

View File

@@ -0,0 +1,36 @@
package com.eatvioapp.app
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData
class EatvioFcmMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
FlutterFirebaseTokenLiveData.getInstance().postToken(token)
Log.i(TAG, "onNewToken")
}
override fun onMessageReceived(message: RemoteMessage) {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
return
}
if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) {
return
}
// Data-only call pushes are delivered only to this service.
// FlutterFirebaseMessagingService is removed, so the Dart listener does
// not see them. Show the incoming UI here for both foreground and background.
VideoCallPushBridge.showIncomingCall(this, message.data)
}
companion object {
private const val TAG = "EatvioFCM"
}
}

View File

@@ -20,17 +20,23 @@ class HmsPushService : HmsMessageService() {
if (message == null) return if (message == null) return
// Берём action_url из data-поля, если оно есть. if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
val actionUrl = message.dataOfMap["action_url"] VideoCallPushBridge.dismissAllIncomingCalls(this)
return
}
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
return
}
val actionUrl = message.dataOfMap["action_url"]
showNotification(message, actionUrl) showNotification(message, actionUrl)
} }
private fun showNotification(message: RemoteMessage, actionUrl: String?) { private fun showNotification(message: RemoteMessage, actionUrl: String?) {
val context = applicationContext val context = applicationContext
// Интент для запуска Flutter-активити.
val intent = Intent(context, MainActivity::class.java).apply { val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
if (!actionUrl.isNullOrBlank()) { if (!actionUrl.isNullOrBlank()) {
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
val channelId = "hms_push_channel" val channelId = "hms_push_channel"
val notification = NotificationCompat.Builder(context, channelId) val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения .setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
.setContentText(message.notification?.body ?: "") .setContentText(message.notification?.body ?: "")
.setAutoCancel(true) .setAutoCancel(true)
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
} }
} }

View File

@@ -1,27 +1,104 @@
package com.eatvioapp.app package com.eatvioapp.app
import android.app.PictureInPictureParams
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.huawei.agconnect.config.AGConnectServicesConfig import com.huawei.agconnect.config.AGConnectServicesConfig
import com.huawei.hms.aaid.HmsInstanceId import com.huawei.hms.aaid.HmsInstanceId
import com.huawei.hms.api.ConnectionResult
import com.huawei.hms.api.HuaweiApiAvailability
import com.huawei.hms.common.ApiException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
companion object {
@JvmStatic
var isInForeground: Boolean = false
private set
}
private val channelName = "com.eatvioapp/hms_push" private val channelName = "com.eatvioapp/hms_push"
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
private var methodChannel: MethodChannel? = null private var methodChannel: MethodChannel? = null
private var videoCallPipChannel: MethodChannel? = null
private var videoCallPushChannel: MethodChannel? = null
private var videoCallPipEnabled: Boolean = false
override fun onResume() {
super.onResume()
isInForeground = true
captureCallKitLaunchIntent(intent)
if (PendingCallKitAcceptBridge.peek() != null) {
scheduleDeliverPendingCallKitAccept()
}
}
override fun onPause() {
isInForeground = false
super.onPause()
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
videoCallPushChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"dismissIncomingCalls" -> {
try {
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
result.success(null)
} catch (e: Exception) {
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
}
}
"drainPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.drain())
}
"peekPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.peekPending())
}
else -> result.notImplemented()
}
}
videoCallPipChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"setPipEnabled" -> {
videoCallPipEnabled = call.arguments as? Boolean ?: false
result.success(null)
}
else -> result.notImplemented()
}
}
methodChannel?.setMethodCallHandler { call, result -> methodChannel?.setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
"isHmsAvailable" -> {
CoroutineScope(Dispatchers.IO).launch {
val available = try {
HuaweiApiAvailability.getInstance()
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
} catch (e: Exception) {
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
false
}
runOnUiThread {
result.success(available)
}
}
}
"getHuaweiToken" -> { "getHuaweiToken" -> {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
@@ -61,10 +138,30 @@ class MainActivity: FlutterActivity() {
runOnUiThread { runOnUiThread {
result.success(token) result.success(token)
} }
} catch (e: ApiException) {
if (e.statusCode == 907135003) {
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
}
}
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e) val message = e.message.orEmpty()
runOnUiThread { if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
result.error("HMS_TOKEN_ERROR", e.message, null) android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
runOnUiThread {
result.success(null)
}
} else {
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
runOnUiThread {
result.error("HMS_TOKEN_ERROR", e.message, null)
}
} }
} }
} }
@@ -75,17 +172,67 @@ class MainActivity: FlutterActivity() {
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
handleIntentForActionUrl(intent) handleIntentForActionUrl(intent)
captureCallKitLaunchIntent(intent)
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
enterVideoCallPictureInPictureIfNeeded()
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent)
captureCallKitLaunchIntent(intent)
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению). // Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
handleIntentForActionUrl(intent) handleIntentForActionUrl(intent)
} }
private fun captureCallKitLaunchIntent(intent: Intent?) {
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
android.util.Log.i(
"VideoCallPush",
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
)
PendingCallKitAcceptBridge.store(payload)
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
scheduleDeliverPendingCallKitAccept()
}
private fun scheduleDeliverPendingCallKitAccept() {
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitAcceptToFlutter() },
)
}
private fun deliverPendingCallKitAcceptToFlutter() {
val payload = PendingCallKitAcceptBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitAcceptToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
)
channel.invokeMethod("onCallKitLaunchIntent", payload)
}
private fun handleIntentForActionUrl(intent: Intent?) { private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return if (intent == null) return
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
return
}
// Вариант 1: action_url лежит в extras. // Вариант 1: action_url лежит в extras.
val fromExtras = intent.extras?.getString("action_url") val fromExtras = intent.extras?.getString("action_url")
@@ -101,4 +248,19 @@ class MainActivity: FlutterActivity() {
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
methodChannel?.invokeMethod("openActionUrl", actionUrl) methodChannel?.invokeMethod("openActionUrl", actionUrl)
} }
private fun enterVideoCallPictureInPictureIfNeeded() {
if (!videoCallPipEnabled) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (isInPictureInPictureMode) return
try {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(2, 3))
.build()
enterPictureInPictureMode(params)
} catch (e: Exception) {
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
}
}
} }

View File

@@ -0,0 +1,42 @@
package com.eatvioapp.app
import android.os.Handler
import android.os.Looper
import android.util.Log
/// Stores accepted call payloads until the Flutter engine is ready to drain them.
object PendingCallKitAcceptBridge {
private const val TAG = "PendingCallKitAccept"
@Volatile
private var pendingPayload: Map<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending accept id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
val payload = pendingPayload
pendingPayload = null
if (payload != null) {
Log.i(TAG, "drained pending accept id=${payload["id"]}")
}
return payload
}
fun peekPending(): Map<String, Any?>? = pendingPayload
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

@@ -0,0 +1,318 @@
package com.eatvioapp.app
import android.content.Context
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.hiennv.flutter_callkit_incoming.CallkitConstants
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
import com.hiennv.flutter_callkit_incoming.CallkitNotificationService
import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager
import com.hiennv.flutter_callkit_incoming.Data
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
import com.hiennv.flutter_callkit_incoming.addCall
import com.hiennv.flutter_callkit_incoming.getDataActiveCalls
import com.hiennv.flutter_callkit_incoming.removeAllCalls
import java.util.HashMap
object VideoCallPushBridge {
private const val TAG = "VideoCallPushBridge"
private const val ANDROID_ACCENT_COLOR = "#2787F5"
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
private var activeSoundManager: CallkitSoundPlayerManager? = null
private var activeNotificationManager: CallkitNotificationManager? = null
fun isIncomingVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"]
?: data["notification_type"]
?: data["event"]
?: data["action"]
?: data["push_type"]
?: data["call_type"]
if (rawType != null) {
val normalized = rawType.lowercase().replace("-", "_")
if (normalized.contains("video_call")) {
return !normalized.contains("end") && !normalized.contains("ended")
}
if (normalized == "incoming_call" ||
normalized == "video_call_incoming" ||
normalized == "incoming_video_call"
) {
return true
}
}
if (isTruthy(data["is_video_call"]) ||
isTruthy(data["incoming_call"]) ||
isTruthy(data["is_incoming_call"])
) {
return conversationUuid(data) != null
}
val actionUrl = data["action_url"]
if (!actionUrl.isNullOrBlank() &&
(actionUrl.contains("video-call") || actionUrl.contains("videoCall"))
) {
return true
}
return false
}
fun isEndedVideoCall(data: Map<String, String>): Boolean {
val rawType = data["type"]
?: data["notification_type"]
?: data["event"]
?: data["action"]
?: data["push_type"]
?: data["call_type"]
if (rawType != null) {
val normalized = rawType.lowercase().replace("-", "_")
if (normalized == "video_call_ended" || normalized == "video_call_end") {
return true
}
if (normalized.contains("video_call") &&
(normalized.contains("ended") || normalized.endsWith("_end"))
) {
return true
}
}
return false
}
fun showIncomingCall(context: Context, data: Map<String, String>) {
val appContext = context.applicationContext
val callData = buildCallDataBundle(data)
if (callData == null) {
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
return
}
Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}")
val soundManager = CallkitSoundPlayerManager(appContext)
val notificationManager = CallkitNotificationManager(appContext, soundManager)
activeSoundManager = soundManager
activeNotificationManager = notificationManager
notificationManager.createNotificationChanel(callData)
notificationManager.showIncomingNotification(callData)
try {
addCall(appContext, Data.fromBundle(callData))
} catch (error: Exception) {
Log.w(TAG, "showIncomingCall: failed to persist active call", error)
}
}
fun dismissAllIncomingCalls(context: Context) {
val appContext = context.applicationContext
val calls = getDataActiveCalls(appContext)
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
stopIncomingRingtone(appContext)
val notificationManager = resolveNotificationManager(appContext)
val bundles = calls.map { it.toBundle() }
for (bundle in bundles) {
try {
dismissIncomingCallNotification(appContext, notificationManager, bundle)
} catch (error: Exception) {
Log.w(
TAG,
"dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
error,
)
}
}
try {
CallkitNotificationService.stopService(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error)
}
try {
removeAllCalls(appContext)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error)
}
stopIncomingRingtone(appContext)
scheduleMissedCallCleanup(notificationManager, bundles)
activeSoundManager = null
activeNotificationManager = null
}
private fun resolveNotificationManager(context: Context): CallkitNotificationManager {
activeNotificationManager?.let { return it }
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitNotificationManager()
?.let { return it }
val soundManager = CallkitSoundPlayerManager(context)
return CallkitNotificationManager(context, soundManager)
}
private fun stopIncomingRingtone(context: Context) {
activeSoundManager?.stop()
FlutterCallkitIncomingPlugin.getInstance()
?.getCallkitSoundPlayerManager()
?.stop()
}
/**
* Replaces the incoming notification before cancel so Android does not fire the
* timeout delete intent (which would show a missed-call notification).
*/
private fun dismissIncomingCallNotification(
context: Context,
notificationManager: CallkitNotificationManager,
bundle: Bundle,
) {
val notificationId = incomingNotificationId(bundle)
val silentNotification = NotificationCompat.Builder(
context,
CallkitNotificationManager.NOTIFICATION_CHANNEL_ID_INCOMING,
)
.setSmallIcon(context.applicationInfo.icon)
.setContentTitle("")
.setSilent(true)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
val notificationManagerCompat = NotificationManagerCompat.from(context)
notificationManagerCompat.notify(notificationId, silentNotification)
notificationManagerCompat.cancel(notificationId)
bundle.putBoolean(CallkitConstants.EXTRA_CALLKIT_MISSED_CALL_SHOW, false)
notificationManager.clearIncomingNotification(bundle, isAccepted = true)
notificationManager.clearMissCallNotification(bundle)
}
private fun scheduleMissedCallCleanup(
notificationManager: CallkitNotificationManager,
bundles: List<Bundle>,
) {
if (bundles.isEmpty()) return
Handler(Looper.getMainLooper()).postDelayed({
for (bundle in bundles) {
try {
notificationManager.clearMissCallNotification(bundle)
} catch (error: Exception) {
Log.w(TAG, "dismissAllIncomingCalls: delayed missed-call cleanup failed", error)
}
}
}, MISSED_CALL_CLEANUP_DELAY_MS)
}
private fun incomingNotificationId(bundle: Bundle): Int {
return bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID, "callkit_incoming").hashCode()
}
private fun buildCallDataBundle(data: Map<String, String>): Bundle? {
val conversationUuid = conversationUuid(data) ?: return null
val callerName = data["counterpartName"]
?: data["counterpart_name"]
?: data["caller_name"]
?: data["callerName"]
?: data["nameCaller"]
?: data["caller"]
?: "Итвио"
val avatarUrl = data["counterpartAvatarUrl"]
?: data["counterpart_avatar_url"]
?: data["avatar"]
?: data["avatarUrl"]
?: data["avatar_url"]
?: data["caller_avatar"]
?: ""
val localParticipantName = data["localParticipantName"]
?: data["local_participant_name"]
val counterpartIsConsultant = isTruthy(
data["counterpartIsConsultant"]
?: data["counterpart_is_consultant"]
?: data["is_consultant"]
?: data["isConsultant"]
)
val extra = HashMap<String, Any?>()
extra["conversationUuid"] = conversationUuid
extra["counterpartName"] = callerName
if (avatarUrl.isNotBlank()) {
extra["counterpartAvatarUrl"] = avatarUrl
}
if (!localParticipantName.isNullOrBlank()) {
extra["localParticipantName"] = localParticipantName
}
if (counterpartIsConsultant) {
extra["counterpartIsConsultant"] = true
}
return Bundle().apply {
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName)
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra)
putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap<String, Any?>())
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false)
putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default")
putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR)
putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR)
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff")
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false)
putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false)
}
}
private fun conversationUuid(data: Map<String, String>): String? {
val fromData = data["conversationUuid"]
?: data["conversation_uuid"]
?: data["conversation_id"]
?: data["chat_uuid"]
?: data["uuid"]
if (!fromData.isNullOrBlank()) {
return fromData
}
val actionUrl = data["action_url"] ?: return null
val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""")
.find(actionUrl)
?.groupValues
?.getOrNull(1)
?.trim()
if (!uuidFromQuery.isNullOrEmpty()) {
return uuidFromQuery
}
val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl)
return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() }
}
private fun isTruthy(value: String?): Boolean {
if (value.isNullOrBlank()) return false
return when (value.trim().lowercase()) {
"1", "true", "yes" -> true
else -> false
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.3438 5.34375C14.9405 5.34375 15.5128 5.5808 15.9347 6.00276C16.3567 6.42472 16.5938 6.99701 16.5938 7.59375M14.3438 0.84375C16.134 0.84375 17.8509 1.55491 19.1167 2.82078C20.3826 4.08665 21.0938 5.80354 21.0938 7.59375M3.09375 1.96875H7.59375L9.84375 7.59375L7.03125 9.28125C8.23608 11.7242 10.2133 13.7014 12.6562 14.9062L14.3438 12.0938L19.9688 14.3438V18.8438C19.9688 19.4405 19.7317 20.0128 19.3097 20.4347C18.8878 20.8567 18.3155 21.0938 17.7188 21.0938C13.3304 20.8271 9.19142 18.9636 6.08268 15.8548C2.97394 12.7461 1.11043 8.60708 0.84375 4.21875C0.84375 3.62201 1.0808 3.04972 1.50276 2.62776C1.92472 2.2058 2.49701 1.96875 3.09375 1.96875Z" stroke="white" stroke-width="1.6875" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 853 B

View File

@@ -44,11 +44,11 @@ post_install do |installer|
'$(inherited)', '$(inherited)',
## dart: PermissionGroup.camera ## dart: PermissionGroup.camera
'PERMISSION_CAMERA=1', 'PERMISSION_CAMERA=1',
## dart: PermissionGroup.microphone
'PERMISSION_MICROPHONE=1',
## dart: PermissionGroup.photos ## dart: PermissionGroup.photos
'PERMISSION_PHOTOS=1', 'PERMISSION_PHOTOS=1',
'PERMISSION_NOTIFICATIONS=1', 'PERMISSION_NOTIFICATIONS=1',
## dart: PermissionGroup.mediaLibrary
'PERMISSION_MEDIA_LIBRARY=1',
] ]
end end

View File

@@ -397,7 +397,7 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Eatvio; INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -548,7 +548,7 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Eatvio; INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -587,7 +587,7 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Eatvio; INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

@@ -1,30 +1,642 @@
import AVFAudio
import CallKit
import Flutter import Flutter
import PushKit
import UIKit import UIKit
import UserNotifications
import WebRTC
import YandexLoginSDK import YandexLoginSDK
import flutter_callkit_incoming
import flutter_foreground_task
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
private var voipRegistry: PKPushRegistry?
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private let appUrlScheme = "eatvio"
/// CallKit is only the ringing UI. After accept the in-app call continues,
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
private var resumeAudioAfterCallKitEnds = false
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
let mainQueue = DispatchQueue.main
voipRegistry = PKPushRegistry(queue: mainQueue)
voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
}
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: controller.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
default:
result(FlutterMethodNotImplemented)
}
}
let callAudioChannel = FlutterMethodChannel(
name: callAudioSessionChannelName,
binaryMessenger: controller.binaryMessenger
)
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "activate":
self.activateInAppCallAudioSession()
result(nil)
case "deactivate":
self.deactivateInAppCallAudioSession()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
}
return launched
} }
@available(iOS 8.0, *) @available(iOS 8.0, *)
func application( override func application(
_ application: UIApplication, continue userActivity: NSUserActivity, _ application: UIApplication,
restorationHandler: @escaping ([Any]?) -> Void continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool { ) -> Bool {
YXLSdk.shared.processUserActivity(userActivity) if YXLSdk.shared.processUserActivity(userActivity) {
return true return true
}
return super.application(
application,
continue: userActivity,
restorationHandler: restorationHandler
)
} }
override public func application( override public func application(
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] _ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool { ) -> Bool {
if url.scheme == appUrlScheme {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let callKitId = queryItems.first { $0.name == "callKitId" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines)
recordVoipDiagnostic(
"accepted_call_url_opened",
data: [
"callKitId": callKitId ?? "",
"hasCallKitId": callKitId != nil,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
voipDiagnosticsChannel?.invokeMethod(
"acceptedVoipCall",
arguments: [
"id": callKitId,
"handle": conversationUuid,
"extra": [
"conversationUuid": conversationUuid
],
]
)
}
return true
}
return YXLSdk.shared.handleOpen( return YXLSdk.shared.handleOpen(
url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String) url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
)
}
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
/// so the next call in this chat is not rejected as a duplicate UUID.
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
guard let messageId, messageId > 0 else { return nil }
let pattern =
#"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
guard let match = regex.firstMatch(in: conversationUuid, range: range),
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
else { return nil }
let suffix = String(format: "%012llx", messageId)
guard suffix.count == 12 else { return nil }
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
}
private func voipMessageId(_ value: Any?) -> UInt64? {
if let number = value as? NSNumber {
let parsed = number.uint64Value
return parsed > 0 ? parsed : nil
}
if let text = value as? String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
return parsed
}
return nil
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
let components = URLComponents(string: actionUrl)
else {
return nil
}
let queryItems = components.queryItems ?? []
for key: String in ["uuid", "conversationUuid", "conversation_uuid"] {
if let value = queryItems.first(where: { $0.name == key })?.value,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
let segments = components.path.split(separator: "/").map(String.init)
for index in segments.indices where segments[index] == "video-call" {
let nextIndex = segments.index(after: index)
if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty {
return segments[nextIndex]
}
}
return nil
}
private func conversationUuid(fromCall call: Call) -> String? {
let extra = call.data.extra as? [String: Any] ?? [:]
for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] {
if let value = extra[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines)
if !handle.isEmpty {
return handle
}
return nil
}
private func storePendingAcceptedCall(_ call: Call) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
return payload
}
private func bringAppToForeground() {
DispatchQueue.main.async {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.makeKeyAndVisible()
}
}
self.window?.makeKeyAndVisible()
}
}
private func openAppForAcceptedCall(_ call: Call) {
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
recordVoipDiagnostic(
"accepted_call_missing_conversation_uuid",
data: ["callKitId": call.data.uuid]
)
return
}
var components = URLComponents()
components.scheme = appUrlScheme
components.host = "video-call"
components.queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
guard let url = components.url else {
recordVoipDiagnostic(
"accepted_call_url_build_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
]
)
return
}
recordVoipDiagnostic(
"accepted_call_open_app_requested",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
"appState": UIApplication.shared.applicationState.rawValue,
]
)
bringAppToForeground()
UIApplication.shared.open(url, options: [:]) { [weak self] success in
self?.recordVoipDiagnostic(
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
}
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
guard let value = value else {
return ""
}
if let string = value as? String {
return string
}
if let number = value as? NSNumber {
return number
}
if let bool = value as? Bool {
return bool
}
if let dictionary = value as? [AnyHashable: Any] {
return dictionary.reduce(into: [String: Any]()) { result, entry in
result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value)
}
}
if let array = value as? [Any] {
return array.map { sanitizedDiagnosticValue($0) }
}
return String(describing: value)
}
private func recordVoipDiagnostic(_ name: String, data: [String: Any]) {
let defaults = UserDefaults.standard
var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
var event: [String: Any] = [
"name": name,
"timestamp": ISO8601DateFormatter().string(from: Date()),
"appState": UIApplication.shared.applicationState.rawValue,
]
event["data"] = sanitizedDiagnosticValue(data)
events.append(event)
if events.count > 20 {
events = Array(events.suffix(20))
}
defaults.set(events, forKey: voipDiagnosticsStorageKey)
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
}
private func drainVoipDiagnostics() -> [[String: Any]] {
let defaults = UserDefaults.standard
let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
defaults.removeObject(forKey: voipDiagnosticsStorageKey)
return events
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
for type: PKPushType
) {
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let dict = payload.dictionaryPayload
let pushMarkers = [
dict["type"] as? String,
dict["notification_type"] as? String,
dict["event"] as? String,
dict["action"] as? String,
dict["push_type"] as? String,
dict["call_type"] as? String,
]
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
if pushMarkers.contains(where: { $0.contains("video_call") })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
"markers": pushMarkers,
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
}
let rawNameCaller = (dict["nameCaller"] as? String)
?? (dict["counterpartName"] as? String)
?? (dict["counterpart_name"] as? String)
?? (dict["caller_name"] as? String)
?? (dict["callerName"] as? String)
?? (dict["caller"] as? String)
?? ""
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Итвио"
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
let conversationUuid = (dict["conversation_uuid"] as? String)
?? (dict["conversationUuid"] as? String)
?? (dict["conversation_id"] as? String)
?? (dict["chat_uuid"] as? String)
?? (dict["uuid"] as? String)
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
?? (dict["handle"] as? String)
?? ""
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
let parsedMessageId = voipMessageId(dict["message_id"])
let id: String
let callKitIdSourceName: String
if let derived = derivedCallKitId(
conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId
) {
id = derived
callKitIdSourceName = "conversation_and_message"
} else {
id = UUID().uuidString
callKitIdSourceName = "generated"
}
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
?? true
var extra: [String: Any] = [:]
if !trimmedConversationUuid.isEmpty {
extra["conversationUuid"] = trimmedConversationUuid
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
?? dict["avatar_url"] as? String
?? dict["counterpartAvatarUrl"] as? String
?? dict["counterpart_avatar_url"] as? String
{
extra["counterpartAvatarUrl"] = avatar
}
if let messageId = dict["message_id"] as? String {
extra["messageId"] = messageId
}
if let roomName = dict["room_name"] as? String {
extra["roomName"] = roomName
}
if let localName = dict["localParticipantName"] as? String
?? dict["local_participant_name"] as? String
{
extra["localParticipantName"] = localName
}
if let counterpartName = dict["counterpartName"] as? String {
extra["counterpartName"] = counterpartName
} else if let counterpartName = dict["counterpart_name"] as? String {
extra["counterpartName"] = counterpartName
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
let data = flutter_callkit_incoming.Data(
id: id,
nameCaller: nameCaller,
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.duration = 45000
data.extra = extra as NSDictionary
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
)
recordVoipDiagnostic(
"voip_incoming_received",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"nameCaller": nameCaller,
"isVideo": isVideo,
"markers": pushMarkers,
"payload": dict,
]
)
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
]
)
completion()
return
}
callkitPlugin.showCallkitIncoming(
data,
fromPushKit: true
) {
let activeCalls = callkitPlugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
NSLog(
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
)
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
]
)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let delayedActiveCalls = callkitPlugin.activeCalls()
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_active_calls_delayed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": delayedActiveIds,
"activeCallsCount": delayedActiveCalls.count,
]
)
}
completion()
}
}
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
resumeAudioAfterCallKitEnds = true
prepareCallAudioCategory()
storePendingAcceptedCall(call)
recordVoipDiagnostic(
"callkit_accept_native",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid(fromCall: call) ?? "",
"data": call.data.toJSON(),
]
)
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
action.fulfill()
}
func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false
action.fulfill()
}
func onEnd(_ call: Call, _ action: CXEndCallAction) {
action.fulfill()
}
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidActivate(audioSession)
rtcSession.isAudioEnabled = true
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.audioSessionDidDeactivate(audioSession)
guard resumeAudioAfterCallKitEnds else { return }
DispatchQueue.main.async { [weak self] in
guard let self, self.resumeAudioAfterCallKitEnds else { return }
self.activateInAppCallAudioSession()
}
}
func providerDidReset() {
resumeAudioAfterCallKitEnds = false
}
private func prepareCallAudioCategory() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
let configuration = callAudioConfiguration()
do {
try rtcSession.setConfiguration(configuration)
} catch {
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
}
}
private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
rtcSession.isAudioEnabled = true
} catch {
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
}
}
private func deactivateInAppCallAudioSession() {
resumeAudioAfterCallKitEnds = false
let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
do {
try rtcSession.setActive(false)
} catch {
NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)")
}
}
private func callAudioConfiguration() -> RTCAudioSessionConfiguration {
let configuration = RTCAudioSessionConfiguration.webRTC()
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
configuration.categoryOptions = [
.allowBluetooth,
.defaultToSpeaker,
]
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
return configuration
} }
} }

View File

@@ -31,20 +31,22 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSAppleMusicUsageDescription</key>
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string> <string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Нужен для звука в видеозвонках с консультантом</string> <string>Нужен для звука в видеозвонках с консультантом</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string></string> <string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Предоставление доступа позволит Вам добавлять фото к записям</string> <string>Предоставление доступа позволит Вам добавлять фото к записям</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string>
<string>voip</string>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
@@ -74,6 +76,57 @@
<string>yandexauth</string> <string>yandexauth</string>
<string>yandexauth2</string> <string>yandexauth2</string>
<string>yandexauth4</string> <string>yandexauth4</string>
<!-- SBP bank schemes (top ~50 by popularity, iOS 15+ caps at 50) -->
<string>bank100000000111</string>
<string>bank100000000004</string>
<string>bank100000000005</string>
<string>bank100000000008</string>
<string>bank100000000007</string>
<string>bank100000000015</string>
<string>bank100000000001</string>
<string>bank100000000010</string>
<string>bank100000000013</string>
<string>bank100000000012</string>
<string>bank100000000020</string>
<string>bank100000000025</string>
<string>bank100000000030</string>
<string>bank100000000030_new</string>
<string>bank100000000003</string>
<string>bank100000000043</string>
<string>bank100000000028</string>
<string>bank100000000086</string>
<string>bank100000000011</string>
<string>bank100000000044</string>
<string>bank100000000049</string>
<string>bank100000000500</string>
<string>bank100000000095</string>
<string>bank100000000900</string>
<string>bank100000000056</string>
<string>bank100000000053</string>
<string>bank100000000121</string>
<string>bank100000000082</string>
<string>bank100000000127</string>
<string>bank100000000017</string>
<string>bank100000000087</string>
<string>bank100000000052</string>
<string>bank100000000006</string>
<string>bank100000000098</string>
<string>bank100000000092</string>
<string>bank100000000229</string>
<string>bank100000000027</string>
<string>bank100000000080</string>
<string>bank100000000122</string>
<string>bank100000000124</string>
<string>bank100000000118</string>
<string>bank100000000159</string>
<string>bank100000000146</string>
<string>bank100000000069</string>
<string>bank100000000140</string>
<string>bank100000000047</string>
<string>bank100000000099</string>
<string>bank100000000135</string>
<string>bank100000000139</string>
<string>bank100000000022</string>
</array> </array>
<key>YAClientId</key> <key>YAClientId</key>
<string>5399b03636104c6d920b87c1ad329254</string> <string>5399b03636104c6d920b87c1ad329254</string>
@@ -87,6 +140,14 @@
<string>yx5399b03636104c6d920b87c1ad329254</string> <string>yx5399b03636104c6d920b87c1ad329254</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleURLName</key>
<string>Eatvio</string>
<key>CFBundleURLSchemes</key>
<array>
<string>eatvio</string>
</array>
</dict>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -8,6 +8,8 @@
<array> <array>
<string>applinks:eatvio.ru</string> <string>applinks:eatvio.ru</string>
<string>applinks:www.eatvio.ru</string> <string>applinks:www.eatvio.ru</string>
<string>applinks:eatvio.com</string>
<string>applinks:www.eatvio.com</string>
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string> <string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
</array> </array>
</dict> </dict>

View File

@@ -75,16 +75,24 @@ platform :ios do
xcodeproj: "Runner.xcodeproj", xcodeproj: "Runner.xcodeproj",
build_number: flutter_build_number build_number: flutter_build_number
) )
update_code_signing_settings(
use_automatic_signing: false,
path: "Runner.xcodeproj",
team_id: "VL68C36HYS",
targets: ["Runner"],
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore com.eatvioapp.app 1781098260"
)
build_app( build_app(
workspace: "Runner.xcworkspace", workspace: "Runner.xcworkspace",
scheme: "Runner", scheme: "Runner",
export_method: "app-store", export_method: "app-store",
codesigning_identity: "Apple Distribution", # codesigning_identity: "Apple Distribution",
export_team_id: "VL68C36HYS"
) )
app_store_connect_api_key( app_store_connect_api_key(
key_id: "325JS9JCZH", key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3", issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
@@ -92,6 +100,7 @@ platform :ios do
duration: 1200, duration: 1200,
in_house: false in_house: false
) )
begin begin
upload_to_testflight( upload_to_testflight(

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.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_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.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_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -35,6 +36,7 @@ class AppConfig {
/// Список глобальных BlocProvider'ов /// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения /// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List<BlocProvider> get globalBlocProviders => [ List<BlocProvider> get globalBlocProviders => [
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()), BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()), BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()), BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),

View File

@@ -1,14 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io' as io; import 'dart:io' as io;
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart'; import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
import 'package:eatvio_app/core/locator_service.dart' as di; import 'package:eatvio_app/core/locator_service.dart' as di;
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/firebase_options.dart'; import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
@@ -17,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
/// Класс для инициализации приложения /// Класс для инициализации приложения
class AppInitializer { class AppInitializer {
/// Инициализация всех сервисов приложения /// Инициализация всех сервисов приложения
static Future<void> initialize({required AppBuildType buildType}) async { static Future<void> initialize({required AppBuildType buildType}) async {
await _initializeEnvironment(); await _initializeEnvironment();
@@ -41,24 +37,21 @@ class AppInitializer {
/// Инициализация Firebase /// Инициализация Firebase
static Future<void> _initializeFirebase() async { static Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout( await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () { onTimeout: () {
throw TimeoutException('Firebase initialization timeout'); throw TimeoutException('Firebase initialization timeout');
}, },
); );
if (sl<AppConfig>().buildType == AppBuildType.prod) { VideoCallFcmHandler.registerBackgroundHandler();
try {
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
} catch (e) {
// Разрешение не критично, продолжаем работу
log('Failed to request Firebase messaging permission: $e');
}
}
} }
/// Инициализация системы внедрения зависимостей /// Инициализация системы внедрения зависимостей
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async { static Future<void> _initializeDependencyInjection({
required AppBuildType buildType,
}) async {
await di.init(buildType: buildType); await di.init(buildType: buildType);
} }

View File

@@ -123,7 +123,21 @@ class AppRouter {
return '${InfoByOrderScreen.path}?planId=$consultationId'; return '${InfoByOrderScreen.path}?planId=$consultationId';
} }
static String? _resolveDeepLinkUri(Uri uri) { /// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
static String? _resolveFinanceAlfaReturnDeepLink(List<String> effectiveSegments) {
if (effectiveSegments.length != 4 ||
effectiveSegments[1] != 'alfa' ||
effectiveSegments[2] != 'return') {
return null;
}
final orderId = effectiveSegments[3];
if (orderId.isEmpty) return null;
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
return '${RouteConstants.chatScreen}?orderId=$orderId';
}
/// Разбирает deep link URI в внутренний route target (или null, если host не наш).
static String? resolveDeepLinkTarget(Uri uri) {
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
final segments = uri.pathSegments; final segments = uri.pathSegments;
@@ -189,6 +203,10 @@ class AppRouter {
final target = _resolveConsultationsDeepLink(effectiveSegments); final target = _resolveConsultationsDeepLink(effectiveSegments);
if (target != null) return target; if (target != null) return target;
break; break;
case 'finance':
final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
if (target != null) return target;
break;
} }
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter'); developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
@@ -196,7 +214,7 @@ class AppRouter {
} }
static String? _handleRedirect(BuildContext context, GoRouterState state) { static String? _handleRedirect(BuildContext context, GoRouterState state) {
return _resolveDeepLinkUri(state.uri); return resolveDeepLinkTarget(state.uri);
} }
static void navigateByDeepLinkUrl(String url) { static void navigateByDeepLinkUrl(String url) {
@@ -230,7 +248,7 @@ class AppRouter {
); );
} }
final target = _resolveDeepLinkUri(uri); final target = resolveDeepLinkTarget(uri);
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
if (target != null) { if (target != null) {
appRouter.go(target); appRouter.go(target);

View File

@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
const newMessagesChannelName = 'private-new-chat-messages'; const newMessagesChannelName = 'private-new-chat-messages';
const newNotificationsChannelName = 'private-message-events'; const newNotificationsChannelName = 'private-message-events';
const directChatChannelName = 'private-direct-chat'; const directChatChannelName = 'private-direct-chat';
const presenceDirectChatChannelName = 'presence-direct-chat';
const thresholdForPagination = 150; const thresholdForPagination = 150;

View File

@@ -0,0 +1,16 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
abstract final class DeviceHwIdProvider {
static Future<String?> current() async {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isIOS) {
return (await deviceInfo.iosInfo).identifierForVendor;
}
if (Platform.isAndroid) {
return (await deviceInfo.androidInfo).id;
}
return null;
}
}

View File

@@ -39,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/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/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart'; import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.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_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_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/plans_cubit/plans_cubit.dart' as profile_plans;
@@ -51,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
import 'package:eatvio_app/features/registration_v2/cubit/registration_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/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/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
@@ -71,6 +74,18 @@ class CubitRegistrations {
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl())); sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit()); sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
sl.registerLazySingleton<VideoCallManager>(
() => VideoCallManager(
videoCallRepository: sl(),
profileRepository: sl(),
),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
} }
/// Регистрация Cubit'ов как фабрик /// Регистрация Cubit'ов как фабрик
@@ -107,7 +122,7 @@ class CubitRegistrations {
// Content viewing cubits // Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl())); sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl())); sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl())); sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl())); sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
@@ -135,6 +150,9 @@ class CubitRegistrations {
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl())); sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl())); sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl())); sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
);
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl())); sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl())); sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl())); sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));

View File

@@ -24,6 +24,8 @@ 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_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/my_plans/repositories/my_diets_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/notifications/repository/notifications_repository.dart';
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_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/plan_repository.dart';
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart'; import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
@@ -74,6 +76,8 @@ class RepositoryRegistrations {
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl())); sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl())); sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl())); sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl())); sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl())); sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));

View File

@@ -26,6 +26,7 @@ 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_finance/services/cards_service.dart';
import 'package:eatvio_app/features/my_plans/services/my_plans_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/notifications/service/notifications_service.dart';
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_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/plan_service.dart';
import 'package:eatvio_app/features/plans/services/plans_service.dart'; import 'package:eatvio_app/features/plans/services/plans_service.dart';
@@ -35,7 +36,9 @@ 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/services/registration_service.dart';
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.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:eatvio_app/features/settings/services/settings_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
/// Регистрация всех Service в системе внедрения зависимостей /// Регистрация всех Service в системе внедрения зависимостей
@@ -75,6 +78,12 @@ class ServiceRegistrations {
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl())); sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl())); sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl())); sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
);
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
() => VideoCallSystemPipCoordinator(),
);
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl())); sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl())); sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl())); sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
@@ -82,6 +91,7 @@ class ServiceRegistrations {
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl())); sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl())); sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl())); sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl())); sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl())); sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));

View File

@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
/// Rules for which errors are sent to crash reporting. /// Rules for which errors are sent to crash reporting.
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
if (error == null) return false; if (error == null) return false;
if (error is String) return false; if (error is String) return false;
if (error is SocketException) return false; if (error is SocketException) return false;
if (isHmsClientUnavailable(error)) return false;
if (error is DioException) { if (error is DioException) {
return _shouldReportDio(error); return _shouldReportDio(error);
@@ -44,6 +46,7 @@ class ErrorReportPolicy {
case DioExceptionType.connectionTimeout: case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout: case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout: case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
case DioExceptionType.connectionError: case DioExceptionType.connectionError:
return true; return true;
case DioExceptionType.unknown: case DioExceptionType.unknown:

View File

@@ -3,58 +3,57 @@ import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:flutter/services.dart'; import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException; import 'package:flutter/services.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
class FirebaseApi { class FirebaseApi {
final ErrorReporter _errorReporter; final ErrorReporter _errorReporter;
final NotificationPermissionRequester _notificationPermissionRequester;
final _firebaseMessaging = FirebaseMessaging.instance; final _firebaseMessaging = FirebaseMessaging.instance;
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter; FirebaseApi({
required ErrorReporter errorReporter,
NotificationPermissionRequester? notificationPermissionRequester,
}) : _errorReporter = errorReporter,
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
Future<String?> getToken() async { Future<String?> getToken() async {
String? token; final credentials = await _resolvePushCredentials();
return credentials.token;
}
if (await _isHuaweiDevice()) { Future<void> _requestNotificationPermission() async {
token = await _getHuaweiPushTokenNative().timeout( if (sl<AppConfig>().buildType != AppBuildType.prod) return;
const Duration(seconds: 5), try {
onTimeout: () => null, await _notificationPermissionRequester.requestOnce(
); () => _firebaseMessaging.requestPermission(),
} else {
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
try {
await _firebaseMessaging.requestPermission().timeout(
const Duration(seconds: 5),
);
} catch (e) {
// Разрешение не критично, продолжаем
log('Failed to request Firebase messaging permission: $e');
}
}
token = await _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
); );
} catch (e) {
log('Failed to request Firebase messaging permission: $e');
} }
return token;
} }
Future<void> registerAnonymousPushToken() async { Future<void> registerAnonymousPushToken() async {
try { try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post( await sl<Dio>().post(
'/api/anonymous_token', '/api/anonymous_token',
data: { data: {
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android', 'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(), 'token': token,
}, },
).timeout( ).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
@@ -72,14 +71,17 @@ class FirebaseApi {
Future<void> registerPushToken() async { Future<void> registerPushToken() async {
if (await TokenStorage.haveTokenInStorage()) { if (await TokenStorage.haveTokenInStorage()) {
try { try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post( await sl<Dio>().post(
'/api/profile/notification/token/register', '/api/profile/notification/token/register',
data: { data: {
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'), 'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id, 'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(), 'token': token,
}, },
).timeout( ).timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
@@ -87,6 +89,9 @@ class FirebaseApi {
throw TimeoutException('registerPushToken timeout'); throw TimeoutException('registerPushToken timeout');
}, },
); );
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
} catch (e, st) { } catch (e, st) {
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi'); await _errorReporter.report(e, st, origin: 'FirebaseApi');
@@ -95,30 +100,156 @@ class FirebaseApi {
} }
} }
Future<void> unregisterPushTokens() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await unregisterPushToken();
} catch (_) {}
if (Platform.isIOS) {
try {
await _unregisterIosVoipPushToken();
} catch (_) {}
}
}
Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
final credentials = await _resolvePushCredentials();
final token = credentials.token ?? '';
if (token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/unregister',
data: {
'platform': credentials.platform,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _registerIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
await sl<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _unregisterIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? '';
await sl<Dio>().post(
'/api/profile/notification/token/voip/unregister',
data: {
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<({String? token, String platform})> _resolvePushCredentials() async {
if (Platform.isIOS) {
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
);
}
final huaweiFamily = await _isHuaweiDevice();
if (huaweiFamily && await _isHmsAvailable()) {
final token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
if (token != null && token.isNotEmpty) {
return (
token: token,
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
);
}
}
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
);
}
Future<String?> _getFcmToken() {
return _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
Future<bool> _isHuaweiDevice() async { Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false; if (!Platform.isAndroid) return false;
try { try {
final info = await DeviceInfoPlugin().androidInfo; final info = await DeviceInfoPlugin().androidInfo;
final brand = info.brand.toLowerCase(); return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
} catch (_) { } catch (_) {
return false; return false;
} }
} }
Future<bool> _isHmsAvailable() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
return available ?? false;
} catch (e, st) {
log('HMS availability check failed: $e', stackTrace: st);
return false;
}
}
Future<String?> _getHuaweiPushTokenNative() async { Future<String?> _getHuaweiPushTokenNative() async {
try { try {
const channel = MethodChannel('com.eatvioapp/hms_push'); const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken'); final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token; return token;
} on PlatformException catch (e, st) { } on PlatformException catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi'); if (!isHmsClientUnavailable(e)) {
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
return null; return null;
} catch (e, st) { } catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi'); await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null; return null;

View File

@@ -0,0 +1,22 @@
class NotificationPermissionRequester {
Future<void>? _inFlight;
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
Future<void> requestOnce(Future<void> Function() requestPermission) async {
final inFlight = _inFlight;
if (inFlight != null) {
await inFlight;
return;
}
final future = requestPermission();
_inFlight = future;
try {
await future;
} finally {
if (identical(_inFlight, future)) {
_inFlight = null;
}
}
}
}

View File

@@ -1,6 +1,13 @@
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'dart:async';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -50,7 +57,37 @@ class PushNavigationHandler {
} }
static void _handleMessageInternal(RemoteMessage message, {required String source}) { static void _handleMessageInternal(RemoteMessage message, {required String source}) {
final data = message.data; final data = Map<String, dynamic>.from(message.data);
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
developer.log(
'PushNavigationHandler: dismiss incoming call UI, source=$source',
name: 'PushNavigationHandler',
);
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
return;
}
final videoCallPayload = VideoCallPushPayload.tryParse(data);
if (videoCallPayload != null) {
developer.log(
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
name: 'PushNavigationHandler',
);
unawaited(
VideoCallNotificationNavigator.openIncomingCall(
conversationUuid: videoCallPayload.conversationUuid,
counterpartName: videoCallPayload.counterpartName,
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
localParticipantName: videoCallPayload.localParticipantName,
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
entryMode: VideoCallEntryMode.incomingAccepted,
),
);
return;
}
final actionUrl = data['action_url'] as String?; final actionUrl = data['action_url'] as String?;
if (actionUrl == null || actionUrl.isEmpty) { if (actionUrl == null || actionUrl.isEmpty) {
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler'); developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');

View File

@@ -0,0 +1,36 @@
import 'package:flutter/services.dart';
bool isHuaweiFamilyBrand({
required String brand,
required String manufacturer,
}) {
final normalizedBrand = brand.toLowerCase();
final normalizedManufacturer = manufacturer.toLowerCase();
return normalizedBrand.contains('huawei') ||
normalizedBrand.contains('honor') ||
normalizedManufacturer.contains('huawei') ||
normalizedManufacturer.contains('honor');
}
/// HMS Core is missing or rejects this app.
///
/// Honor devices with Google services return `907135003: client api invalid`.
/// That is a device capability, not an app crash: the caller should use FCM.
bool isHmsClientUnavailable(Object error) {
if (error is! PlatformException) return false;
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
return message.contains('907135003') ||
message.contains('client api invalid');
}
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
String pushPlatform({
required bool isIos,
required bool huaweiFamily,
required bool hmsAvailable,
required bool hasHmsToken,
}) {
if (isIos) return 'ios';
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
return 'android';
}

View File

@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
@@ -23,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart'; import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart'; import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart'; import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart'; import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
@@ -182,7 +184,14 @@ class OtherRoutes {
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64); return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
}, },
), ),
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()), GoRoute(
path: RouteConstants.createPlanScreen,
name: 'createPlanScreen',
builder: (context, state) {
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
return CreatePlanScreen(plan: plan);
},
),
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()), GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()), GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()), GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
@@ -211,13 +220,23 @@ class OtherRoutes {
name: 'chatScreen', name: 'chatScreen',
builder: (context, state) { builder: (context, state) {
final uuidFromQuery = state.uri.queryParameters['uuid']; final uuidFromQuery = state.uri.queryParameters['uuid'];
if (uuidFromQuery != null) { final orderIdFromQuery = state.uri.queryParameters['orderId'];
return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false); if (uuidFromQuery != null || orderIdFromQuery != null) {
return ChatScreen(
uuid: uuidFromQuery,
orderId: orderIdFromQuery,
sendInitialMessage: false,
);
} }
final data = state.extra as Map<String, dynamic>; final data = state.extra as Map<String, dynamic>;
final uuid = data['uuid'] as String; final uuid = data['uuid'] as String?;
final orderId = data['orderId'] as String?;
final sendInitialMessage = data['sendInitialMessage'] as bool?; final sendInitialMessage = data['sendInitialMessage'] as bool?;
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage); return ChatScreen(
uuid: uuid,
orderId: orderId,
sendInitialMessage: sendInitialMessage,
);
}, },
), ),
GoRoute( GoRoute(
@@ -234,7 +253,13 @@ class OtherRoutes {
return VideoCallScreen( return VideoCallScreen(
conversationUuid: extra['conversationUuid'] as String, conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?, counterpartName: extra['counterpartName'] as String?,
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
counterpartUserId: extra['counterpartUserId'] as int?,
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
localParticipantName: extra['localParticipantName'] as String?, localParticipantName: extra['localParticipantName'] as String?,
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
onBackToChat: extra['onBackToChat'] as void Function()?, onBackToChat: extra['onBackToChat'] as void Function()?,
onHangUp: extra['onHangUp'] as void Function()?, onHangUp: extra['onHangUp'] as void Function()?,
); );
@@ -248,7 +273,15 @@ class OtherRoutes {
), ),
// Consultant routes // Consultant routes
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), GoRoute(
path: RouteConstants.becomeConsultantScreen,
name: 'becomeConsultantScreen',
builder: (context, state) {
final data = state.extra as Map<String, dynamic>?;
final slug = data?['slug'] as String?;
return BecomeConsultantScreen(slug: slug);
},
),
GoRoute( GoRoute(
path: RouteConstants.stepperBecomeConsultantScreen, path: RouteConstants.stepperBecomeConsultantScreen,
name: 'stepperBecomeConsultantScreen', name: 'stepperBecomeConsultantScreen',
@@ -256,7 +289,12 @@ class OtherRoutes {
final data = state.extra as Map<String, dynamic>; final data = state.extra as Map<String, dynamic>;
final slug = data['slug'] as String?; final slug = data['slug'] as String?;
final isCanceled = data['isCanceled'] as bool?; final isCanceled = data['isCanceled'] as bool?;
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled); final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
return StepperBecomeConsultantScreen(
slug: slug,
isCanceled: isCanceled,
isNewRegistration: isNewRegistration,
);
}, },
), ),
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()), GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
@@ -329,3 +367,11 @@ class OtherRoutes {
]; ];
} }
} }
VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) {
if (raw is VideoCallEntryMode) return raw;
if (raw == VideoCallEntryMode.incomingAccepted.name) {
return VideoCallEntryMode.incomingAccepted;
}
return VideoCallEntryMode.outgoing;
}

View File

@@ -80,6 +80,48 @@ class CustomTextStyles {
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);
static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
fontWeight: FontWeight.w600,
height: 32 / 24,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
fontWeight: FontWeight.w400,
fontSize: 16,
height: 24 / 16,
letterSpacing: 0.5,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 24 / 13,
letterSpacing: 0.5,
color: appTheme.black900,
);
static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w500,
height: 24 / 14,
color: appTheme.gray600,
);
static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w400,
height: 1.35,
color: appTheme.red,
);
static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: appTheme.whiteA700,
);
} }
extension on TextStyle { extension on TextStyle {

View File

@@ -0,0 +1,61 @@
/// flutter_svg / vector_graphics ignore `<style>` blocks.
/// Paths then get the SVG default fill (black), so badges look like a black blob.
/// This inlines class rules from `<style>` as attributes before parsing.
String inlineSvgCss(String svg) {
final styleBlockPattern = RegExp(
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
caseSensitive: false,
);
final classProps = <String, Map<String, String>>{};
for (final match in styleBlockPattern.allMatches(svg)) {
final css = match.group(1) ?? '';
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
for (final rule in rulePattern.allMatches(css)) {
final className = rule.group(1)!;
final body = rule.group(2)!;
final props = classProps.putIfAbsent(className, () => <String, String>{});
for (final declaration in body.split(';')) {
final colon = declaration.indexOf(':');
if (colon <= 0) {
continue;
}
final key = declaration.substring(0, colon).trim();
final value = declaration.substring(colon + 1).trim();
if (key.isNotEmpty && value.isNotEmpty) {
props[key] = value;
}
}
}
}
var result = svg.replaceAll(styleBlockPattern, '');
if (classProps.isEmpty) {
return result;
}
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
Match match,
) {
final classes = match
.group(2)!
.split(RegExp(r'\s+'))
.where((name) => name.isNotEmpty);
final attrs = <String, String>{};
for (final className in classes) {
final props = classProps[className];
if (props != null) {
attrs.addAll(props);
}
}
if (attrs.isEmpty) {
return match.group(0)!;
}
final attrString = attrs.entries
.map((entry) => '${entry.key}="${entry.value}"')
.join(' ');
return ' $attrString';
});
return result;
}

View File

@@ -0,0 +1,11 @@
/// Joins an API media path with [baseUrl].
///
/// Uploaded photos come back as relative paths (`/storage/...`).
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
String joinBaseUrl(String baseUrl, String path) {
final normalized = path.toLowerCase();
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
return path;
}
return '$baseUrl$path';
}

View File

@@ -26,20 +26,47 @@ class WsManager {
PrivateChannel? _privateNotificationChannel; PrivateChannel? _privateNotificationChannel;
PrivateChannel? _privateMessagesChannel; PrivateChannel? _privateMessagesChannel;
PrivateChannel? _privateDirectChatChannel; PrivateChannel? _privateDirectChatChannel;
PresenceChannel? _presenceDirectChatChannel;
StreamSubscription? _presenceMemberAddedSubscription;
StreamSubscription? _presenceMemberRemovedSubscription;
StreamSubscription? _presenceSubscriptionSucceededSubscription;
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
bool _presenceChannelEventsBound = false;
StreamSubscription? _lifecycleSubscription; StreamSubscription? _lifecycleSubscription;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
StreamSubscription? _connectionEstablishedSubscription; StreamSubscription? _connectionEstablishedSubscription;
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
///
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
StreamController<void> _reconnectController = StreamController<void>.broadcast();
Stream<ChannelReadEvent>? _directChatStream;
Stream<ChannelReadEvent>? _clientTypingStream;
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated'); Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing'); Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
///
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
/// сообщения (S5).
Stream<void> get reconnected => _reconnectController.stream;
Future<void> initialize() async { Future<void> initialize() async {
if (_reconnectController.isClosed) {
_reconnectController = StreamController<void>.broadcast();
}
if (client == null) { if (client == null) {
try { try {
if (!await TokenStorage.haveTokenInStorage()) return; if (!await TokenStorage.haveTokenInStorage()) return;
@@ -98,8 +125,7 @@ class WsManager {
_lifecycleSubscription = client?.lifecycleStream.listen((data) { _lifecycleSubscription = client?.lifecycleStream.listen((data) {
if (data == PusherChannelsClientLifeCycleState.establishedConnection) { if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
_unsubscribe(); handleConnectionEstablished();
_subscribe();
} }
}); });
_eventSubscription = client?.eventStream.listen((event) { _eventSubscription = client?.eventStream.listen((event) {
@@ -163,6 +189,7 @@ class WsManager {
} }
Future<void> initPrivateDirectChatChannel(String chatId) async { Future<void> initPrivateDirectChatChannel(String chatId) async {
_privateDirectChatChannel?.unsubscribe();
final token = await TokenStorage.getTokenFromStorage(); final token = await TokenStorage.getTokenFromStorage();
_privateDirectChatChannel = client?.privateChannel( _privateDirectChatChannel = client?.privateChannel(
'$directChatChannelName.$chatId', '$directChatChannelName.$chatId',
@@ -173,6 +200,109 @@ class WsManager {
}, },
), ),
); );
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
}
Future<void> initPresenceDirectChatChannel(String chatId) async {
final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
);
}
void addPresenceDirectChatListener({
required int counterpartUserId,
required void Function(bool isOnline) onCounterpartOnlineChanged,
}) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceDirectChatListeners.add(
_PresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
),
);
_bindPresenceChannelEventsIfNeeded();
channel.subscribe();
_emitCounterpartPresence(counterpartUserId);
}
void removePresenceDirectChatListener(
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
}
}
void _bindPresenceChannelEventsIfNeeded() {
if (_presenceChannelEventsBound) return;
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription =
channel.whenSubscriptionSucceeded().listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
});
}
void _emitCounterpartPresence(int counterpartUserId) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);
}
}
}
int? _parsePresenceMemberUserId(String? data) {
if (data == null || data.isEmpty) return null;
try {
final decoded = jsonDecode(data);
if (decoded is! Map) return null;
final rawId = decoded['user_id'] ?? decoded['id'];
if (rawId == null) return null;
return int.tryParse(rawId.toString());
} catch (_) {
return null;
}
} }
void _subscribe() { void _subscribe() {
@@ -185,12 +315,40 @@ class WsManager {
_privateNotificationChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe();
} }
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
///
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
/// Pusher-клиента.
void handleConnectionEstablished() {
_unsubscribe();
_subscribe();
_privateDirectChatChannel?.subscribe();
_presenceDirectChatChannel?.subscribe();
_reconnectController.add(null);
}
void subscribeDirectChatChannel() { void subscribeDirectChatChannel() {
_privateDirectChatChannel?.subscribe(); _privateDirectChatChannel?.subscribe();
} }
void unsubscribeDirectChatChannel() { void unsubscribeDirectChatChannel() {
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
_directChatStream = null;
_clientTypingStream = null;
}
void unsubscribePresenceDirectChatChannel() {
_presenceSubscriptionSucceededSubscription?.cancel();
_presenceMemberAddedSubscription?.cancel();
_presenceMemberRemovedSubscription?.cancel();
_presenceSubscriptionSucceededSubscription = null;
_presenceMemberAddedSubscription = null;
_presenceMemberRemovedSubscription = null;
_presenceDirectChatListeners.clear();
_presenceChannelEventsBound = false;
_presenceDirectChatChannel?.unsubscribe();
_presenceDirectChatChannel = null;
} }
Future<void> clear() async { Future<void> clear() async {
@@ -204,10 +362,23 @@ class WsManager {
client = null; client = null;
userId = null; userId = null;
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
unsubscribePresenceDirectChatChannel();
_privateMessagesChannel?.unsubscribe(); _privateMessagesChannel?.unsubscribe();
_privateNotificationChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe();
_privateDirectChatChannel = null; _privateDirectChatChannel = null;
_privateMessagesChannel = null; _privateMessagesChannel = null;
_privateNotificationChannel = null; _privateNotificationChannel = null;
_directChatStream = null;
_clientTypingStream = null;
} }
} }
class _PresenceDirectChatListener {
const _PresenceDirectChatListener({
required this.counterpartUserId,
required this.onCounterpartOnlineChanged,
});
final int counterpartUserId;
final void Function(bool isOnline) onCounterpartOnlineChanged;
}

View File

@@ -6,22 +6,34 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.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_cubit.dart';
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart'; import 'package:eatvio_app/features/complaining/presentation/complaining_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/create_recipe/presentation/pages/recipe_create_screen.dart';
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.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/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart'; import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart'; import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart'; import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
class CustomPopup extends StatelessWidget { class CustomPopup extends StatelessWidget {
final bool isAuthor; final bool isAuthor;
final bool isAuthorAdmin;
final ComplainedContent? complainedContentType; final ComplainedContent? complainedContentType;
final String path; final String path;
final String? slug; final String? slug;
@@ -29,11 +41,14 @@ class CustomPopup extends StatelessWidget {
final FeedBlogPostModel? feedBlogPostModel; final FeedBlogPostModel? feedBlogPostModel;
final Recipe? recipe; final Recipe? recipe;
final Diet? diet; final Diet? diet;
final PlanModel? plan;
final String userSlug; final String userSlug;
final VoidCallback? onPostsChanged;
const CustomPopup({ const CustomPopup({
super.key, super.key,
required this.isAuthor, required this.isAuthor,
this.isAuthorAdmin = false,
this.complainedContentType, this.complainedContentType,
required this.path, required this.path,
this.id, this.id,
@@ -41,9 +56,14 @@ class CustomPopup extends StatelessWidget {
this.feedBlogPostModel, this.feedBlogPostModel,
this.recipe, this.recipe,
this.diet, this.diet,
required this.userSlug this.plan,
required this.userSlug,
this.onPostsChanged,
}); });
bool get _isAdminAuthor =>
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
@@ -76,7 +96,103 @@ class CustomPopup extends StatelessWidget {
); );
} }
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) { Future<void> _openEditor(BuildContext context) async {
final router = GoRouter.of(context);
switch (complainedContentType) {
case ComplainedContent.posts:
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
onPostsChanged?.call();
case ComplainedContent.recipe:
final currentRecipe = recipe;
if (currentRecipe == null) {
return;
}
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
if (result != null && context.mounted) {
_reloadEditedContent(context);
}
case ComplainedContent.diet:
final currentDiet = diet;
if (currentDiet == null) {
return;
}
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
if (result == true && context.mounted) {
_reloadEditedContent(context);
}
case ComplainedContent.plan:
final currentPlan = plan;
if (currentPlan == null) {
return;
}
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
if (result == true && context.mounted) {
_reloadEditedContent(context);
}
default:
return;
}
}
void _reloadEditedContent(BuildContext context) {
switch (complainedContentType) {
case ComplainedContent.recipe:
final currentRecipe = recipe;
if (currentRecipe == null) {
return;
}
try {
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
} catch (_) {}
try {
context.read<RecipesCubit>().loadRecipesList();
} catch (_) {}
case ComplainedContent.diet:
final currentDiet = diet;
if (currentDiet == null) {
return;
}
try {
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
} catch (_) {}
try {
context.read<DietsCubit>().loadDiets();
} catch (_) {}
case ComplainedContent.plan:
final currentPlan = plan;
if (currentPlan == null) {
return;
}
try {
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
} catch (_) {}
try {
context.read<PlansCubit>().loadPlans();
} catch (_) {}
try {
context.read<MyPlansCubit>().loadPlans();
} catch (_) {}
default:
return;
}
}
bool _showsEditAction() {
switch (complainedContentType) {
case ComplainedContent.posts:
return feedBlogPostModel != null || id != null;
case ComplainedContent.recipe:
return recipe != null;
case ComplainedContent.diet:
return diet != null;
case ComplainedContent.plan:
return plan != null;
default:
return false;
}
}
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
final entries = <PopupMenuEntry>[]; final entries = <PopupMenuEntry>[];
void addItem(String icon, String text, Function() onTap) { void addItem(String icon, String text, Function() onTap) {
@@ -100,11 +216,9 @@ class CustomPopup extends StatelessWidget {
), ),
); );
}); });
if (complainedContentType == ComplainedContent.posts) { if (_showsEditAction()) {
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async { addItem('assets/icons/edit_paint.svg', s.edit, () {
if (complainedContentType == ComplainedContent.posts) { _openEditor(context);
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
}
}); });
} }
addItem('assets/icons/red_trash.svg', 'Удалить', () async { addItem('assets/icons/red_trash.svg', 'Удалить', () async {
@@ -114,7 +228,14 @@ class CustomPopup extends StatelessWidget {
showErrorDialog(context, title: S.current.could_not_delete_blog_post); showErrorDialog(context, title: S.current.could_not_delete_blog_post);
return; return;
} }
showSuccessToast('Пост удален, обновите ленту'); onPostsChanged?.call();
ProfileCubit.myProfileCubit?.reloadBlogPosts();
if (context.mounted) {
try {
context.read<FeedCubit>().loadFeedBlogPostList();
} catch (_) {}
}
showSuccessToast('Пост удален');
} else if (complainedContentType == ComplainedContent.recipe) { } else if (complainedContentType == ComplainedContent.recipe) {
final result = await sl<RecipesCubit>().deleteRecipe(slug!); final result = await sl<RecipesCubit>().deleteRecipe(slug!);
if (!result && context.mounted) { if (!result && context.mounted) {
@@ -154,19 +275,21 @@ class CustomPopup extends StatelessWidget {
), ),
); );
}); });
addItem('assets/icons/complain.svg', 'Пожаловаться', () { if (!_isAdminAuthor) {
if (complainedContentType != null) { addItem('assets/icons/complain.svg', 'Пожаловаться', () {
showModalBottomSheet( if (complainedContentType != null) {
context: context, showModalBottomSheet(
isScrollControlled: true, context: context,
builder: (context) => ComplainingBottomSheet( isScrollControlled: true,
complainedContentType: complainedContentType!, builder: (context) => ComplainingBottomSheet(
id: id, complainedContentType: complainedContentType!,
slug: slug, id: id,
), slug: slug,
); ),
} );
}); }
});
}
addItem( addItem(
'assets/icons/complain.svg', 'assets/icons/complain.svg',
'Заблокировать автора', 'Заблокировать автора',

View File

@@ -0,0 +1,57 @@
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:http/http.dart' as http;
final Map<String, String> _inlinedSvgCache = <String, String>{};
class NetworkSvgPicture extends StatefulWidget {
const NetworkSvgPicture({
super.key,
required this.url,
this.width,
this.height,
});
final String url;
final double? width;
final double? height;
@override
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
}
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
late final Future<String> _svgFuture = _load(widget.url);
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _svgFuture,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
if (!snapshot.hasData) {
return SizedBox(width: widget.width, height: widget.height);
}
return SvgPicture.string(
snapshot.data!,
width: widget.width,
height: widget.height,
);
},
);
}
static Future<String> _load(String url) async {
final cached = _inlinedSvgCache[url];
if (cached != null) {
return cached;
}
final response = await http.get(Uri.parse(url));
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Failed to load SVG from $url');
}
final processed = inlineSvgCss(response.body);
_inlinedSvgCache[url] = processed;
return processed;
}
}

View File

@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
if (isDrafted == true) { if (isDrafted == true) {
text = 'cохранена в черновиках'; text = 'cохранена в черновиках';
} else if (isUpdated == true) { } else if (isUpdated == true) {
text = 'успешно обновлена'; text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
} else if (isPlan == true) { } else if (isPlan == true) {
text = 'опубликован'; text = 'опубликован';
} else { } else {

View File

@@ -1,7 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart'; import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
@@ -9,7 +10,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart'; import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -23,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
final int id; final int id;
final int? highlightedCommentId; final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId}); const BlogPostScreen({
super.key,
required this.id,
this.highlightedCommentId,
});
@override @override
State<BlogPostScreen> createState() => _BlogPostScreenState(); State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -33,6 +41,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
final CommentsCubit commentsCubit = sl<CommentsCubit>(); final CommentsCubit commentsCubit = sl<CommentsCubit>();
final textEditingController = TextEditingController(); final textEditingController = TextEditingController();
int? replyId; int? replyId;
int currentGalleryIndex = 0;
@override @override
void initState() { void initState() {
@@ -40,12 +49,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
super.initState(); super.initState();
} }
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return BlocProvider(
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id), create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
child: BlocProvider<CommentsCubit>( child: BlocProvider<CommentsCubit>(
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), create: (BuildContext context) => commentsCubit
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
child: BlocBuilder<BlogPostCubit, BlogPostState>( child: BlocBuilder<BlogPostCubit, BlogPostState>(
builder: (context, blogPostState) { builder: (context, blogPostState) {
return BlocConsumer<CommentsCubit, CommentsState>( return BlocConsumer<CommentsCubit, CommentsState>(
@@ -73,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
body: const Loader(), body: const Loader(),
); );
} }
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false; final bool isLiked =
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0; blogPostState.blogPostModel?.wasLikedByMe ?? false;
final int totalLikes =
blogPostState.blogPostModel?.totalLikes ?? 0;
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
bottomSheet: Container( bottomSheet: Container(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), border: Border(
top: BorderSide(
color: Colors.black.withValues(alpha: 0.1),
),
),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -99,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
}, },
child: Container( child: Container(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle), decoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
height: 32, height: 32,
width: 32, width: 32,
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white), child: const Icon(
Icons.close_rounded,
size: 18,
color: Colors.white,
),
), ),
), ),
if (replyId != null) const SizedBox(width: 8), if (replyId != null) const SizedBox(width: 8),
@@ -110,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52), constraints: const BoxConstraints(minHeight: 52),
child: TextFormField( child: TextFormField(
textCapitalization: TextCapitalization.sentences, textCapitalization:
TextCapitalization.sentences,
onChanged: (_) { onChanged: (_) {
setState(() {}); setState(() {});
}, },
@@ -118,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
maxLines: 5, maxLines: 5,
minLines: 1, minLines: 1,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2), style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.black,
height: 1.2,
),
decoration: InputDecoration( decoration: InputDecoration(
isDense: false, isDense: false,
// убираем true, чтобы padding учитывался корректно // убираем true, чтобы padding учитывался корректно
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
// вертикальный паддинг увеличен // вертикальный паддинг увеличен
hintText: S.of(context).comment_hint, hintText: S.of(context).comment_hint,
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)), hintStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.2,
color: Color(0xFFAEAEB2),
),
filled: true, filled: true,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1), borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
), ),
), ),
), ),
@@ -155,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
); );
if (!context.mounted) return; if (!context.mounted) return;
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id); if (isSent) {
context
.read<FeedCubit>()
.incrementCommentsState(widget.id);
}
context.read<CommentsCubit>().setReplyId(null); context.read<CommentsCubit>().setReplyId(null);
setState(() { setState(() {
@@ -166,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
} }
}, },
child: Container( child: Container(
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle), decoration: BoxDecoration(
color: textEditingController.text.length > 2
? appTheme.blueA400
: Colors.grey,
shape: BoxShape.circle,
),
height: 32, height: 32,
width: 32, width: 32,
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white), child: const Icon(
Icons.send_rounded,
size: 18,
color: Colors.white,
),
), ),
), ),
], ],
@@ -189,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
), ),
actions: [ actions: [
CustomPopup( CustomPopup(
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false, isAuthor:
blogPostState.blogPostModel?.author.authorIsMe ??
false,
isAuthorAdmin:
blogPostState.blogPostModel?.author.isAdmin ??
false,
complainedContentType: ComplainedContent.posts, complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(widget.id)}', path: 'blog_post/${(widget.id)}',
id: widget.id, id: widget.id,
feedBlogPostModel: blogPostState.blogPostModel, feedBlogPostModel: blogPostState.blogPostModel,
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '', userSlug:
blogPostState.blogPostModel?.author.authorSlug ??
'',
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area padding: EdgeInsets.only(
bottom: 160 + MediaQuery.of(context).padding.bottom,
), // поле комментария (52-200) + padding (12+12+56) + safe area
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug), onTap: () => GoRouter.of(context).pushNamed(
'profileScreen',
extra:
blogPostState.blogPostModel?.author.authorSlug,
),
child: Ink( child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row( child: Row(
@@ -214,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 16, radius: 16,
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), foregroundImage: NetworkImage(
backgroundImage: const AssetImage('assets/images/no_avatar.png'), joinBaseUrl(
sl<AppConfig>().baseUrl,
blogPostState
.blogPostModel
?.author
.authorProfileIcon ??
'',
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
blogPostState.blogPostModel?.author.authorName ?? '', blogPostState
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1), .blogPostModel
?.author
.authorName ??
'',
style: const TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'), (blogPostState.blogPostModel?.createdAt ??
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1), DateTime.now())
.format('dd MMMM', 'ru'),
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1,
),
), ),
], ],
), ),
@@ -236,31 +344,99 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
), ),
), ),
), ),
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) if (blogPostState
.blogPostModel
?.description
.isNotEmpty ??
false)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), horizontal: 16,
vertical: 8,
),
child: MarkdownHtmlWidget(
markdownText:
blogPostState.blogPostModel!.description,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) Builder(
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'), builder: (context) {
final post = blogPostState.blogPostModel;
if (post == null) {
return const SizedBox.shrink();
}
final imageUrl = PostFeedMapper.resolveMediaUrl(
post.image,
);
final embedUrl = post.videoEmbedUrl?.trim();
return BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl:
embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(
post.videoUrlThumb,
)
: null,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'blog-post-screen-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'blog-post-screen-${post.id}-cover'
: null,
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
});
},
);
},
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row( child: Row(
children: [ children: [
_iconButton( _iconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded, icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: totalLikes.toString(), text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, iconColor: isLiked
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null, ? Colors.red.withValues(alpha: 0.8)
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null, : null,
backgroundColor: isLiked
? Colors.red.withValues(alpha: 0.1)
: null,
textColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
onTap: () async { onTap: () async {
await context.read<BlogPostCubit>().toggleLike(); await context
.read<BlogPostCubit>()
.toggleLike();
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
final updated = context.read<BlogPostCubit>().state.blogPostModel; final updated = context
.read<BlogPostCubit>()
.state
.blogPostModel;
if (updated != null) { if (updated != null) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe); context
.read<FeedCubit>()
.updateFeedBlogPostList(
updated,
updated.wasLikedByMe,
);
} }
}, },
), ),
@@ -268,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
_iconButton( _iconButton(
icon: Icons.reply_rounded, icon: Icons.reply_rounded,
onTap: () { onTap: () {
final box = context.findRenderObject() as RenderBox?; final box =
context.findRenderObject() as RenderBox?;
SharePlus.instance.share( SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'), uri: Uri.parse(
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, '${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) &
box.size,
), ),
); );
}, },
), ),
const Spacer(), const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500), Icon(
Icons.visibility_rounded,
size: 16,
color: appTheme.gray500,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
blogPostState.blogPostModel?.totalViews.toString() ?? '', blogPostState.blogPostModel?.totalViews
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), .toString() ??
'',
style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
), ),
], ],
), ),
@@ -290,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
Container( Container(
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
height: 0.4, height: 0.4,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -298,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ', '${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400), style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -323,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
); );
} }
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) { InkWell _iconButton({
required IconData icon,
String? text,
Function()? onTap,
Color? iconColor,
Color? backgroundColor,
Color? textColor,
}) {
return InkWell( return InkWell(
onTap: onTap ?? () {}, onTap: onTap ?? () {},
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Ink( child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: backgroundColor ?? appTheme.gray100,
borderRadius: BorderRadius.circular(16),
),
height: 32, height: 32,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -339,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (text != null) if (text != null)
Text( Text(
text, text,
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), style: TextStyle(
color: textColor ?? appTheme.gray600,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),

View File

@@ -3,38 +3,195 @@ import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart'; import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart'; import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> { class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
final BlogPostRepository blogPostRepository; final BlogPostRepository blogPostRepository;
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState()); BlogPostCreateCubit({required this.blogPostRepository})
: super(const BlogPostCreateState());
void clearTitleError() {
if (state.titleError != null || state.formError != null) {
emit(state.copyWith(titleError: null, formError: null));
}
}
void clearDescriptionError() {
if (state.descriptionError != null || state.formError != null) {
emit(state.copyWith(descriptionError: null, formError: null));
}
}
void clearVideoUrlError() {
if (state.videoUrlError != null || state.formError != null) {
emit(state.copyWith(videoUrlError: null, formError: null));
}
}
void clearImagesError() {
if (state.imagesError != null || state.formError != null) {
emit(state.copyWith(imagesError: null, formError: null));
}
}
void setImagesError(String? error) {
if (state.imagesError == error &&
state.formError == null &&
state.errorMessage == null) {
return;
}
emit(
state.copyWith(imagesError: error, formError: null, errorMessage: null),
);
}
void resetFormErrors() {
if (!state.hasValidationErrors &&
!state.isSuccess &&
state.errorMessage == null &&
!state.isLoading) {
return;
}
emit(const BlogPostCreateState());
}
/// Validates form fields and emits error messages on [BlogPostCreateState].
/// Returns `true` when the form is valid.
bool validateForm({
required String title,
required String description,
required String videoUrl,
required int imageCount,
Iterable<String> imagePaths = const [],
}) {
final titleError = BlogPostFormValidator.title(title);
final descriptionError = BlogPostFormValidator.description(description);
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
final imagesError =
BlogPostFormValidator.galleryCount(imageCount) ??
BlogPostFormValidator.imagePaths(imagePaths);
final formError = BlogPostFormValidator.contentRequiredMessage(
title: title,
description: description,
videoUrl: videoUrl,
imageCount: imageCount,
);
final hasFieldErrors =
titleError != null ||
descriptionError != null ||
videoUrlError != null ||
imagesError != null;
final resolvedFormError = hasFieldErrors ? null : formError;
emit(
state.copyWith(
titleError: titleError,
descriptionError: descriptionError,
videoUrlError: videoUrlError,
imagesError: imagesError,
// Form-level message only when individual fields are fine but content is empty.
formError: resolvedFormError,
isSuccess: false,
errorMessage: null,
),
);
return titleError == null &&
descriptionError == null &&
videoUrlError == null &&
imagesError == null &&
resolvedFormError == null;
}
Future<bool> createBlogPost({
String? title,
String? videoUrl,
String? description,
List<XFile> images = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: images.length,
imagePaths: images.map((image) => image.path),
);
if (!isValid) {
return false;
}
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
try { try {
emit(const BlogPostCreateState.loading()); emit(
await blogPostRepository.createBlogPost(title, videoUrl, description, image); state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
emit(const BlogPostCreateState.loaded()); );
await blogPostRepository.createBlogPost(
title: trimmedTitle.isEmpty ? null : trimmedTitle,
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
description: trimmedDescription.isEmpty ? null : trimmedDescription,
images: images,
);
emit(const BlogPostCreateState(isSuccess: true));
return true; return true;
} catch (error, stackTrace) { } catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace); log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this); await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString())); emit(BlogPostCreateState(errorMessage: error.toString()));
return false; return false;
} }
} }
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async { Future<bool> updateBlogPost({
required int id,
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
List<int> mediaIdsToDelete = const [],
}) async {
final trimmedTitle = title?.trim() ?? '';
final trimmedDescription = description?.trim() ?? '';
final trimmedVideoUrl = videoUrl?.trim() ?? '';
final imageCount = existingMediaIds.length + newImages.length;
final isValid = validateForm(
title: trimmedTitle,
description: trimmedDescription,
videoUrl: trimmedVideoUrl,
imageCount: imageCount,
imagePaths: newImages.map((image) => image.path),
);
if (!isValid) {
return false;
}
try { try {
emit(const BlogPostCreateState.loading()); emit(
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl); state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
emit(const BlogPostCreateState.loaded()); );
await blogPostRepository.updateBlogPost(
id: id,
title: trimmedTitle,
videoUrl: trimmedVideoUrl,
description: trimmedDescription,
existingMediaIds: existingMediaIds,
newImages: newImages,
mediaIdsToDelete: mediaIdsToDelete,
);
emit(const BlogPostCreateState(isSuccess: true));
return true; return true;
} catch (error, stackTrace) { } catch (error, stackTrace) {
log('BlogPostCreateCubit: $error', stackTrace: stackTrace); log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
await reportFrom(error, stackTrace, this); await reportFrom(error, stackTrace, this);
emit(BlogPostCreateState.error(errorMessage: error.toString())); emit(BlogPostCreateState(errorMessage: error.toString()));
return false; return false;
} }
} }

View File

@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
@freezed @freezed
abstract class BlogPostCreateState with _$BlogPostCreateState { abstract class BlogPostCreateState with _$BlogPostCreateState {
const factory BlogPostCreateState() = _BlogPostCreateState; const BlogPostCreateState._();
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading; const factory BlogPostCreateState({
@Default(false) bool isLoading,
@Default(false) bool isSuccess,
String? errorMessage,
String? titleError,
String? descriptionError,
String? videoUrlError,
String? imagesError,
String? formError,
}) = _BlogPostCreateState;
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded; bool get hasValidationErrors =>
titleError != null ||
const factory BlogPostCreateState.error({ descriptionError != null ||
required String errorMessage, videoUrlError != null ||
}) = BlogPostCreateStateError; imagesError != null ||
formError != null;
} }

View File

@@ -1,79 +1,122 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:http/http.dart' as http;
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart'; import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:path_provider/path_provider.dart';
class BlogPostRepository { class BlogPostRepository {
static const maxGalleryImages = 10;
final BlogPostService blogPostService; final BlogPostService blogPostService;
BlogPostRepository({ BlogPostRepository({required this.blogPostService});
required this.blogPostService,
});
Future<void> createBlogPost( Future<void> createBlogPost({
String? title, String? title,
String? videoUrl, String? videoUrl,
String? description, String? description,
XFile? image, List<XFile> images = const [],
) async { }) async {
final formData = FormData.fromMap({ final formData = await _buildFormData(
if (title != null && title.isNotEmpty) 'title': title, title: title,
if (description != null && description.isNotEmpty) 'description_md': description, videoUrl: videoUrl,
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl, description: description,
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name), newImages: images,
});
return blogPostService.createBlogPost(
formData: formData,
); );
return blogPostService.createBlogPost(formData: formData);
} }
Future<XFile?> downloadImage(String imageUrl) async { Future<void> updateBlogPost({
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl')); required int id,
String? title,
if (response.statusCode == 200) { String? videoUrl,
final directory = await getTemporaryDirectory(); String? description,
final filePath = '${directory.path}/image.jpg'; List<int> existingMediaIds = const [],
final file = File(filePath); List<XFile> newImages = const [],
await file.writeAsBytes(response.bodyBytes); List<int> mediaIdsToDelete = const [],
}) async {
return XFile(file.path); if (mediaIdsToDelete.isNotEmpty) {
await Future.wait(
mediaIdsToDelete.map(
(mediaId) =>
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
),
);
} }
return null;
final formData = await _buildFormData(
title: title ?? '',
videoUrl: videoUrl ?? '',
description: description ?? '',
existingMediaIds: existingMediaIds,
newImages: newImages,
includeExistingMediaIds: true,
includeEmptyTextFields: true,
);
return blogPostService.updateBlogPost(id: id, formData: formData);
} }
Future<void> updateBlogPost( Future<void> deleteBlogPostMedia({
int id, required int postId,
String? title, required int mediaId,
String? videoUrl, }) async {
String? description, await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
XFile? image,
String? oldImageUrl,
) async {
final formData = FormData.fromMap({
'title': title,
'description_md': description,
'video_url': videoUrl,
'image': image != null
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
: oldImageUrl != '' && oldImageUrl != null
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
: null,
});
return blogPostService.updateBlogPost(
id: id,
formData: formData,
);
} }
Future<void> deleteBlogPost(int id) async { Future<void> deleteBlogPost(int id) async {
await blogPostService.deleteBlogPost(id: id); await blogPostService.deleteBlogPost(id: id);
} }
Future<FormData> _buildFormData({
String? title,
String? videoUrl,
String? description,
List<int> existingMediaIds = const [],
List<XFile> newImages = const [],
bool includeExistingMediaIds = false,
bool includeEmptyTextFields = false,
}) async {
final formData = FormData();
if (includeEmptyTextFields) {
formData.fields.add(MapEntry('title', title ?? ''));
formData.fields.add(MapEntry('description_md', description ?? ''));
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
} else {
if (title != null && title.isNotEmpty) {
formData.fields.add(MapEntry('title', title));
}
if (description != null && description.isNotEmpty) {
formData.fields.add(MapEntry('description_md', description));
}
if (videoUrl != null && videoUrl.isNotEmpty) {
formData.fields.add(MapEntry('video_url', videoUrl));
}
}
if (includeExistingMediaIds) {
for (final mediaId in existingMediaIds) {
formData.fields.add(
MapEntry('existing_media_ids[]', mediaId.toString()),
);
}
}
for (final image in newImages) {
final compressed = await ImageCompressUtility.compressFile(
File(image.path),
);
formData.files.add(
MapEntry(
'images[]',
await MultipartFile.fromFile(compressed.path, filename: image.name),
),
);
}
return formData;
}
} }

View File

@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
abstract class BlogPostService { abstract class BlogPostService {
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService; factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
@POST('/api/blogPosts/create/published') @POST('/api/v2/blogPosts/create/published')
@MultiPart() @MultiPart()
Future<void> createBlogPost({ Future<void> createBlogPost({@Body() required FormData formData});
@Body() required FormData formData,
});
@POST('/api/blogPosts/update/published/{id}') @PUT('/api/v2/blogPosts/update/published/{id}')
@MultiPart() @MultiPart()
Future<void> updateBlogPost({ Future<void> updateBlogPost({
@Path('id') required int id, @Path('id') required int id,
@Body() required FormData formData, @Body() required FormData formData,
}); });
@DELETE('/api/blogPosts/delete/{id}') @DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
Future<void> deleteBlogPost({ Future<void> deleteBlogPostMedia({
@Path('id') required int id, @Path('postId') required int postId,
@Path('mediaId') required int mediaId,
}); });
@DELETE('/api/blogPosts/delete/{id}')
Future<void> deleteBlogPost({@Path('id') required int id});
} }

View File

@@ -0,0 +1,158 @@
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
abstract final class BlogPostFormValidator {
static const minTextLength = 3;
static const maxVideoUrlLength = 2048;
static const maxGalleryImages = 10;
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
static const descriptionTooShort =
'Описание должно содержать минимум 3 символа';
static const contentRequired =
'Добавьте заголовок, описание, изображение или ссылку на видео';
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
static const videoUrlUnsupportedHost =
'Поддерживаются только Rutube, VK и OK';
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? title(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return titleTooShort;
}
return null;
}
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
static String? description(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return null;
}
if (text.length < minTextLength) {
return descriptionTooShort;
}
return null;
}
/// Empty is allowed; non-empty must match allowed video hosts/paths.
static String? videoUrl(String? value) {
final url = value?.trim() ?? '';
if (url.isEmpty) {
return null;
}
if (url.length > maxVideoUrlLength) {
return videoUrlTooLong;
}
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return videoUrlInvalid;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return videoUrlInvalid;
}
final host = uri.host.toLowerCase();
if (!_hostMatchesRootAllowlist(host, const [
'vk.com',
'vkvideo.ru',
'ok.ru',
'rutube.ru',
])) {
return videoUrlUnsupportedHost;
}
final path = uri.path;
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
if (!_rutubePath.hasMatch(path)) {
return videoUrlRutubeFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
if (!_vkPath.hasMatch(path)) {
return videoUrlVkFormat;
}
return null;
}
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
if (!_okPath.hasMatch(path)) {
return videoUrlOkFormat;
}
}
return null;
}
/// At least one of title / description / images / video must be meaningfully filled.
static String? contentRequiredMessage({
required String title,
required String description,
required String videoUrl,
required int imageCount,
}) {
final hasTitle = title.trim().length >= minTextLength;
final hasDescription = description.trim().length >= minTextLength;
final trimmedVideo = videoUrl.trim();
final hasVideo =
trimmedVideo.isNotEmpty &&
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
final hasImages = imageCount > 0;
if (hasTitle || hasDescription || hasVideo || hasImages) {
return null;
}
return contentRequired;
}
static String? galleryCount(int count) {
if (count > maxGalleryImages) {
return galleryTooLarge;
}
return null;
}
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
static String? imagePaths(Iterable<String> paths) {
for (final path in paths) {
final name = path.split(RegExp(r'[\\/]')).last;
final dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length - 1) {
return imageTypeInvalid;
}
final ext = name.substring(dot + 1).toLowerCase();
if (!allowedImageExtensions.contains(ext)) {
return imageTypeInvalid;
}
}
return null;
}
static final _rutubePath = RegExp(
r'^/(video|shorts)/[a-z0-9]+/?$',
caseSensitive: false,
);
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
static final _okPath = RegExp(r'^/video/\d+/?$');
static bool _hostMatchesRootAllowlist(
String host,
List<String> allowedRootHosts,
) {
for (final allowed in allowedRootHosts) {
if (host == allowed || host.endsWith('.$allowed')) {
return true;
}
}
return false;
}
}

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/env_config.dart'; import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart'; import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
}); });
final Widget child; final Widget child;
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши). /// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
final String currentLocation; final String currentLocation;
@override @override
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState(); State<BottomNavigationBarWidget> createState() =>
_BottomNavigationBarWidgetState();
} }
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> { class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
List<String> _getPages(bool isConsultant) { List<String> _getPages(bool isConsultant) {
if (isConsultant) { if (isConsultant) {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/consultations-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
} else { } else {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen']; return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
} }
} }
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final logo = SvgPicture.asset( final logo = SvgPicture.asset(
ImageConstant.imgLogo, ImageConstant.imgLogo,
height: 32, height: 32,
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn), colorFilter: isProduction
? null
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
); );
if (isProduction) return logo; if (isProduction) return logo;
return Stack( return Stack(
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5), border: Border.all(
color: _devAccentColor.withValues(alpha: 0.6),
width: 1.5,
),
color: _devAccentColor.withValues(alpha: 0.08), color: _devAccentColor.withValues(alpha: 0.08),
), ),
child: logo, child: logo,
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _devAccentColor, color: _devAccentColor,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))], boxShadow: [
BoxShadow(
color: _devAccentColor.withValues(alpha: 0.4),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
), ),
child: const Text( child: const Text(
'DEV', 'DEV',
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0), style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
height: 1.0,
),
), ),
), ),
), ),
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши). // Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
final pathWithoutQuery = widget.currentLocation.split('?').first; final pathWithoutQuery = widget.currentLocation.split('?').first;
final indexFromRoute = pages!.indexOf(pathWithoutQuery); final indexFromRoute = pages!.indexOf(pathWithoutQuery);
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex; final effectiveIndex = indexFromRoute >= 0
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) { ? indexFromRoute
: currentBottomNavBarIndex;
if (indexFromRoute >= 0 &&
indexFromRoute != currentBottomNavBarIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute); if (mounted) {
setState(() => currentBottomNavBarIndex = indexFromRoute);
}
}); });
} }
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: '', label: '',
icon: CircleAvatar( icon: CircleAvatar(
foregroundImage: NetworkImage( foregroundImage: NetworkImage(
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl, joinBaseUrl(
sl<AppConfig>().baseUrl,
profileModel.profilePhotoUrl,
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
), ),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
radius: 16, radius: 16,
), ),
), ),
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: 'Лента', label: 'Лента',
icon: Padding( icon: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed), child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20,
height: 20,
ImageConstant.icnFeed,
),
), ),
activeIcon: Padding( activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), child: SvgPicture.asset(
ImageConstant.icnFeed,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
), ),
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
label: s.diary, label: s.diary,
icon: Padding( icon: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20), child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
ImageConstant.icnDiary,
width: 20,
height: 20,
),
), ),
activeIcon: Padding( activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)), child: SvgPicture.asset(
ImageConstant.icnDiary,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
), ),
), ),
if (profileModel.roles.contains('consultant')) if (profileModel.roles.contains('consultant'))
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
icon: Padding( icon: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset( child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
'assets/icons/consultations.svg', 'assets/icons/consultations.svg',
width: 20, width: 20,
height: 20, height: 20,
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
'assets/icons/consultations.svg', 'assets/icons/consultations.svg',
width: 20, width: 20,
height: 20, height: 20,
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
), ),
), ),
), ),
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: s.chat, label: s.chat,
icon: BlocBuilder<ChatsCubit, ChatsState>( icon: BlocBuilder<ChatsCubit, ChatsState>(
builder: (BuildContext context, ChatsState state) { builder: (BuildContext context, ChatsState state) {
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0; final hasUnreadMessages =
state.chatsWithUnreadMessagesCount > 0;
return badges.Badge( return badges.Badge(
position: badges.BadgePosition.topEnd(), position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(), badgeAnimation: const badges.BadgeAnimation.fade(),
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent), badgeStyle: badges.BadgeStyle(
badgeColor: hasUnreadMessages
? Colors.red
: Colors.transparent,
),
badgeContent: Text( badgeContent: Text(
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '', hasUnreadMessages
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8), ? state.chatsWithUnreadMessagesCount.toString()
: '',
style: TextStyle(
color: hasUnreadMessages
? Colors.white
: Colors.transparent,
fontSize: 8,
),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
ImageConstant.icnChat, ImageConstant.icnChat,
width: 20, width: 20,
height: 20, height: 20,
colorFilter: pages![effectiveIndex] == '/chat-select-screen' colorFilter:
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) pages![effectiveIndex] == '/chat-select-screen'
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ? ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
)
: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
), ),
), ),
); );
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
return badges.Badge( return badges.Badge(
position: badges.BadgePosition.topEnd(), position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(), badgeAnimation: const badges.BadgeAnimation.fade(),
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications, showBadge:
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)), state is NotificationsStateLoaded &&
state.hasUnreadNotifications,
badgeContent: const Text(
'',
style: TextStyle(color: Colors.white, fontSize: 8),
),
child: Padding( child: Padding(
padding: const EdgeInsets.only(bottom: 2), padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset( child: SvgPicture.asset(
ImageConstant.icnBell, ImageConstant.icnBell,
colorFilter: pages![effectiveIndex] == '/notifications-screen' colorFilter:
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn) pages![effectiveIndex] ==
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), '/notifications-screen'
? ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
)
: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20, width: 20,
height: 20, height: 20,
), ),

View File

@@ -31,9 +31,14 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId, @JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt, @JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
// recipient-relative и для второго устройства отправителя всегда false,
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
// только для обратной совместимости парсинга.
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId, @JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type') String? type, @JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState, @Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid, @Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
}) = _ChatMessageModel; }) = _ChatMessageModel;
@@ -42,3 +47,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
} }
enum MessageState { loading, sent, error } enum MessageState { loading, sent, error }
enum ChatMessageType {
@JsonValue('text')
text,
@JsonValue('system')
system,
@JsonValue('attachment')
attachment,
@JsonValue('image')
image,
@JsonValue('plan_order')
planOrder,
@JsonValue('video_call_started')
videoCallStarted,
@JsonValue('video_call_ended')
videoCallEnded,
@JsonValue('unknown')
unknown;
}

View File

@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
@JsonKey(name: "name") required String name, @JsonKey(name: "name") required String name,
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath, @JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUser; }) = _ChatUser;
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json); factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);

View File

@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
required String name, required String name,
required String slug, required String slug,
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl, @JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUserModel; }) = _ChatUserModel;
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json); factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);

View File

@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'order_id_to_conversation_model.freezed.dart';
part 'order_id_to_conversation_model.g.dart';
@freezed
abstract class OrderIdToConversationModel with _$OrderIdToConversationModel {
factory OrderIdToConversationModel({
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
@JsonKey(name: 'conversation_uuid') required String conversationUuid,
}) = _OrderIdToConversationModel;
factory OrderIdToConversationModel.fromJson(Map<String, dynamic> json) =>
_$OrderIdToConversationModelFromJson(json);
}

View File

@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
@freezed @freezed
abstract class PaymentModel with _$PaymentModel { abstract class PaymentModel with _$PaymentModel {
factory PaymentModel({ factory PaymentModel({
@JsonKey(name: 'checkout_url') required String paymentUrl, @JsonKey(name: 'checkout_url') required String paymentUrl,
@JsonKey(name: 'order_id') String? orderId,
@JsonKey(name: 'payment_provider') String? paymentProvider,
}) = _PaymentModel; }) = _PaymentModel;
factory PaymentModel.fromJson(Map<String, dynamic> json) => _$PaymentModelFromJson(json); factory PaymentModel.fromJson(Map<String, dynamic> json) =>
} _$PaymentModelFromJson(json);
}

View File

@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart';
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -30,17 +31,27 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
StreamSubscription<ChannelReadEvent>? typingSubscription; StreamSubscription<ChannelReadEvent>? typingSubscription;
StreamSubscription<ChannelReadEvent>? messagesSubscription; StreamSubscription<ChannelReadEvent>? messagesSubscription;
StreamSubscription<void>? _reconnectSubscription;
int currentPage = 1; int currentPage = 1;
late int lastPage; late int lastPage;
bool isLoading = false; bool isLoading = false;
Timer? _timer; Timer? _timer;
String? _activeChatUuid;
void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid(); var uuid = const Uuid();
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); ChatRoomCubit({
required this.chatRepository,
required this.wsManager,
required this.cardsRepository,
}) : super(const ChatRoomState.loading());
void setTimer() { void setTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState()); _timer = Timer.periodic(
const Duration(seconds: 5),
(_) => updateChosenPlanState(),
);
} }
void cancelTimer() { void cancelTimer() {
@@ -51,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (currentPage < lastPage && !isLoading) { if (currentPage < lastPage && !isLoading) {
isLoading = true; isLoading = true;
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading)); _emitLoaded((current) => current.copyWith(isLoading: isLoading));
try { try {
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1); final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage + 1,
);
isLoading = false; isLoading = false;
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
isLoading: isLoading, isLoading: isLoading,
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList], chatMessages: [
...current.chatMessages,
...chatMessages.messagesList,
],
), ),
); );
@@ -83,25 +100,57 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
void _setCard({required List<CardModel> cards}) { void _setCard({required List<CardModel> cards}) {
final currentState = state; if (cards.where((card) => card.isDefault).isNotEmpty) {
if (currentState is ChatRoomStateLoaded) { _emitLoaded((current) => current.copyWith(hasCard: true));
if (cards.where((card) => card.isDefault).isNotEmpty) {
emit(currentState.copyWith(hasCard: true));
}
} }
} }
Future<void> initChat(String uuid, {bool sendInitialMessage = false}) async { /// Инициализирует чат по [uuid] либо по [orderId] (resolve → conversation_uuid).
///
/// При наличии [orderId] сначала вызывается
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
///
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
Future<String?> initChat(
String? uuid, {
String? orderId,
bool sendInitialMessage = false,
}) async {
try { try {
emit(const ChatRoomState.loading()); emit(const ChatRoomState.loading());
final chatRoom = await chatRepository.getChatRoom(uuid);
ChosenPlanModel? chosenPlan; String? resolvedUuid = uuid;
if (chatRoom.chosenPlanId != null) { int? resolvedChosenPlanId;
chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
if (orderId != null && orderId.isNotEmpty) {
final resolved = await chatRepository.resolveOrderIdToConversation(
orderId: orderId,
);
resolvedUuid = resolved.conversationUuid;
resolvedChosenPlanId = resolved.chosenPlanId;
} }
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage);
if (resolvedUuid == null || resolvedUuid.isEmpty) {
throw ArgumentError('initChat requires uuid or orderId');
}
currentPage = 1;
isLoading = false;
_activeChatUuid = resolvedUuid;
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
ChosenPlanModel? chosenPlan;
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(
resolvedUuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage; lastPage = chatMessages.meta.lastPage;
await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id); await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
_listenToReconnect();
emit( emit(
ChatRoomState.loaded( ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan), chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -111,30 +160,117 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
hasCard: false, hasCard: false,
), ),
); );
await _subscribeToPresenceChannel(chatId: chatRoom.id);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards();
}
return resolvedUuid;
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
typingSubscription?.cancel();
messagesSubscription?.cancel();
_reconnectSubscription?.cancel();
_reconnectSubscription = null;
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null;
}
}
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
void _listenToReconnect() {
_reconnectSubscription?.cancel();
_reconnectSubscription = wsManager.reconnected.listen((_) {
unawaited(refreshCurrentChat());
});
}
/// Догружает первую страницу сообщений после восстановления соединения (S5).
///
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
/// сообщения добавляются, существующие не дублируются. Порядок — по
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
Future<void> refreshCurrentChat() async {
final uuid = _activeChatUuid;
if (uuid == null) return;
try {
final page = await chatRepository.getChatMessages(uuid, 1);
lastPage = page.meta.lastPage;
_emitLoaded((current) {
final byId = {
for (final message in current.chatMessages) message.id: message,
};
for (final message in page.messagesList) {
byId.putIfAbsent(message.id, () => message);
}
final merged = byId.values.toList()
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
return current.copyWith(chatMessages: merged);
});
} catch (e, st) {
await reportFrom(e, st, this);
}
}
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
Future<void> refreshChat() async {
final uuid = _activeChatUuid;
if (uuid == null || uuid.isEmpty) {
return;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return;
}
try {
currentPage = 1;
isLoading = false;
final chatRoom = await chatRepository.getChatRoom(uuid);
ChosenPlanModel? chosenPlan;
final planId =
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
if (planId != null) {
chosenPlan = await chatRepository.getChosenPlan(id: planId);
}
final chatMessages = await chatRepository.getChatMessages(
uuid,
currentPage,
);
lastPage = chatMessages.meta.lastPage;
_emitLoaded(
(latest) => latest.copyWith(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
chatMessages: chatMessages.messagesList,
isLoading: false,
),
);
if (chosenPlan?.consultantId == chatRoom.users.sender.id) { if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards(); await checkCards();
} }
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString());
typingSubscription?.cancel();
messagesSubscription?.cancel();
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
} }
} }
Future<void> updateChosenPlanState() async { Future<void> updateChosenPlanState() async {
try { try {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is! ChatRoomStateLoaded) {
final chosenPlanId = currentState.chatRoom.chosenPlanId; return;
if (chosenPlanId != null) {
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
}
} }
final chosenPlanId = currentState.chatRoom.chosenPlanId;
if (chosenPlanId == null) {
return;
}
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
@@ -146,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId; final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
if (planId == null) return false; if (planId == null) return false;
final stringDate = date.format('yyyy-MM-dd'); final stringDate = date.format('yyyy-MM-dd');
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate}); await chatRepository.choseStartPlanDate(
id: planId,
date: {'start_date': stringDate},
);
final chosenPlan = await chatRepository.getChosenPlan(id: planId); final chosenPlan = await chatRepository.getChosenPlan(id: planId);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan)); emit(
(state as ChatRoomStateLoaded).copyWith.chatRoom(
chosenPlan: chosenPlan,
),
);
return true; return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -157,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<void> cancelChosenPlan({required String chatId, required int planId}) async { Future<void> cancelChosenPlan({
required String chatId,
required int planId,
}) async {
try { try {
await chatRepository.cancelChosenPlan(id: planId); await chatRepository.cancelChosenPlan(id: planId);
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null)); emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
@@ -167,93 +313,294 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async { bool _emitLoaded(
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
) {
if (isClosed) {
return false;
}
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
emit(update(currentState));
return true;
}
Future<void> _subscribeToChatChannel({
required String uuid,
required String chatId,
}) async {
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
// пропущенного после реконнекта (S5), даже если сокет появится позже.
_activeChatUuid = uuid;
final client = wsManager.client; final client = wsManager.client;
if (client == null) return; if (client == null) return;
await messagesSubscription?.cancel();
await typingSubscription?.cancel();
messagesSubscription = null;
typingSubscription = null;
await wsManager.initPrivateDirectChatChannel(chatId); await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel(); wsManager.subscribeDirectChatChannel();
messagesSubscription = wsManager.directChatStream?.listen((event) { messagesSubscription = wsManager.directChatStream?.listen(
AudioPlayer().play((AssetSource('audio/recive_message.wav'))); _onDirectChatEvent,
final message = ChatMessageModel.fromJson(jsonDecode(event.data)); onError: (Object e, StackTrace st) {
final currentState = state as ChatRoomStateLoaded; unawaited(reportFrom(e, st, this));
},
// Сообщения от текущего пользователя не приходят через WebSocket, );
// они обрабатываются через ответ API в sendMessage
// Здесь обрабатываем только входящие сообщения от других пользователей
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
chatRepository.emitNewMessageToChats(message);
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
});
typingSubscription = wsManager.clientTypingStream?.listen((event) { typingSubscription = wsManager.clientTypingStream?.listen((event) {
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading)); _emitLoaded(
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
);
EasyDebounce.debounce( EasyDebounce.debounce(
'client-typing', 'client-typing',
const Duration(seconds: 3), const Duration(seconds: 3),
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)), () => _emitLoaded(
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
),
); );
}); });
} }
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async { void _onDirectChatEvent(ChannelReadEvent event) {
try {
if (addIncomingMessage(
ChatMessageModel.fromJson(
jsonDecode(event.data) as Map<String, dynamic>,
),
)) {
unawaited(_playIncomingSound());
}
} catch (e, st) {
unawaited(reportFrom(e, st, this));
}
}
Future<void> _playIncomingSound() async {
try {
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
} catch (_) {}
}
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
///
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
/// обновляет S3 через `new-chat-messages.{senderId}`.
///
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
/// сообщение, реально добавленное).
bool addIncomingMessage(ChatMessageModel message) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) {
return false;
}
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
// это эхо уже добавленного оптимистичного сообщения).
final alreadyExists = currentState.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
);
if (alreadyExists) {
return false;
}
final added = _emitLoaded((current) {
if (current.chatMessages.any(
(existing) =>
(message.id != 0 && existing.id == message.id) ||
(message.uuid != null && existing.uuid == message.uuid),
)) {
return current;
}
return current.copyWith(
chatMessages: [message, ...current.chatMessages],
isLoading: isLoading,
);
});
if (!added) {
return false;
}
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
if (!isOwn) {
chatRepository.emitNewMessageToChats(message);
}
return !isOwn;
}
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: currentState.chatRoom.users,
currentUserId: wsManager.userId,
);
await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) {
unawaited(
_handleCounterpartOnlineChanged(
counterpartField:
counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
),
);
};
wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
Future<void> _handleCounterpartOnlineChanged({
required _ChatCounterpartField counterpartField,
required bool isOnline,
}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = counterpartField == _ChatCounterpartField.receiver
? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline &&
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
return;
}
if (!isOnline) {
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
return;
}
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: true,
lastSeenText: '',
);
}
Future<void> _refreshCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
}) async {
final chatUuid = _activeChatUuid;
if (chatUuid == null) return;
try {
final chatRoom = await chatRepository.getChatRoom(chatUuid);
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: wsManager.userId,
);
final field = counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender;
if (field != counterpartField) return;
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: counterpart.isOnline,
lastSeenText: counterpart.lastSeenText,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _emitCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
required bool isOnline,
required String lastSeenText,
}) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final users = currentState.chatRoom.users;
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
? users.copyWith(
receiver: users.receiver.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
)
: users.copyWith(
sender: users.sender.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
);
emit(
currentState.copyWith(
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
),
);
}
Future<bool> sendMessage({
String? data,
List<File>? files,
required String userSlug,
}) async {
try { try {
final messageUuid = uuid.v4(); final messageUuid = uuid.v4();
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null; final compressedFiles = files != null
AudioPlayer().play((AssetSource('audio/send_message.wav'))); ? await ImageCompressUtility.compressFilesList(List<File>.from(files))
: null;
try {
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
} catch (_) {}
// Создаем временное сообщение с состоянием loading _emitLoaded(
emit( (current) => current.copyWith(
(state as ChatRoomStateLoaded).copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -messageUuid.hashCode.abs(),
data: data ?? '', data: data ?? '',
mediaData: compressedFiles, mediaData: compressedFiles,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: messageUuid, uuid: messageUuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages, ...current.chatMessages,
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера с правильными данными final serverMessage = await chatRepository.sendMessage(
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug); data: data,
files: compressedFiles,
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов) slug: userSlug,
emit( );
(state as ChatRoomStateLoaded).copyWith( _replaceOptimisticMessage(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { messageUuid: messageUuid,
if (elem.uuid == messageUuid) { serverMessage: serverMessage,
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
return serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
); );
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == messageUuid) { if (elem.uuid == messageUuid) {
return elem.copyWith(messageState: MessageState.error); return elem.copyWith(messageState: MessageState.error);
} }
@@ -271,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async { Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
try { try {
if (state is ChatRoomStateLoaded) { if (state is! ChatRoomStateLoaded) {
final ids = messagesIds.map((e) => e.id).toList(); return false;
await chatRepository.markMessagesAsRead(chatId, ids);
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
for (var elem in messagesIds) {
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
}
if (!isClosed) {
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
}
return true;
} }
return false; await chatRepository.markMessagesAsRead(chatId, messageIds);
final idSet = messageIds.toSet();
_emitLoaded(
(current) => current.copyWith(
chatMessages: current.chatMessages
.map(
(message) => idSet.contains(message.id)
? message.copyWith(isRead: true)
: message,
)
.toList(),
),
);
return true;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
@@ -293,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
void _replaceOptimisticMessage({
required String messageUuid,
required ChatMessageModel serverMessage,
}) {
_emitLoaded((current) {
final sentMessage = serverMessage.copyWith(
uuid: messageUuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
);
final withoutDuplicate = current.chatMessages.where(
(message) =>
message.id != serverMessage.id || message.uuid == messageUuid,
);
final hasOptimistic = withoutDuplicate.any(
(message) => message.uuid == messageUuid,
);
if (!hasOptimistic) {
return current.copyWith(
chatMessages: [sentMessage, ...withoutDuplicate],
);
}
return current.copyWith(
chatMessages: withoutDuplicate.map((elem) {
if (elem.uuid == messageUuid) {
return sentMessage.copyWith(
isRead: elem.isRead,
roomId: elem.roomId?.isNotEmpty == true
? elem.roomId
: serverMessage.roomId,
);
}
return elem;
}).toList(),
);
});
}
Future<String?> getPaymentLink() async { Future<String?> getPaymentLink() async {
try { try {
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
if (planId == null) return null; if (planId == null) return null;
final paymentModel = await chatRepository.getPaymentLink(id: planId); final paymentModel = await chatRepository.getPaymentLink(
id: planId,
paymentProvider: 'alfa-bank',
);
return paymentModel.paymentUrl; return paymentModel.paymentUrl;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -337,87 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override @override
Future<void> close() async { Future<void> close() async {
EasyDebounce.cancel('client-typing');
EasyDebounce.cancel('searchChatRoom');
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
_timer?.cancel(); _timer?.cancel();
messagesSubscription?.cancel(); await messagesSubscription?.cancel();
typingSubscription?.cancel(); await typingSubscription?.cancel();
await _reconnectSubscription?.cancel();
super.close(); super.close();
} }
void deleteMessage(String uuid) { void deleteMessage(String uuid) {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is ChatRoomStateLoaded) {
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList())); emit(
currentState.copyWith(
chatMessages: currentState.chatMessages
.where((message) => message.uuid != uuid)
.toList(),
),
);
} }
} }
Future<void> resendMessage({required String uuid, required String userSlug}) async { Future<void> resendMessage({
required String uuid,
required String userSlug,
}) async {
final currentState = state; final currentState = state;
if (currentState is ChatRoomStateLoaded) { if (currentState is ChatRoomStateLoaded) {
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid); final message = currentState.chatMessages.firstWhereOrNull(
(message) => message.uuid == uuid,
);
if (message != null) { if (message != null) {
try { try {
AudioPlayer().play((AssetSource('audio/send_message.wav'))); try {
unawaited(
AudioPlayer().play(AssetSource('audio/send_message.wav')),
);
} catch (_) {}
// Определяем, есть ли файлы для повторной отправки
List<File>? filesToResend; List<File>? filesToResend;
if (message.mediaData != null && message.mediaData!.isNotEmpty) { if (message.mediaData != null && message.mediaData!.isNotEmpty) {
// Если mediaData содержит File объекты, используем их
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
final firstItem = message.mediaData!.first; final firstItem = message.mediaData!.first;
if (firstItem is File) { if (firstItem is File) {
filesToResend = message.mediaData!.cast<File>(); filesToResend = message.mediaData!.cast<File>();
} }
} }
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: [ chatMessages: [
ChatMessageModel( ChatMessageModel(
id: 1, id: -uuid.hashCode.abs(),
data: message.data, data: message.data,
mediaData: message.mediaData, mediaData: message.mediaData,
authorId: 1, authorId: wsManager.userId ?? 0,
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
isRead: true, isRead: true,
isCurrentUserAuthor: true, isCurrentUserAuthor: true,
roomId: '', roomId: current.chatRoom.id,
messageState: MessageState.loading, messageState: MessageState.loading,
uuid: uuid, uuid: uuid,
), ),
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid), ...current.chatMessages.where((item) => item.uuid != uuid),
], ],
), ),
); );
try { try {
// Отправляем сообщение и получаем ответ от сервера final serverMessage = await chatRepository.sendMessage(
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug); data: message.data,
files: filesToResend,
// Заменяем локальное сообщение данными с сервера slug: userSlug,
emit( );
(state as ChatRoomStateLoaded).copyWith( _replaceOptimisticMessage(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { messageUuid: uuid,
if (elem.uuid == uuid) { serverMessage: serverMessage,
return serverMessage.copyWith(
uuid: uuid,
messageState: MessageState.sent,
isCurrentUserAuthor: true,
isRead: elem.isRead,
roomId: elem.roomId ?? serverMessage.roomId,
authorId: serverMessage.authorId,
updatedAt: serverMessage.updatedAt,
);
}
return elem;
}).toList(),
),
); );
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( _emitLoaded(
(state as ChatRoomStateLoaded).copyWith( (current) => current.copyWith(
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) { chatMessages: current.chatMessages.map((elem) {
if (elem.uuid == uuid) { if (elem.uuid == uuid) {
return elem.copyWith(messageState: MessageState.error); return elem.copyWith(messageState: MessageState.error);
} }
@@ -435,3 +833,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
} }
enum _ChatCounterpartField { sender, receiver }

View File

@@ -5,9 +5,14 @@ import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart'; import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
final WsManager wsManager; final WsManager wsManager;
StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription; StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall;
ChatsCubit({ ChatsCubit({
required this.chatRepository, required this.chatRepository,
required this.wsManager, required this.wsManager,
required this.profileRepository, required this.profileRepository,
this.presentIncomingVideoCall,
this.dismissIncomingVideoCall,
}) : super(ChatsState()) { }) : super(ChatsState()) {
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) { newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
message,
) {
_setNewLastMessage(message); _setNewLastMessage(message);
}); });
} }
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
Future<void> connectToWebSocket() async { Future<void> connectToWebSocket() async {
try { try {
await newIncomingMessagesSubscription?.cancel(); await newIncomingMessagesSubscription?.cancel();
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) { newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
event,
) {
_handleNewMessageViaSocket(event.data); _handleNewMessageViaSocket(event.data);
}); });
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
});
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
_query = query; _query = query;
emit(state.copyWith(state: BaseState.loading)); emit(state.copyWith(state: BaseState.loading));
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
page++; page++;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
page = 1; page = 1;
_query = query; _query = query;
try { try {
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: query,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
lastPage = chatRoomsPaginationModel.meta.lastPage + 1; lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
emit( emit(
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
emit( emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
);
} }
} }
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
try { try {
if (state.isLoadingMoreChats || lastPage == page) return; if (state.isLoadingMoreChats || lastPage == page) return;
emit(state.copyWith(isLoadingMoreChats: true)); emit(state.copyWith(isLoadingMoreChats: true));
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page); final chatRoomsPaginationModel = await chatRepository.getChatRooms(
query: _query,
page: page,
);
final unreadChats = chatRoomsPaginationModel.unreadChatsCount; final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
emit( emit(
state.copyWith( state.copyWith(
@@ -134,15 +159,20 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount, chatsWithUnreadMessagesCount: isChatRead
? state.chatsWithUnreadMessagesCount - 1
: state.chatsWithUnreadMessagesCount,
), ),
); );
} }
Future<void> _handleNewMessageViaSocket(dynamic data) async { Future<void> _handleNewMessageViaSocket(dynamic data) async {
try { try {
final json = jsonDecode(data); final json = jsonDecode(data) as Map<String, dynamic>;
final message = ChatMessageModel.fromJson(json['message']); final message = ChatMessageModel.fromJson(json);
final currentUserId = wsManager.userId;
final isOwn = currentUserId != null && message.authorId == currentUserId;
final currentChatRooms = [...state.chatRooms]; final currentChatRooms = [...state.chatRooms];
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
final updatedChatRooms = currentChatRooms.map((chatRoom) { final updatedChatRooms = currentChatRooms.map((chatRoom) {
if (chatRoom.id == message.roomId) { if (chatRoom.id == message.roomId) {
wasChanged = true; wasChanged = true;
if (chatRoom.unreadMessagesCount == 0) { // Идемпотентность: повторная доставка того же message.id (реконнект,
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
final isDuplicate =
message.id != 0 && chatRoom.lastMessage?.id == message.id;
// Собственное сообщение (второе устройство автора) обновляет
// lastMessage, но не создаёт непрочитанное (S3).
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
willIncrementUnreadChats = true; willIncrementUnreadChats = true;
} }
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1); return chatRoom.copyWith(
lastMessage: message,
unreadMessagesCount: isOwn || isDuplicate
? chatRoom.unreadMessagesCount
: chatRoom.unreadMessagesCount + 1,
);
} }
return chatRoom; return chatRoom;
}).toList(); }).toList();
@@ -165,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
emit( emit(
state.copyWith( state.copyWith(
chatRooms: updatedChatRooms, chatRooms: updatedChatRooms,
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount, chatsWithUnreadMessagesCount: willIncrementUnreadChats
? state.chatsWithUnreadMessagesCount + 1
: state.chatsWithUnreadMessagesCount,
), ),
); );
} }
_presentVideoCallFromMessage(
message,
isOwn: isOwn,
chatRooms: currentChatRooms,
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
} }
} }
void _presentVideoCallFromMessage(
ChatMessageModel message, {
required bool isOwn,
required List<ChatRoom> chatRooms,
}) {
final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi());
return;
}
if (isOwn || kind != VideoCallChatSignalKind.started) return;
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
return;
}
final caller = _callerFromRooms(message, chatRooms);
final payload = VideoCallChatSignal.incomingPayload(
message: message,
callerName: caller?.name,
callerAvatarUrl: caller?.profilePhotoUrl,
);
if (payload == null) return;
unawaited(_showIncomingVideoCall(payload));
}
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
try {
await (presentIncomingVideoCall ??
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
} catch (e, st) {
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
}
}
Future<void> _dismissIncomingVideoCallUi() async {
try {
await (dismissIncomingVideoCall ??
VideoCallEndedPushHandler.dismissIncomingCallUi)();
} catch (e, st) {
log(
'ChatsCubit: failed to dismiss incoming video call: $e',
stackTrace: st,
);
}
}
ChatUser? _callerFromRooms(
ChatMessageModel message,
List<ChatRoom> chatRooms,
) {
for (final room in chatRooms) {
if (room.id != message.roomId) continue;
final from = room.users.fromChatUser;
final to = room.users.toChatUser;
if (from.id == message.authorId) return from;
if (to.id == message.authorId) return to;
}
return null;
}
void _setNewLastMessage(ChatMessageModel message) { void _setNewLastMessage(ChatMessageModel message) {
final chats = state.chatRooms.map((chat) { final chats = state.chatRooms.map((chat) {
if (chat.id == message.roomId) { if (chat.id == message.roomId) {
@@ -188,6 +297,7 @@ class ChatsCubit extends Cubit<ChatsState> {
@override @override
Future<void> close() { Future<void> close() {
newIncomingMessagesSubscription?.cancel(); newIncomingMessagesSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close(); return super.close();
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
final int index; final int index;
final bool isLocal; final bool isLocal;
/// When set, used instead of the image URL so source/destination tags stay unique.
final Object? heroTag;
const PhotoViewScreen({ const PhotoViewScreen({
super.key, super.key,
required this.imageUrls, required this.imageUrls,
required this.index, required this.index,
this.isLocal = false, this.isLocal = false,
this.heroTag,
}); });
@override @override
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
super.initState(); super.initState();
} }
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
if (widget.heroTag != null) {
if (index != widget.index) {
return null;
}
return PhotoViewHeroAttributes(
tag: widget.heroTag!,
transitionOnUserGestures: true,
);
}
return PhotoViewHeroAttributes(
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DismissiblePage( return DismissiblePage(
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
direction: DismissiblePageDismissDirection.vertical, direction: DismissiblePageDismissDirection.vertical,
isFullScreen: true, isFullScreen: true,
child: PhotoViewGallery.builder( child: PhotoViewGallery.builder(
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(color: Colors.transparent),
color: Colors.transparent,
),
scrollPhysics: const BouncingScrollPhysics(), scrollPhysics: const BouncingScrollPhysics(),
builder: (BuildContext context, int index) { builder: (BuildContext context, int index) {
final imageProvider = widget.isLocal final imageProvider = widget.isLocal
? FileImage(widget.imageUrls[index]) as ImageProvider<Object> ? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>; : CachedNetworkImageProvider(widget.imageUrls[index])
as ImageProvider<Object>;
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(
imageProvider: imageProvider, imageProvider: imageProvider,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes( heroAttributes: _heroAttributesFor(index),
tag: widget.imageUrls[index],
transitionOnUserGestures: true,
),
errorBuilder: (context, object, stackTrace) { errorBuilder: (context, object, stackTrace) {
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
), ),
contentPadding: const EdgeInsets.all(16), contentPadding: const EdgeInsets.all(16),
onTap: () async { onTap: () async {
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) { if (!await launchUrl(
throw Exception('Could not launch ${widget.imageUrls[index]}'); Uri.parse(widget.imageUrls[index]),
)) {
throw Exception(
'Could not launch ${widget.imageUrls[index]}',
);
} }
}, },
leading: const Icon( leading: const Icon(
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
color: Colors.white, color: Colors.white,
), ),
title: Text( title: Text(
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''), widget.imageUrls[index].replaceAll(
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
'',
),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 12, fontSize: 12,
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
width: 20.0, width: 20.0,
height: 20.0, height: 20.0,
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!, value: event == null
? 0
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
), ),
), ),
), ),

View File

@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
final MessageState messageState; final MessageState messageState;
final String messageUuid; final String messageUuid;
final String userSlug; final String userSlug;
final String? messageType; final ChatMessageType? messageType;
final bool? isConsultant; final bool? isConsultant;
final List<Map<String, dynamic>>? urlsMediaInfo; final List<Map<String, dynamic>>? urlsMediaInfo;
@override @override
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
} }
} }
return ListTile( return Material(
contentPadding: const EdgeInsets.all(4), color: Colors.transparent,
horizontalTitleGap: 8, child: ListTile(
onTap: () async { contentPadding: const EdgeInsets.all(4),
if (mediaItem is File) { horizontalTitleGap: 8,
// Для локальных файлов используем file:// URI onTap: () async {
final fileUri = Uri.file(mediaItem.path); if (mediaItem is File) {
if (await canLaunchUrl(fileUri)) { // Для локальных файлов используем file:// URI
await launchUrl(fileUri, mode: LaunchMode.externalApplication); final fileUri = Uri.file(mediaItem.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
}
} else {
// Для URL файлов используем обычный launchUrl
final urlString = mediaItem.toString();
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
? Uri.parse(urlString)
: Uri.parse('https://$urlString');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} }
} else { },
// Для URL файлов используем обычный launchUrl leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
final urlString = mediaItem.toString(); trailing: const Icon(Icons.download_outlined, color: Colors.black),
final uri = urlString.startsWith('http://') || urlString.startsWith('https://') title: Text(
? Uri.parse(urlString) fileName,
: Uri.parse('https://$urlString'); maxLines: 2,
if (await canLaunchUrl(uri)) { style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
await launchUrl(uri, mode: LaunchMode.externalApplication); ),
}
}
},
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
trailing: const Icon(Icons.download_outlined, color: Colors.black),
title: Text(
fileName,
maxLines: 2,
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
), ),
); );
} }
@@ -255,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget {
} }
String _getDisplayText() { String _getDisplayText() {
if (messageType == 'plan_order') { if (messageType == ChatMessageType.planOrder) {
if (isConsultant == true) { if (isConsultant == true) {
// Текст для консультанта // Текст для консультанта
return '1. Уточните у подопечного его цели и пожелания\n' return '1. Уточните у подопечного его цели и пожелания\n'

View File

@@ -0,0 +1,70 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ChatUserOnlineStatusLine extends StatelessWidget {
const ChatUserOnlineStatusLine({
super.key,
required this.isOnline,
required this.lastSeenText,
this.style,
this.showOnlineDot = true,
});
final bool isOnline;
final String lastSeenText;
final TextStyle? style;
final bool showOnlineDot;
@override
Widget build(BuildContext context) {
final textStyle = style ??
CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12);
if (isOnline) {
if (!showOnlineDot) {
return Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
],
);
}
final trimmedLastSeen = lastSeenText.trim();
if (trimmedLastSeen.isEmpty) {
return const SizedBox.shrink();
}
return Text(
trimmedLastSeen,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
}

View File

@@ -2,15 +2,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.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/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
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/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/complete_plan_bottom_sheet.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_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_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart'; import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
} }
class _NotificationBannerState extends State<NotificationBanner> { class _NotificationBannerState extends State<NotificationBanner> {
bool isLoading = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context); final size = MediaQuery.sizeOf(context);
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
padding: EdgeInsets.only(top: 12, right: 8, left: 12), padding: EdgeInsets.only(top: 12, right: 8, left: 12),
child: _StarIcon(), child: _StarIcon(),
), ),
if (isLoading) Padding(
const Padding(
padding: EdgeInsets.only(left: 40, top: 24),
child: Loader(),
)
else
Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
GestureDetector( GestureDetector(
onTap: () async { onTap: () {
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id; final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
setState(() { if (planId != null) {
isLoading = true; showConsultationPaymentFlow(context, planId: planId);
});
final paymentLink = await context.read<ChatRoomCubit>().getPaymentLink();
setState(() {
isLoading = false;
});
if (paymentLink != null && planId != null) {
if (!context.mounted) return;
GoRouter.of(context).pushReplacement(
PaymentScreen.path,
extra: {'url': paymentLink, 'planId': planId},
);
} }
}, },
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)), child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),

View File

@@ -0,0 +1,103 @@
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';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class VideoCallChatMessageWidget extends StatelessWidget {
const VideoCallChatMessageWidget({
super.key,
required this.text,
required this.sendTime,
required this.onVideoCallPressed,
});
final String text;
final DateTime sendTime;
final VoidCallback onVideoCallPressed;
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
return SizedBox(
width: size.width,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 64, 4),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
constraints: BoxConstraints(maxWidth: size.width * 0.7),
decoration: BoxDecoration(
color: appTheme.gray60004,
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(16),
right: Radius.circular(24),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (text.isNotEmpty)
Flexible(
child: Text(text, style: textStyle),
),
if (text.isNotEmpty) const SizedBox(width: 8),
_VideoCallBubbleActionButton(onPressed: onVideoCallPressed),
],
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 4),
child: Text(
sendTime.format('HH:mm', 'ru'),
style: const TextStyle(
color: Color(0xFF7B8794),
height: 1,
fontSize: 10,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}
}
class _VideoCallBubbleActionButton extends StatelessWidget {
const _VideoCallBubbleActionButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: appTheme.greenA700,
borderRadius: BorderRadius.circular(10),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onPressed,
child: SizedBox(
width: 36,
height: 36,
child: Center(
child: SvgPicture.asset(
'assets/icons/video_call/vc_control_enter.svg',
width: 20,
height: 20,
fit: BoxFit.contain,
),
),
),
),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart';
@@ -98,8 +99,19 @@ class ChatRepository {
Future<PaymentModel> getPaymentLink({ Future<PaymentModel> getPaymentLink({
required int id, required int id,
required String paymentProvider,
}) async { }) async {
return await chatService.getPaymentLink(id: id); return await chatService.getPaymentLink(
id: id,
body: {'payment_provider': paymentProvider},
);
}
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
required String orderId,
}) async {
return await chatService.resolveOrderIdToConversation(orderId: orderId);
} }

View File

@@ -3,8 +3,13 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart'; import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
@@ -65,6 +70,32 @@ abstract class ChatService {
@POST('/api/chosen_plans/{id}/start_payment_process') @POST('/api/chosen_plans/{id}/start_payment_process')
Future<PaymentModel> getPaymentLink({ Future<PaymentModel> getPaymentLink({
@Path() required int id, @Path() required int id,
@Body() required Map<String, String> body,
});
@GET('/api/chosen_plans/applied_promos')
Future<PromoAppliedListResponse> listAppliedPromoCodes();
@POST('/api/chosen_plans/{id}/validate_promo')
Future<PromoValidateResponse> validatePromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@POST('/api/chosen_plans/{id}/apply_promo')
Future<PromoApplyResponse> applyPromoCode({
@Path() required int id,
@Body() required Map<String, String> body,
});
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
Future<PromoRemoveResponse> removePromoCode({
@Path() required int id,
});
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
@Path() required String orderId,
}); });
@GET('/api/chosen_plans/show/{id}') @GET('/api/chosen_plans/show/{id}')

View File

@@ -0,0 +1,15 @@
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
ChatUserModel resolveChatCounterpart({
required Users users,
required int? currentUserId,
}) {
if (currentUserId != null && users.sender.id == currentUserId) {
return users.receiver;
}
if (currentUserId != null && users.receiver.id == currentUserId) {
return users.sender;
}
return users.receiver;
}

View File

@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id), create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
child: Container( child: Container(
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16), padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
decoration: const BoxDecoration( decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
child: BlocBuilder<ComplainingCubit, ComplainingState>( child: BlocBuilder<ComplainingCubit, ComplainingState>(
builder: (context, state) { builder: (context, state) {
final category = state.category; final category = state.category;
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.complaint,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
s.complaint_question, s.complaint_question,
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.spam,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.explicitImages,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
}); });
}, },
), ),
Text( Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
], ],
); );
}), }),
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
if ((state.complainInfo.description?.length ?? 10) < 10) ...[ if ((state.complainInfo.description?.length ?? 10) < 10) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
const ValidationErrorWidget(error: 'Минимум 10 символов'), const ValidationErrorWidget(error: 'Минимум 10 символов'),
] ],
], ],
const SizedBox(height: 20), const SizedBox(height: 20),
_ComplainButton( _ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
),
], ],
); );
}, },
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
}, },
), ),
Expanded( Expanded(
child: Text( child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
), ),
], ],
); );
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
super.didChangeDependencies(); super.didChangeDependencies();
final s = S.of(context); final s = S.of(context);
if (tags.isEmpty && descriptions.isEmpty) { if (tags.isEmpty && descriptions.isEmpty) {
tags.addAll([ tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
(false, s.misrepresentation),
(false, s.fraud),
]);
descriptions.addAll([ descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
s.misrepresentation_description,
s.fraud_description,
]);
} }
} }
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.lawViolation,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
element.$2,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
child: Text( child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
descriptions[index],
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
),
), ),
], ],
), ),
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.complaint_sent,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
text: s.ok, text: s.ok,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () { onPressed: () {
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
}, },
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
children: [ children: [
const BottomSheetHandlerBar(), const BottomSheetHandlerBar(),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
s.could_not_send_complain,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
width: MediaQuery.sizeOf(context).width * 0.7, width: MediaQuery.sizeOf(context).width * 0.7,
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
text: s.ok, text: s.ok,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () { onPressed: () {
AppRouter.navigatorKey.currentContext?.pop(); AppRouter.navigatorKey.currentContext?.pop();
}, },
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
return BlocBuilder<ComplainingCubit, ComplainingState>( return BlocBuilder<ComplainingCubit, ComplainingState>(
builder: (context, state) { builder: (context, state) {
if (state.isLoading) { if (state.isLoading) {
return const Loader(); return const SizedBox(height: 52, child: Loader());
} }
return CustomOutlinedButton( return CustomOutlinedButton(
text: s.send_complain, text: s.send_complain,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async { onPressed: () async {
if (isDisabled) { if (isDisabled) {
context.read<ComplainingCubit>().setDescription(''); context.read<ComplainingCubit>().setDescription('');
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
child: Ink( child: Ink(
child: Row( child: Row(
children: [ children: [
Text( Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
category.getTranslatedText(),
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
const Spacer(), const Spacer(),
SvgPicture.asset('assets/icons/right_arrow.svg'), SvgPicture.asset('assets/icons/right_arrow.svg'),
], ],
@@ -591,18 +533,18 @@ enum ComplainType {
explicitImage; explicitImage;
String getTranslatedText() => switch (this) { String getTranslatedText() => switch (this) {
ComplainType.spam => S.current.spam, ComplainType.spam => S.current.spam,
ComplainType.lawViolation => S.current.lawViolation, ComplainType.lawViolation => S.current.lawViolation,
ComplainType.deception => S.current.deception, ComplainType.deception => S.current.deception,
ComplainType.violenceDndHostility => S.current.violenceAndHostility, ComplainType.violenceDndHostility => S.current.violenceAndHostility,
ComplainType.explicitImage => S.current.explicitImage, ComplainType.explicitImage => S.current.explicitImage,
}; };
String getValue() => switch (this) { String getValue() => switch (this) {
ComplainType.spam => 'spam', ComplainType.spam => 'spam',
ComplainType.lawViolation => 'legalViolation', ComplainType.lawViolation => 'legalViolation',
ComplainType.deception => 'fraud', ComplainType.deception => 'fraud',
ComplainType.violenceDndHostility => 'violenceAndHatred', ComplainType.violenceDndHostility => 'violenceAndHatred',
ComplainType.explicitImage => 'explicitImage', ComplainType.explicitImage => 'explicitImage',
}; };
} }

View File

@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
} }
Future<void> complain() async { Future<void> complain() async {
if (state.isLoading) {
return;
}
emit(state.copyWith(isLoading: true));
try { try {
final category = state.category; final category = state.category;
final id = state.contentId; final id = state.contentId;
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
case ComplainedContent.diet: case ComplainedContent.diet:
if (id != null) { if (id != null) {
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo); await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
case ComplainedContent.plan: case ComplainedContent.plan:
if (slug != null) { if (slug != null) {
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo); await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
case ComplainedContent.recipe: case ComplainedContent.recipe:
if (slug != null) { if (slug != null) {
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo); await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
case ComplainedContent.profile: case ComplainedContent.profile:
if (slug != null) { if (slug != null) {
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo); await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
case ComplainedContent.posts: case ComplainedContent.posts:
if (id != null) { if (id != null) {
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo); await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
case ComplainedContent.comment: case ComplainedContent.comment:
if (id != null) { if (id != null) {
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo); await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
emit(state.copyWith(wasSentSuccessfully: true)); emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
return; return;
} }
} }
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString(), stackTrace: st); log(e.toString(), stackTrace: st);
} }
emit(state.copyWith(wasSentSuccessfully: false)); emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
} }
} }

View File

@@ -0,0 +1,51 @@
import 'package:freezed_annotation/freezed_annotation.dart';
enum AlfaScoringStatus {
@JsonValue('none')
none,
@JsonValue('beneficiary_creating')
beneficiaryCreating,
@JsonValue('beneficiary_failed')
beneficiaryFailed,
@JsonValue('scoring_pending')
scoringPending,
@JsonValue('scoring_complete')
scoringComplete,
@JsonValue('scoring_failed')
scoringFailed;
static AlfaScoringStatus fromString(String? value) {
switch (value) {
case 'beneficiary_creating':
return AlfaScoringStatus.beneficiaryCreating;
case 'beneficiary_failed':
return AlfaScoringStatus.beneficiaryFailed;
case 'scoring_pending':
return AlfaScoringStatus.scoringPending;
case 'scoring_complete':
return AlfaScoringStatus.scoringComplete;
case 'scoring_failed':
return AlfaScoringStatus.scoringFailed;
case 'none':
case null:
case '':
return AlfaScoringStatus.none;
default:
return AlfaScoringStatus.none;
}
}
bool get isProcessing =>
this == AlfaScoringStatus.beneficiaryCreating ||
this == AlfaScoringStatus.scoringPending;
bool get isFailed =>
this == AlfaScoringStatus.beneficiaryFailed ||
this == AlfaScoringStatus.scoringFailed;
bool get canRetry => isFailed;
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
bool get isTerminal => isComplete || isFailed;
}

View File

@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
const ConsultantCertPhotoModel._(); const ConsultantCertPhotoModel._();
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json); factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
_$ConsultantCertPhotoModelFromJson(json);
static const String visibleGroup = 'user_certs'; static const String visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs'; static const String hiddenGroup = 'hidden_user_certs';

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'consultant_profile_model.freezed.dart'; part 'consultant_profile_model.freezed.dart';
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
@freezed @freezed
abstract class ConsultantProfileModel with _$ConsultantProfileModel { abstract class ConsultantProfileModel with _$ConsultantProfileModel {
const ConsultantProfileModel._();
const factory ConsultantProfileModel({ const factory ConsultantProfileModel({
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation, @JsonKey(name: 'has_medical_education', includeIfNull: false)
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks, bool? hasMedicalEducation,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos, @JsonKey(name: 'social_links', includeIfNull: false)
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates, SocialLinksModel? socialLinks,
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
@JsonKey(name: 'completed_courses', includeIfNull: false)
List<CertificatesModel>? certificates,
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
@JsonKey(name: 'passport_series', includeIfNull: false)
String? passportSeries,
@JsonKey(name: 'passport_number', includeIfNull: false)
String? passportNumber,
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
String? passportIssueDate,
@JsonKey(name: 'passport_issuer', includeIfNull: false)
String? passportIssuer,
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
String? passportIssuerCode,
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
@JsonKey(name: 'registration_address', includeIfNull: false)
String? registrationAddress,
@JsonKey(name: 'residence_address', includeIfNull: false)
String? residenceAddress,
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
String? sbpBankMemberId,
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
bool? agreementAccepted,
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
bool? frdoAgreementAccepted,
}) = _ConsultantProfileModel; }) = _ConsultantProfileModel;
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json); factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
_$ConsultantProfileModelFromJson(json);
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
} }
@freezed @freezed
abstract class SocialLinksModel with _$SocialLinksModel { abstract class SocialLinksModel with _$SocialLinksModel {
const factory SocialLinksModel({ const factory SocialLinksModel({
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink, @JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink, @JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink, @JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink, @JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink, @JsonKey(name: 'personal_site_link', includeIfNull: false)
String? personalSiteLink,
}) = _SocialLinksModel; }) = _SocialLinksModel;
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json); factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
_$SocialLinksModelFromJson(json);
} }
@freezed @freezed
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
required String date, required String date,
}) = _CertificatesModel; }) = _CertificatesModel;
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json); factory CertificatesModel.fromJson(Map<String, Object?> json) =>
_$CertificatesModelFromJson(json);
} }

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
@JsonKey(name: 'rating_place') int? ratingPlace, @JsonKey(name: 'rating_place') int? ratingPlace,
@JsonKey(name: 'education_resource') required EducationModel educationModel, @JsonKey(name: 'education_resource') required EducationModel educationModel,
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos, @JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration, @JsonKey(name: 'canceled_moderation')
CanceledModerationModel? canceledModeration,
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
}) = _ConsultationRequestModel; }) = _ConsultationRequestModel;
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json); factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
_$ConsultationRequestModelFromJson(json);
}
@freezed
abstract class LegalEntityModel with _$LegalEntityModel {
const LegalEntityModel._();
const factory LegalEntityModel({
required int id,
required String type,
String? inn,
String? snils,
String? state,
@JsonKey(
name: 'alfa_scoring_status',
unknownEnumValue: AlfaScoringStatus.none,
defaultValue: AlfaScoringStatus.none,
)
@Default(AlfaScoringStatus.none)
AlfaScoringStatus alfaScoringStatus,
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
@JsonKey(name: 'last_name') String? lastName,
@JsonKey(name: 'first_name') String? firstName,
@JsonKey(name: 'middle_name') String? middleName,
@JsonKey(name: 'passport_series') String? passportSeries,
@JsonKey(name: 'passport_number') String? passportNumber,
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
@JsonKey(name: 'passport_issuer') String? passportIssuer,
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
@JsonKey(name: 'birth_date') String? birthDate,
@JsonKey(name: 'birth_place') String? birthPlace,
@JsonKey(name: 'registration_address') String? registrationAddress,
@JsonKey(name: 'residence_address') String? residenceAddress,
String? citizenship,
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
@JsonKey(name: 'sbp_phone') String? sbpPhone,
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
}) = _LegalEntityModel;
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
_$LegalEntityModelFromJson(json);
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
static String? normalizeApiDate(String? value) {
if (value == null || value.isEmpty) {
return null;
}
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
return value.substring(0, 10);
}
return value;
}
String? get normalizedPassportIssueDate =>
normalizeApiDate(passportIssueDate);
String? get normalizedBirthDate => normalizeApiDate(birthDate);
} }
@freezed @freezed
abstract class CertPhotosModel with _$CertPhotosModel { abstract class CertPhotosModel with _$CertPhotosModel {
const factory CertPhotosModel({ const factory CertPhotosModel({required String url}) = _CertPhotosModel;
required String url,
}) = _CertPhotosModel;
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json); factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
_$CertPhotosModelFromJson(json);
} }
enum ModerationStatus { enum ModerationStatus { approved, created, canceled }
approved,
created,
canceled
}
@freezed @freezed
abstract class CanceledModerationModel with _$CanceledModerationModel { abstract class CanceledModerationModel with _$CanceledModerationModel {
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
required List<String>? additional, required List<String>? additional,
}) = _CanceledModerationModel; }) = _CanceledModerationModel;
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json); factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
_$CanceledModerationModelFromJson(json);
} }

View File

@@ -0,0 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'sbp_bank_model.freezed.dart';
part 'sbp_bank_model.g.dart';
@freezed
abstract class SbpBankModel with _$SbpBankModel {
const factory SbpBankModel({
@JsonKey(name: 'bank_member_id') required String bankMemberId,
@JsonKey(name: 'bank_name') required String bankName,
@JsonKey(name: 'short_name') String? shortName,
@JsonKey(name: 'logo_url') String? logoUrl,
String? bic,
}) = _SbpBankModel;
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
_$SbpBankModelFromJson(json);
}

View File

@@ -1,42 +1,118 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> { class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository; final ConsultantRepository consultantRepository;
final Duration scoringPollInterval;
bool _update = false; bool _update = false;
String? _userSlug;
Timer? _scoringPollTimer;
RegisterConsultantCubit({required this.consultantRepository}) static const String individualPersonLegalType = 'individualPerson';
: super(
const RegisterConsultantState( RegisterConsultantCubit({
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true), required this.consultantRepository,
loading: true, this.scoringPollInterval = const Duration(seconds: 3),
acceptOffer: false, }) : super(
isUpdating: false, const RegisterConsultantState(
), consultant: ConsultantProfileModel(
); socialLinks: SocialLinksModel(),
hasMedicalEducation: true,
legalType: individualPersonLegalType,
),
loading: true,
acceptOffer: false,
acceptFrdoAgreement: false,
isUpdating: false,
),
);
String? get userSlug => _userSlug;
void setUserSlug(String? slug) {
if (slug != null && slug.isNotEmpty) {
_userSlug = slug;
}
}
Future<List<SbpBankModel>> _loadSbpBanks() async {
try {
return await consultantRepository.getSbpBanks();
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
return const <SbpBankModel>[];
}
}
List<SbpBankModel> searchSbpBanks(String query) {
final normalized = query.trim().toLowerCase();
if (normalized.isEmpty) {
return state.sbpBanks;
}
return state.sbpBanks.where((bank) {
final name = bank.bankName.toLowerCase();
final shortName = (bank.shortName ?? '').toLowerCase();
final bic = (bank.bic ?? '').toLowerCase();
return name.contains(normalized) ||
shortName.contains(normalized) ||
bic.contains(normalized);
}).toList();
}
SbpBankModel? findSbpBankById(String? bankMemberId) {
if (bankMemberId == null || bankMemberId.isEmpty) {
return null;
}
for (final bank in state.sbpBanks) {
if (bank.bankMemberId == bankMemberId) {
return bank;
}
}
return null;
}
Future<void> getMyRequestStatus(String slug) async { Future<void> getMyRequestStatus(String slug) async {
try { try {
_update = true; _update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); _userSlug = slug;
final myRequestStatus = await consultantRepository.getMyRequestStatus(
slug,
);
final certPhotos = await consultantRepository.getConsultantCertPhotos(); final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { List<CertificatesModel>? certificates = myRequestStatus
final course = courseMap.values.first; .educationModel
.completedCourses
?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(title: course.title, date: course.date); return CertificatesModel(title: course.title, date: course.date);
}).toList(); })
.toList();
String? sanitizeLink(String? link) { String? sanitizeLink(String? link) {
return (link != null && link.isNotEmpty) ? link : null; return (link != null && link.isNotEmpty) ? link : null;
} }
final legal = myRequestStatus.legalEntity;
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
emit( emit(
RegisterConsultantState( RegisterConsultantState(
consultant: ConsultantProfileModel( consultant: ConsultantProfileModel(
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), personalSiteLink: sanitizeLink(
myRequestStatus.educationModel.personalSiteLink,
),
), ),
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, hasMedicalEducation:
myRequestStatus.educationModel.hasMedicalEducation == 1,
legalType: legal?.type ?? individualPersonLegalType,
lastName: _emptyToNull(legal?.lastName),
firstName: _emptyToNull(legal?.firstName),
middleName: _emptyToNull(legal?.middleName),
inn: _emptyToNull(legal?.inn),
snils: _emptyToNull(legal?.snils),
passportSeries: _emptyToNull(legal?.passportSeries),
passportNumber: _emptyToNull(legal?.passportNumber),
passportIssueDate: legal?.normalizedPassportIssueDate,
passportIssuer: _emptyToNull(legal?.passportIssuer),
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
birthDate: legal?.normalizedBirthDate,
birthPlace: _emptyToNull(legal?.birthPlace),
registrationAddress: _emptyToNull(legal?.registrationAddress),
residenceAddress: _emptyToNull(legal?.residenceAddress),
citizenship: _emptyToNull(legal?.citizenship),
taxResidentUs: legal?.taxResidentUs ?? false,
foreignTaxResident: legal?.foreignTaxResident ?? false,
sbpPhone: _emptyToNull(legal?.sbpPhone),
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
), ),
acceptOffer: false, acceptOffer: false,
acceptFrdoAgreement: false,
loading: false, loading: false,
requestModel: myRequestStatus, requestModel: myRequestStatus,
certPhotos: certPhotos, certPhotos: certPhotos,
sbpBanks: sbpBanks,
isUpdating: _update, isUpdating: _update,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
fieldErrors: const <String, String>{},
error: null, error: null,
), ),
); );
if (scoringStatus.isProcessing) {
startScoringPoll();
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
loading: false,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
awaitingScoringOutcome: false,
),
);
}
}
String? _emptyToNull(String? value) {
if (value == null || value.trim().isEmpty) {
return null;
}
return value.trim();
}
Future<void> disableLoading({String? slug}) async {
try {
setUserSlug(slug);
final certPhotos = await consultantRepository.getConsultantCertPhotos();
final sbpBanks = await _loadSbpBanks();
emit(
state.copyWith(
loading: false,
certPhotos: certPhotos,
sbpBanks: sbpBanks,
error: null,
fieldErrors: const <String, String>{},
awaitingScoringOutcome: false,
consultant: state.consultant.copyWith(
legalType: individualPersonLegalType,
citizenship: state.consultant.citizenship,
taxResidentUs: state.consultant.taxResidentUs ?? false,
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
),
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(loading: false, error: e.toString())); final parsed = parseLaravelValidationErrors(e);
} emit(
} state.copyWith(
loading: false,
Future<void> disableLoading() async { error: parsed.summary,
try { fieldErrors: parsed.fieldErrors,
final certPhotos = await consultantRepository.getConsultantCertPhotos(); awaitingScoringOutcome: false,
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null)); ),
} catch (e, st) { );
await reportFrom(e, st, this);
log(e.toString());
emit(state.copyWith(loading: false, error: e.toString()));
} }
} }
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
if (slug != null) { if (slug != null) {
await getMyRequestStatus(slug); await getMyRequestStatus(slug);
} else { } else {
await disableLoading(); await disableLoading(slug: _userSlug);
} }
} }
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { void updateConsultantProfileModel(
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null)); ConsultantProfileModel consultant, [
bool? acceptOffer,
bool? acceptFrdoAgreement,
]) {
final nextFieldErrors = state.fieldErrors.isEmpty
? const <String, String>{}
: PassportDataValidator.validate(consultant);
emit(
state.copyWith(
consultant: consultant,
acceptOffer: acceptOffer ?? state.acceptOffer,
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
loading: false,
error: null,
fieldErrors: nextFieldErrors,
),
);
}
Map<String, String> validatePassportFields() {
final errors = PassportDataValidator.validate(state.consultant);
emit(state.copyWith(fieldErrors: errors, error: null));
return errors;
}
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
emit(
state.copyWith(
acceptOffer: acceptOffer ?? state.acceptOffer,
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
error: null,
),
);
}
bool get hasAcceptedRequiredAgreements {
if (_update) {
return state.acceptOffer;
}
return state.acceptOffer && state.acceptFrdoAgreement;
}
ConsultantProfileModel buildSubmitPayload() {
final withLegalType = state.consultant.copyWith(
legalType: individualPersonLegalType,
citizenship: state.consultant.citizenship,
taxResidentUs: state.consultant.taxResidentUs ?? false,
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
certificates: state.consultant.certificates
?.map(
(course) => CertificatesModel(
title: course.title,
date:
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
),
)
.toList(),
);
if (_update) {
return ConsultantProfileModel(
hasMedicalEducation: withLegalType.hasMedicalEducation,
socialLinks: withLegalType.socialLinks,
photos: withLegalType.photos,
certificates: withLegalType.certificates,
legalType: withLegalType.legalType,
lastName: withLegalType.lastName,
firstName: withLegalType.firstName,
middleName: withLegalType.middleName,
inn: withLegalType.inn,
snils: withLegalType.snils,
passportSeries: withLegalType.passportSeries,
passportNumber: withLegalType.passportNumber,
passportIssueDate: withLegalType.passportIssueDate,
passportIssuer: withLegalType.passportIssuer,
passportIssuerCode: withLegalType.passportIssuerCode,
birthDate: withLegalType.birthDate,
birthPlace: withLegalType.birthPlace,
registrationAddress: withLegalType.registrationAddress,
residenceAddress: withLegalType.residenceAddress,
citizenship: withLegalType.citizenship,
taxResidentUs: withLegalType.taxResidentUs,
foreignTaxResident: withLegalType.foreignTaxResident,
sbpPhone: withLegalType.sbpPhone,
sbpBankMemberId: withLegalType.sbpBankMemberId,
);
}
return withLegalType.copyWith(
agreementAccepted: state.acceptOffer,
frdoAgreementAccepted: state.acceptFrdoAgreement,
);
} }
Future<void> registerConsultant() async { Future<void> registerConsultant() async {
emit(state.copyWith(error: null)); final passportErrors = validatePassportFields();
if (passportErrors.isNotEmpty) {
return;
}
// Show scoring widget immediately while request / job are in progress.
emit(
state.copyWith(
error: null,
fieldErrors: const <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
awaitingScoringOutcome: true,
scoringRetryInProgress: false,
),
);
try { try {
if (state.newAddedPhotos.isNotEmpty) { if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos); await consultantRepository.storeConsultantCertPhotos(
files: state.newAddedPhotos,
);
} }
if (state.deletedPhotoIds.isNotEmpty) { if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) { for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id); await consultantRepository.deletePhoto(id);
} }
} }
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update); await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: _update,
);
_update = true;
emit(
state.copyWith(
isUpdating: true,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
scoringRetryInProgress: false,
awaitingScoringOutcome: true,
newAddedPhotos: const <File>[],
deletedPhotoIds: const <int>[],
fieldErrors: const <String, String>{},
error: null,
),
);
startScoringPoll();
} on DioException catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(error: e.toString())); emit(
state.copyWith(
error: e.toString(),
scoringRetryInProgress: false,
awaitingScoringOutcome: false,
isScoringPolling: false,
alfaScoringStatus: AlfaScoringStatus.none,
),
);
} }
} }
Future<void> retryScoring() async {
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
return;
}
final passportErrors = validatePassportFields();
if (passportErrors.isNotEmpty) {
return;
}
emit(
state.copyWith(
scoringRetryInProgress: true,
error: null,
fieldErrors: const <String, String>{},
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
awaitingScoringOutcome: true,
),
);
_update = true;
try {
await consultantRepository.registerConsultant(
consultant: buildSubmitPayload(),
update: true,
);
startScoringPoll();
emit(
state.copyWith(
scoringRetryInProgress: false,
isUpdating: true,
awaitingScoringOutcome: true,
),
);
} on DioException catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
final parsed = parseLaravelValidationErrors(e);
emit(
state.copyWith(
scoringRetryInProgress: false,
isScoringPolling: false,
awaitingScoringOutcome: false,
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
error: parsed.summary,
fieldErrors: parsed.fieldErrors,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
emit(
state.copyWith(
scoringRetryInProgress: false,
isScoringPolling: false,
awaitingScoringOutcome: false,
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
error: e.toString(),
),
);
}
}
void startScoringPoll() {
final slug = _userSlug;
if (slug == null || slug.isEmpty) {
emit(
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
);
return;
}
_stopScoringPoll();
emit(state.copyWith(isScoringPolling: true));
_scoringPollTimer = Timer.periodic(
scoringPollInterval,
(_) => _pollScoringStatus(slug),
);
unawaited(_pollScoringStatus(slug));
}
Future<void> _pollScoringStatus(String slug) async {
if (isClosed) {
return;
}
try {
final status = await consultantRepository.getMyRequestStatus(slug);
if (isClosed) {
return;
}
final scoringStatus =
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
// API stays on `none` until ProcessConsultantScoringJob starts
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
// UI and continue polling until a real terminal/processing status arrives.
if (scoringStatus == AlfaScoringStatus.none) {
emit(
state.copyWith(
requestModel: status,
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
isScoringPolling: true,
),
);
return;
}
final isTerminal = scoringStatus.isTerminal;
emit(
state.copyWith(
requestModel: status,
alfaScoringStatus: scoringStatus,
isScoringPolling: scoringStatus.isProcessing,
// Clear session flag on terminal so success sheet can fire once
// (listenWhen: previous.awaiting && !current.awaiting && complete).
awaitingScoringOutcome: isTerminal
? false
: state.awaitingScoringOutcome,
),
);
if (!scoringStatus.isProcessing) {
_stopScoringPoll();
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString());
// Keep polling on transient errors while scoring is in progress.
}
}
void _stopScoringPoll() {
_scoringPollTimer?.cancel();
_scoringPollTimer = null;
}
Future<void> addCertPhotos(List<File> files) async { Future<void> addCertPhotos(List<File> files) async {
if (files.isEmpty) { if (files.isEmpty) {
return; return;
} }
try { try {
final compressedFiles = await ImageCompressUtility.compressFilesList(files); final compressedFiles = await ImageCompressUtility.compressFilesList(
files,
);
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null)); emit(
state.copyWith(
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
error: null,
),
);
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
return; return;
} }
if (path != null) { if (path != null) {
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList())); emit(
state.copyWith(
newAddedPhotos: state.newAddedPhotos
.where((file) => file.uri.path != path)
.toList(),
),
);
} }
} }
@override
Future<void> close() {
_stopScoringPoll();
return super.close();
}
} }

View File

@@ -1,8 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'register_consultant_state.freezed.dart'; part 'register_consultant_state.freezed.dart';
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
const factory RegisterConsultantState({ const factory RegisterConsultantState({
required ConsultantProfileModel consultant, required ConsultantProfileModel consultant,
required bool acceptOffer, required bool acceptOffer,
@Default(false) bool acceptFrdoAgreement,
required bool loading, required bool loading,
required bool isUpdating, required bool isUpdating,
ConsultationRequestModel? requestModel, ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos, @Default(<ConsultantCertPhotoModel>[])
List<ConsultantCertPhotoModel> certPhotos,
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
@Default(<File>[]) List<File> newAddedPhotos, @Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds, @Default(<int>[]) List<int> deletedPhotoIds,
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
@Default(false) bool isScoringPolling,
@Default(false) bool scoringRetryInProgress,
/// True only after successful register/retry in this session until scoring is terminal.
@Default(false) bool awaitingScoringOutcome,
@Default(<String, String>{}) Map<String, String> fieldErrors,
String? error, String? error,
}) = _RegisterConsultantState; }) = _RegisterConsultantState;
} }

View File

@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
const AdditionalSettingsScreen({super.key}); const AdditionalSettingsScreen({super.key});
@override @override
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState(); State<AdditionalSettingsScreen> createState() =>
_AdditionalSettingsScreenState();
} }
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> { class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
@override @override
void initState() { void initState() {
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) { if (context
.read<RegisterConsultantCubit>()
.state
.consultant
.hasMedicalEducation !=
null) {
haveMedicalDegree = context haveMedicalDegree = context
.read<RegisterConsultantCubit>() .read<RegisterConsultantCubit>()
.state .state
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
value: publishDiplomasAndCertificatesInProfile, value: publishDiplomasAndCertificatesInProfile,
onTap: () { onTap: () {
setState(() { setState(() {
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile; publishDiplomasAndCertificatesInProfile =
!publishDiplomasAndCertificatesInProfile;
}); });
}, },
), ),
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
setState(() { setState(() {
haveMedicalDegree = !haveMedicalDegree; haveMedicalDegree = !haveMedicalDegree;
}); });
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context
context.read<RegisterConsultantCubit>().state.consultant.copyWith( .read<RegisterConsultantCubit>()
hasMedicalEducation: haveMedicalDegree, .updateConsultantProfileModel(
), context
); .read<RegisterConsultantCubit>()
.state
.consultant
.copyWith(hasMedicalEducation: haveMedicalDegree),
);
}, },
), ),
], ],

View File

@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class BecomeConsultantScreen extends StatefulWidget { class BecomeConsultantScreen extends StatefulWidget {
const BecomeConsultantScreen({super.key}); final String? slug;
const BecomeConsultantScreen({super.key, this.slug});
@override @override
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState(); State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
title: const Text('Стать консультантом'), title: const Text('Стать консультантом'),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
centerTitle: false, centerTitle: false,
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), leading: IconButton(
onPressed: () => GoRouter.of(context).pop(),
icon: const Icon(Icons.close),
),
), ),
body: const Padding( body: const Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
InkWell _startRegistrationButton() { InkWell _startRegistrationButton() {
return InkWell( return InkWell(
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), onTap: () => GoRouter.of(context).pushReplacementNamed(
'stepperBecomeConsultantScreen',
extra: {'slug': widget.slug, 'isNewRegistration': true},
),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Ink( child: Ink(
height: 52, height: 52,
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center( child: const Center(
child: Text( child: Text(
'Начать регистрацию', 'Начать регистрацию',
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), style: TextStyle(
color: Colors.white,
height: 1,
fontWeight: FontWeight.w600,
fontSize: 18,
),
), ),
), ),
), ),

View File

@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
class CertificatesAndDiplomasScreen extends StatefulWidget { class CertificatesAndDiplomasScreen extends StatefulWidget {
const CertificatesAndDiplomasScreen({super.key}); const CertificatesAndDiplomasScreen({super.key});
@override @override
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState(); State<CertificatesAndDiplomasScreen> createState() =>
_CertificatesAndDiplomasScreenState();
} }
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> { class _CertificatesAndDiplomasScreenState
extends State<CertificatesAndDiplomasScreen> {
final scrollController = ScrollController(); final scrollController = ScrollController();
List<DiplomaEntity> diplomasList = [ List<DiplomaEntity> diplomasList = [
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) { if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
null) {
diplomasList = [ diplomasList = [
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map( ...context
.read<RegisterConsultantCubit>()
.state
.consultant
.certificates!
.map(
(e) => DiplomaEntity( (e) => DiplomaEntity(
date: DateTime.tryParse(e.date), date: DateTime.tryParse(e.date),
textEditingController: TextEditingController(text: e.title), textEditingController: TextEditingController(text: e.title),
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
ListView.separated( ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]), itemBuilder: (context, index) =>
_diplomaInfo(diploma: diplomasList[index]),
separatorBuilder: (context, index) => Padding( separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: _divider(context), child: _divider(context),
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
); );
} }
Column _diplomaInfo({ Column _diplomaInfo({required DiplomaEntity diploma}) {
required DiplomaEntity diploma,
}) {
return Column( return Column(
children: [ children: [
CustomTextFormField( CustomTextFormField(
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( SizedBox(
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32), width:
MediaQuery.of(context).size.width -
(diploma.enableDeleteButton ? 92 : 32),
child: ListTile( child: ListTile(
onTap: onTapCalendar(context, diploma), onTap: onTapCalendar(context, diploma),
trailing: Icon( trailing: Icon(
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
title: Text( title: Text(
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи', diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
style: TextStyle( style: TextStyle(
color: diploma.date == null ? Colors.grey.shade400 : Colors.black, color: diploma.date == null
? Colors.grey.shade400
: Colors.black,
), ),
), ),
tileColor: const Color(0xFFF6F6F6), tileColor: const Color(0xFFF6F6F6),
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: const Icon( child: const Icon(Icons.delete_outline_rounded),
Icons.delete_outline_rounded,
),
), ),
), ),
], ],
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
bottom: MediaQuery.of(context).viewInsets.bottom, bottom: MediaQuery.of(context).viewInsets.bottom,
), ),
color: CupertinoColors.systemBackground.resolveFrom(context), color: CupertinoColors.systemBackground.resolveFrom(context),
child: SafeArea( child: SafeArea(top: false, child: child),
top: false,
child: child,
),
), ),
); );
} }
void checkLast() { void checkLast() {
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) { if (diplomasList.last.textEditingController.text.isNotEmpty ||
diplomasList.last.date != null) {
setState(() { setState(() {
diplomasList.add( diplomasList.add(
DiplomaEntity( DiplomaEntity(
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
void _saveState() { void _saveState() {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith( context.read<RegisterConsultantCubit>().state.consultant.copyWith(
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist( certificates: diplomasList
0, .map(
diplomasList.length - 1, (e) => CertificatesModel(
), title: e.textEditingController.text,
date: e.date == null
? ''
: DateFormat('yyyy-MM-dd').format(e.date!),
), ),
); )
.toList()
.sublist(0, diplomasList.length - 1),
),
);
} }
} }

View File

@@ -1,8 +1,11 @@
import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
} }
class _LastStepScreenState extends State<LastStepScreen> { class _LastStepScreenState extends State<LastStepScreen> {
bool checkboxState = false; static const _frdoUrl =
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
late final TapGestureRecognizer _frdoTapRecognizer;
@override
void initState() {
super.initState();
_frdoTapRecognizer = TapGestureRecognizer()
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
}
@override
void dispose() {
_frdoTapRecognizer.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cubit = context.watch<RegisterConsultantCubit>();
final state = cubit.state;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 80, top: 16), padding: const EdgeInsets.only(bottom: 80, top: 16),
child: Column( child: Column(
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), child: Text(
'Последний шаг',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
), ),
if (state.awaitingScoringOutcome ||
state.isScoringPolling ||
state.alfaScoringStatus !=
AlfaScoringStatus.none) ...[
const SizedBox(height: 16),
const ScoringStatusWidget(),
],
if (state.requestModel?.canceledModeration != null) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0x33FFCC00),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.requestModel!.canceledModeration!.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
if (state.requestModel!.canceledModeration!.additional !=
null) ...[
const SizedBox(height: 8),
for (final item
in state
.requestModel!
.canceledModeration!
.additional!) ...[
Text(
'• $item',
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
],
],
),
),
),
],
const SizedBox(height: 16), const SizedBox(height: 16),
CheckboxListTile( CheckboxListTile(
value: checkboxState, value: state.acceptOffer,
title: Text( title: Text(
hidePlans hidePlans
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных' ? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
: 'Я ознакомлен и принимаю договор публичной офёрты', : 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
style: const TextStyle(fontSize: 18, height: 1), style: const TextStyle(fontSize: 18, height: 1.2),
), ),
onChanged: (_) { onChanged: (value) {
setState(() { cubit.updateAgreements(acceptOffer: value ?? false);
checkboxState = !checkboxState;
});
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
}, },
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
contentPadding: const EdgeInsets.symmetric(horizontal: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16),
controlAffinity: ListTileControlAffinity.leading,
),
if (!state.isUpdating) ...[
CheckboxListTile(
value: state.acceptFrdoAgreement,
title: Text.rich(
TextSpan(
style: const TextStyle(
fontSize: 18,
height: 1.2,
color: Color(0xFF323F4B),
),
children: [
const TextSpan(
text:
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
),
TextSpan(
text: 'ФРДО',
style: TextStyle(
color: appTheme.blueA400,
decoration: TextDecoration.underline,
),
recognizer: _frdoTapRecognizer,
),
],
),
),
onChanged: (value) {
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
},
activeColor: appTheme.blueA400,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
controlAffinity: ListTileControlAffinity.leading,
),
],
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
), ),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
child: TextButton( child: TextButton(
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')), onPressed: () async => await launchUrl(
Uri.parse(
hidePlans
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
),
),
child: Text( child: Text(
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты', hidePlans
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline), ? 'Политика конфиденциальности и пользовательское соглашение'
: 'Договор оферты',
style: const TextStyle(
height: 1,
fontSize: 16,
color: Color(0xFF323F4B),
decoration: TextDecoration.underline,
),
), ),
), ),
), ),
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
); );
} }
} }

View File

@@ -0,0 +1,608 @@
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_text_form_field.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
class PassportDataScreen extends StatefulWidget {
const PassportDataScreen({super.key});
@override
State<PassportDataScreen> createState() => _PassportDataScreenState();
}
class _PassportDataScreenState extends State<PassportDataScreen> {
late final TextEditingController lastNameController;
late final TextEditingController firstNameController;
late final TextEditingController middleNameController;
late final TextEditingController innController;
late final TextEditingController snilsController;
late final TextEditingController seriesController;
late final TextEditingController numberController;
late final TextEditingController issueDateController;
late final TextEditingController issuerController;
late final TextEditingController issuerCodeController;
late final TextEditingController birthDateController;
late final TextEditingController birthPlaceController;
late final TextEditingController citizenshipController;
late final TextEditingController registrationAddressController;
late final TextEditingController residenceAddressController;
late final TextEditingController sbpPhoneController;
late final TextEditingController sbpBankController;
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
bool taxResidentUs = false;
bool foreignTaxResident = false;
String? selectedSbpBankMemberId;
String? selectedSbpBankName;
final snilsMask = MaskTextInputFormatter(
mask: '###-###-### ##',
filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
final dateMaskIssue = MaskTextInputFormatter(
mask: '##.##.####',
filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
final dateMaskBirth = MaskTextInputFormatter(
mask: '##.##.####',
filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
final issuerCodeMask = MaskTextInputFormatter(
mask: '###-###',
filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
final phoneMask = MaskTextInputFormatter(
mask: '+7 (###) ### ## ##',
filter: {'#': RegExp(r'[0-9]')},
type: MaskAutoCompletionType.lazy,
);
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
);
@override
void initState() {
super.initState();
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
lastNameController = TextEditingController(text: consultant.lastName ?? '');
firstNameController = TextEditingController(
text: consultant.firstName ?? '',
);
middleNameController = TextEditingController(
text: consultant.middleName ?? '',
);
innController = TextEditingController(text: consultant.inn ?? '');
snilsController = TextEditingController(text: consultant.snils ?? '');
seriesController = TextEditingController(
text: consultant.passportSeries ?? '',
);
numberController = TextEditingController(
text: consultant.passportNumber ?? '',
);
issueDateController = TextEditingController(
text: _apiDateToUi(consultant.passportIssueDate),
);
issuerController = TextEditingController(
text: consultant.passportIssuer ?? '',
);
issuerCodeController = TextEditingController(
text: consultant.passportIssuerCode ?? '',
);
birthDateController = TextEditingController(
text: _apiDateToUi(consultant.birthDate),
);
birthPlaceController = TextEditingController(
text: consultant.birthPlace ?? '',
);
citizenshipController = TextEditingController(text: consultant.citizenship);
registrationAddressController = TextEditingController(
text: consultant.registrationAddress ?? '',
);
residenceAddressController = TextEditingController(
text: consultant.residenceAddress ?? '',
);
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
selectedSbpBankMemberId = consultant.sbpBankMemberId;
final selectedBank = context
.read<RegisterConsultantCubit>()
.findSbpBankById(consultant.sbpBankMemberId);
selectedSbpBankName = selectedBank?.bankName;
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
taxResidentUs = consultant.taxResidentUs ?? false;
foreignTaxResident = consultant.foreignTaxResident ?? false;
}
void _hydrateSbpBankNameIfNeeded() {
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
return;
}
if (sbpBankController.text.trim().isNotEmpty) {
return;
}
final selectedBank = context
.read<RegisterConsultantCubit>()
.findSbpBankById(selectedSbpBankMemberId);
if (selectedBank == null) {
return;
}
selectedSbpBankName = selectedBank.bankName;
sbpBankController.text = selectedBank.bankName;
}
@override
void dispose() {
lastNameController.dispose();
firstNameController.dispose();
middleNameController.dispose();
innController.dispose();
snilsController.dispose();
seriesController.dispose();
numberController.dispose();
issueDateController.dispose();
issuerController.dispose();
issuerCodeController.dispose();
birthDateController.dispose();
birthPlaceController.dispose();
citizenshipController.dispose();
registrationAddressController.dispose();
residenceAddressController.dispose();
sbpPhoneController.dispose();
sbpBankController.dispose();
sbpBankSuggestionsController.dispose();
super.dispose();
}
String _apiDateToUi(String? apiDate) {
if (apiDate == null || apiDate.isEmpty) {
return '';
}
final parts = apiDate.split('-');
if (parts.length != 3) {
return apiDate;
}
return '${parts[2]}.${parts[1]}.${parts[0]}';
}
String? _uiDateToApi(String uiDate) {
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
if (digits.length != 8) {
return null;
}
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
}
String? _uiPhoneToApi(String uiPhone) {
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
if (digits.isEmpty) {
return null;
}
if (digits.length == 11 && digits.startsWith('7')) {
return '+$digits';
}
if (digits.length == 10) {
return '+7$digits';
}
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
}
void _syncToCubit() {
final cubit = context.read<RegisterConsultantCubit>();
final current = cubit.state.consultant;
cubit.updateConsultantProfileModel(
current.copyWith(
legalType: RegisterConsultantCubit.individualPersonLegalType,
lastName: lastNameController.text.trim().isEmpty
? null
: lastNameController.text.trim(),
firstName: firstNameController.text.trim().isEmpty
? null
: firstNameController.text.trim(),
middleName: middleNameController.text.trim().isEmpty
? null
: middleNameController.text.trim(),
inn: innController.text.trim().isEmpty
? null
: innController.text.trim(),
snils: snilsController.text.trim().isEmpty
? null
: snilsController.text.trim(),
passportSeries: seriesController.text.trim().isEmpty
? null
: seriesController.text.trim(),
passportNumber: numberController.text.trim().isEmpty
? null
: numberController.text.trim(),
passportIssueDate: _uiDateToApi(issueDateController.text),
passportIssuer: issuerController.text.trim().isEmpty
? null
: issuerController.text.trim(),
passportIssuerCode: issuerCodeController.text.trim().isEmpty
? null
: issuerCodeController.text.trim(),
birthDate: _uiDateToApi(birthDateController.text),
birthPlace: birthPlaceController.text.trim().isEmpty
? null
: birthPlaceController.text.trim(),
citizenship: citizenshipController.text.trim().isEmpty
? 'RU'
: citizenshipController.text.trim().toUpperCase(),
registrationAddress: registrationAddressController.text.trim().isEmpty
? null
: registrationAddressController.text.trim(),
residenceAddress: residenceAddressController.text.trim().isEmpty
? null
: residenceAddressController.text.trim(),
taxResidentUs: taxResidentUs,
foreignTaxResident: foreignTaxResident,
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
sbpBankMemberId: selectedSbpBankMemberId,
),
cubit.state.acceptOffer,
cubit.state.acceptFrdoAgreement,
);
}
void _selectSbpBank(SbpBankModel bank) {
setState(() {
selectedSbpBankMemberId = bank.bankMemberId;
selectedSbpBankName = bank.bankName;
sbpBankController.text = bank.bankName;
});
_syncToCubit();
}
void _clearSbpBankIfNeeded(String value) {
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
setState(() {
selectedSbpBankMemberId = null;
selectedSbpBankName = null;
});
_syncToCubit();
}
}
@override
Widget build(BuildContext context) {
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
buildWhen: (previous, current) =>
previous.fieldErrors != current.fieldErrors,
builder: (context, state) {
final errors = state.fieldErrors;
return SingleChildScrollView(
padding: const EdgeInsets.only(
bottom: 80,
right: 16,
left: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PassportScreenTitle(title: 'Паспортные данные'),
const PassportSectionTitle(
title: 'Персональные данные (как в паспорте)',
),
PassportFieldWithError(
error: errors['last_name'],
child: CustomTextFormField(
hintText: 'Фамилия',
controller: lastNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('last_name'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['first_name'],
child: CustomTextFormField(
hintText: 'Имя',
controller: firstNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('first_name'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['middle_name'],
child: CustomTextFormField(
hintText: 'Отчество',
controller: middleNameController,
maxLength: 50,
inputFormatters: [_alfaPersonNameFormatter],
hasError: errors.containsKey('middle_name'),
onChanged: (_) => _syncToCubit(),
),
),
const PassportSectionTitle(title: 'Идентификация'),
PassportFieldWithError(
error: errors['inn'],
child: CustomTextFormField(
hintText: 'ИНН (12 цифр)',
controller: innController,
keyboardType: TextInputType.number,
maxLength: 12,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
hasError: errors.containsKey('inn'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['snils'],
child: CustomTextFormField(
hintText: 'СНИЛС',
controller: snilsController,
keyboardType: TextInputType.number,
inputFormatters: [snilsMask],
hasError: errors.containsKey('snils'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['birth_date'],
child: CustomTextFormField(
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
controller: birthDateController,
keyboardType: TextInputType.number,
inputFormatters: [dateMaskBirth],
hasError: errors.containsKey('birth_date'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['birth_place'],
child: CustomTextFormField(
hintText: 'Место рождения',
controller: birthPlaceController,
hasError: errors.containsKey('birth_place'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['citizenship'],
child: CustomTextFormField(
hintText: 'Гражданство (ISO, например RU)',
controller: citizenshipController,
maxLength: 2,
hasError: errors.containsKey('citizenship'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['registration_address'],
child: CustomTextFormField(
hintText: 'Адрес регистрации',
controller: registrationAddressController,
hasError: errors.containsKey('registration_address'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['residence_address'],
child: CustomTextFormField(
hintText: 'Адрес проживания',
controller: residenceAddressController,
hasError: errors.containsKey('residence_address'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 8),
CheckboxListTile(
value: taxResidentUs,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: appTheme.blueA400,
title: Text(
'Я являюсь налоговым резидентом США',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.black,
),
),
onChanged: (value) {
setState(() => taxResidentUs = value ?? false);
_syncToCubit();
},
),
CheckboxListTile(
value: foreignTaxResident,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
activeColor: appTheme.blueA400,
title: Text(
'Я являюсь иностранным налоговым резидентом',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.black,
),
),
onChanged: (value) {
setState(() => foreignTaxResident = value ?? false);
_syncToCubit();
},
),
const PassportSectionTitle(title: 'Паспорт'),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: PassportFieldWithError(
error: errors['passport_series'],
child: CustomTextFormField(
hintText: 'Серия',
controller: seriesController,
keyboardType: TextInputType.number,
maxLength: 4,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
hasError: errors.containsKey('passport_series'),
onChanged: (_) => _syncToCubit(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: PassportFieldWithError(
error: errors['passport_number'],
child: CustomTextFormField(
hintText: 'Номер',
controller: numberController,
keyboardType: TextInputType.number,
maxLength: 6,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
hasError: errors.containsKey('passport_number'),
onChanged: (_) => _syncToCubit(),
),
),
),
],
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issue_date'],
child: CustomTextFormField(
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
controller: issueDateController,
keyboardType: TextInputType.number,
inputFormatters: [dateMaskIssue],
hasError: errors.containsKey('passport_issue_date'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issuer_code'],
child: CustomTextFormField(
hintText: 'Код подразделения',
controller: issuerCodeController,
keyboardType: TextInputType.number,
inputFormatters: [issuerCodeMask],
hasError: errors.containsKey('passport_issuer_code'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['passport_issuer'],
child: CustomTextFormField(
hintText: 'Кем выдан',
controller: issuerController,
hasError: errors.containsKey('passport_issuer'),
onChanged: (_) => _syncToCubit(),
),
),
const PassportSectionTitle(title: 'Реквизиты СБП'),
PassportFieldWithError(
error: errors['sbp_phone'],
child: CustomTextFormField(
hintText: 'Телефон для выплат через СБП',
controller: sbpPhoneController,
keyboardType: TextInputType.phone,
inputFormatters: [phoneMask],
hasError: errors.containsKey('sbp_phone'),
onChanged: (_) => _syncToCubit(),
),
),
const SizedBox(height: 12),
PassportFieldWithError(
error: errors['sbp_bank_member_id'],
child: TypeAheadField<SbpBankModel>(
debounceDuration: const Duration(milliseconds: 250),
controller: sbpBankController,
suggestionsController: sbpBankSuggestionsController,
builder: (context, controller, focusNode) =>
CustomTextFormField(
controller: controller,
focusNode: focusNode,
hintText: 'Банк получатель (поиск)',
hasError: errors.containsKey('sbp_bank_member_id'),
onChanged: _clearSbpBankIfNeeded,
),
suggestionsCallback: (query) async {
return context
.read<RegisterConsultantCubit>()
.searchSbpBanks(query);
},
itemBuilder: (context, bank) {
return ListTile(
dense: true,
title: Text(
bank.bankName,
style: TextStyles.textMMedium.copyWith(
color: appTheme.black900,
),
),
subtitle:
bank.shortName == null || bank.shortName!.isEmpty
? null
: Text(
bank.shortName!,
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
);
},
decorationBuilder: (context, child) => Material(
type: MaterialType.card,
elevation: 4,
borderRadius: BorderRadius.circular(8),
color: appTheme.whiteA700,
child: child,
),
emptyBuilder: (context) => Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Ничего не найдено',
style: TextStyles.textSMedium.copyWith(
color: appTheme.grey7B,
),
),
),
onSelected: _selectSbpBank,
),
),
const SizedBox(height: 16),
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
const SizedBox(height: 16),
Text(
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
style: TextStyles.textSMedium.copyWith(
color: appTheme.blueA400,
),
),
],
),
);
},
),
);
}
}

View File

@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), const Text(
'Загрузите фото или сканы дипломов и сертификатов',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) { builder: (context, state) {
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList(); final photos = state.certPhotos
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
.toList();
final addedPhotos = state.newAddedPhotos; final addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length; final totalPhotosCount = photos.length + addedPhotos.length;
return Column( return Column(
children: [ children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)], if (isUploading) ...[
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
crossAxisCount: 3, const SliverGridDelegateWithFixedCrossAxisCount(
childAspectRatio: 3 / 4, crossAxisCount: 3,
crossAxisSpacing: 8, childAspectRatio: 3 / 4,
mainAxisSpacing: 8, crossAxisSpacing: 8,
), mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
if (index == totalPhotosCount) { if (index == totalPhotosCount) {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(), onTap: () => _pickAndUploadPhotos(),
child: Ink( child: Ink(
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)), decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
),
child: Icon(Icons.add, color: appTheme.blueA400), child: Icon(Icons.add, color: appTheme.blueA400),
), ),
); );
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
return _LocalCertPhotoCard( return _LocalCertPhotoCard(
photo: localPhoto, photo: localPhoto,
allPhotos: addedPhotos, allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path), onRemove: () =>
_removePhoto(path: localPhoto.uri.path),
); );
} }
} }
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
}); });
try { try {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList()); await context.read<RegisterConsultantCubit>().addCertPhotos(
newImages.map((file) => File(file.path)).toList(),
);
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
final List<ConsultantCertPhotoModel> allPhotos; final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove; final VoidCallback onRemove;
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); const _CertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
onTap: () { onTap: () {
context.pushTransparentRoute( context.pushTransparentRoute(
rootNavigator: true, rootNavigator: true,
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)), PhotoViewScreen(
imageUrls: allPhotos.map((e) => e.url).toList(),
isLocal: false,
index: allPhotos.indexOf(photo),
),
); );
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
Ink( Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)), borderRadius: const BorderRadius.all(Radius.circular(20)),
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)), image: DecorationImage(
fit: BoxFit.cover,
image: NetworkImage(photo.url),
),
), ),
), ),
Positioned( Positioned(
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
), ),
), ),
), ),
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
final List<File> allPhotos; final List<File> allPhotos;
final VoidCallback onRemove; final VoidCallback onRemove;
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove}); const _LocalCertPhotoCard({
required this.photo,
required this.allPhotos,
required this.onRemove,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
child: Ink( child: Ink(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle), decoration: const BoxDecoration(
child: const Icon(size: 20, Icons.close, color: Colors.black), color: Color(0xFFF1F2F6),
shape: BoxShape.circle,
),
child: const Icon(
size: 20,
Icons.close,
color: Colors.black,
),
), ),
), ),
), ),

View File

@@ -1,5 +1,7 @@
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RegistrationAsScreen extends StatefulWidget { class RegistrationAsScreen extends StatefulWidget {
const RegistrationAsScreen({super.key}); const RegistrationAsScreen({super.key});
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
enum RegistrationAsType { physical, selfBusy, iP, ooo } enum RegistrationAsType { physical, selfBusy, iP, ooo }
class _RegistrationAsScreenState extends State<RegistrationAsScreen> { class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
RegistrationAsType registrationAsType = RegistrationAsType.physical; static const RegistrationAsType registrationAsType =
RegistrationAsType.physical;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final cubit = context.read<RegisterConsultantCubit>();
cubit.updateConsultantProfileModel(
cubit.state.consultant.copyWith(
legalType: RegisterConsultantCubit.individualPersonLegalType,
),
cubit.state.acceptOffer,
cubit.state.acceptFrdoAgreement,
);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
children: [ children: [
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)), child: Text(
'Регистрация в качестве',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Column( Column(
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: (_) {},
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)), child: Text(
'Физического лица',
style: TextStyle(fontSize: 18, height: 1),
),
), ),
), ),
], ],
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)), child: Text(
'Самозанятого',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)), child: Text(
'Индивидуального предпринимателя',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
groupValue: registrationAsType, groupValue: registrationAsType,
activeColor: appTheme.blueA400, activeColor: appTheme.blueA400,
// ignore: deprecated_member_use // ignore: deprecated_member_use
onChanged: (value) { onChanged: null,
setState(() {
registrationAsType = value!;
});
},
), ),
const Expanded( const Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8), padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)), child: Text(
'Общества с ограниченной ответственностью',
style: TextStyle(
fontSize: 18,
height: 1,
color: Colors.black38,
),
),
), ),
), ),
], ],
), ),
], ],
), ),
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)), Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: _divider(context),
),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 16), padding: EdgeInsets.symmetric(horizontal: 16),
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'), child: Text(
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
),
), ),
], ],
), ),
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
return Container( return Container(
height: 0.4, height: 0.4,
width: MediaQuery.of(context).size.width - 32, width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
); );
} }
} }

View File

@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
var maskFormatter = MaskTextInputFormatter( var maskFormatter = MaskTextInputFormatter(
mask: 'https://########################################', mask: 'https://########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
var maskTgFormatter = MaskTextInputFormatter( var maskTgFormatter = MaskTextInputFormatter(
mask: 'https://tg.me/########################################', mask: 'https://tg.me/########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
var maskVkFormatter = MaskTextInputFormatter( var maskVkFormatter = MaskTextInputFormatter(
mask: 'https://vk.com/########################################', mask: 'https://vk.com/########################################',
filter: { filter: {'#': RegExp(r"[a-zA-Z. ]+")},
'#': RegExp(r"[a-zA-Z. ]+"),
},
type: MaskAutoCompletionType.lazy, type: MaskAutoCompletionType.lazy,
); );
@override @override
void initState() { void initState() {
super.initState(); super.initState();
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? ''; vkController.text =
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? ''; context
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? ''; .read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.vkLink ??
'';
telegramController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.tgLink ??
'';
webSiteController.text =
context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks!
.personalSiteLink ??
'';
} }
@override @override
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/vk.svg'),
'assets/icons/vk.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/telegram.svg'),
'assets/icons/telegram.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
color: const Color(0xFFF6F6F6), color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset( child: SvgPicture.asset('assets/icons/web.svg'),
'assets/icons/web.svg',
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
String? personalSiteLink, String? personalSiteLink,
}) { }) {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel( context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith( context.read<RegisterConsultantCubit>().state.consultant.copyWith(
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith( socialLinks: context
tgLink: tgLink == null .read<RegisterConsultantCubit>()
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink .state
: tgLink.isEmpty .consultant
? null .socialLinks
: tgLink, ?.copyWith(
vkLink: vkLink == null tgLink: tgLink == null
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink ? context
: vkLink.isEmpty .read<RegisterConsultantCubit>()
? null .state
: vkLink, .consultant
personalSiteLink: personalSiteLink == null .socialLinks
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink ?.tgLink
: personalSiteLink.isEmpty : tgLink.isEmpty
? null ? null
: personalSiteLink, : tgLink,
), vkLink: vkLink == null
), ? context
); .read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.vkLink
: vkLink.isEmpty
? null
: vkLink,
personalSiteLink: personalSiteLink == null
? context
.read<RegisterConsultantCubit>()
.state
.consultant
.socialLinks
?.personalSiteLink
: personalSiteLink.isEmpty
? null
: personalSiteLink,
),
),
);
} }
} }

View File

@@ -1,11 +1,13 @@
import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget { class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug; final String? slug;
final bool? isCanceled; final bool? isCanceled;
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled}); final bool isNewRegistration;
const StepperBecomeConsultantScreen({
super.key,
this.slug,
this.isCanceled,
this.isNewRegistration = false,
});
@override @override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState(); State<StepperBecomeConsultantScreen> createState() =>
_StepperBecomeConsultantScreenState();
} }
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> { class _StepperBecomeConsultantScreenState
double progressValue = 1 / 6; extends State<StepperBecomeConsultantScreen> {
double progressValue = 0;
int step = 1; int step = 1;
final pageController = PageController(); final pageController = PageController();
late final RegisterConsultantCubit registrationConsultantCubit; late final RegisterConsultantCubit registrationConsultantCubit;
bool isLoading = false; bool isLoading = false;
bool _successSheetShown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.slug == null) { if (widget.isNewRegistration || widget.slug == null) {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading(); registrationConsultantCubit = sl<RegisterConsultantCubit>()
..disableLoading(slug: widget.slug);
} else { } else {
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!); registrationConsultantCubit = sl<RegisterConsultantCubit>()
..getMyRequestStatus(widget.slug!);
} }
} }
static const int totalSteps = 7;
List<Widget> _pages() {
return const [
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
PassportDataScreen(),
LastStepScreen(),
];
}
Future<void> _showSuccessSheet(BuildContext context) async {
if (_successSheetShown) {
return;
}
_successSheetShown = true;
await showModalBottomSheet(
useRootNavigator: true,
showDragHandle: true,
context: context,
builder: (context) => Container(
padding: const EdgeInsets.all(16),
width: MediaQuery.of(context).size.width,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
height: 1,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Lottie.asset(
'assets/lottie_animations/success.json',
width: 160,
height: 160,
),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<RegisterConsultantCubit>( return BlocProvider<RegisterConsultantCubit>(
create: (context) => registrationConsultantCubit, create: (context) => registrationConsultantCubit,
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>( child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) { listenWhen: (previous, current) =>
if (state.loading) { previous.fieldErrors.isEmpty &&
return const Scaffold(body: Loader()); current.fieldErrors.isNotEmpty &&
} current.error == null,
if (state.error != null) { listener: (context, state) {
pageController.jumpToPage(5);
},
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
listenWhen: (previous, current) =>
previous.awaitingScoringOutcome &&
!current.awaitingScoringOutcome &&
current.alfaScoringStatus.isComplete,
listener: (context, state) {
_showSuccessSheet(context);
},
builder: (context, state) {
if (state.loading) {
return const Scaffold(body: Loader());
}
if (state.error != null &&
state.requestModel == null &&
widget.slug != null &&
state.alfaScoringStatus == AlfaScoringStatus.none) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
leading: BackButton(
onPressed: () {
if (context.canPop()) {
GoRouter.of(context).pop();
return;
}
GoRouter.of(context).go('/feed-screen');
},
),
title: Text(
'',
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.black,
),
),
),
body: DataErrorWidget(
onReload: () => context
.read<RegisterConsultantCubit>()
.reloadData(widget.slug),
),
);
}
if (progressValue == 0) {
progressValue = 1 / totalSteps;
}
final submitDisabled =
isLoading ||
state.isScoringPolling ||
state.scoringRetryInProgress;
return Scaffold( return Scaffold(
appBar: AppBar( floatingActionButtonLocation:
centerTitle: true, FloatingActionButtonLocation.centerFloat,
leading: BackButton(
onPressed: () {
if (context.canPop()) {
GoRouter.of(context).pop();
return;
}
GoRouter.of(context).go('/feed-screen');
},
),
title: Text(
'',
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
),
body: DataErrorWidget(
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
),
);
}
return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: Row( floatingActionButton: Row(
children: [ children: [
Container( Container(
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
focusElevation: 0, focusElevation: 0,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: const Color(0xFFEAF2FD), backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async { onPressed: () async {
setState(() { setState(() {
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
}, },
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400), label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
), ),
), ),
if (widget.isCanceled == true) if (widget.isCanceled == true && step != totalSteps)
InkWell( InkWell(
onTap: () { onTap: () {
final canceledModeration =
state.requestModel?.canceledModeration;
if (canceledModeration == null) return;
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(state.requestModel!.canceledModeration!.title), title: Text(canceledModeration.title),
content: Column( content: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[ if (canceledModeration.additional != null)
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'), for (final item
], in canceledModeration.additional!) ...[
Text('• $item'),
],
], ],
), ),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
), ),
); );
}, },
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink( child: Ink(
height: 48, height: 48,
width: MediaQuery.of(context).size.width * 0.6, width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
child: const Center( child: const Center(
child: Text( child: Text(
'Комментарий', 'Комментарий',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1), style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
), ),
), ),
), ),
), ),
Container( Container(
height: 48, height: 48,
width: widget.isCanceled == true width: widget.isCanceled == true && step != totalSteps
? MediaQuery.of(context).size.width * 0.2 ? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8, : MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16), padding: const EdgeInsets.only(left: 4, right: 16),
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0, highlightElevation: 0,
elevation: 0, elevation: 0,
focusElevation: 0, focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
backgroundColor: isLoading borderRadius: BorderRadius.circular(20),
),
backgroundColor: submitDisabled
? Colors.grey ? Colors.grey
: widget.isCanceled == true : widget.isCanceled == true && step != totalSteps
? const Color(0xFFEAF2FD) ? const Color(0xFFEAF2FD)
: appTheme.blueA400, : appTheme.blueA400,
onPressed: isLoading onPressed: submitDisabled
? null ? null
: () async { : () async {
if (step == 6) { if (step == totalSteps) {
if (widget.isCanceled == true) { final passportErrors =
registrationConsultantCubit
.validatePassportFields();
if (passportErrors.isNotEmpty) {
pageController.jumpToPage(5);
return; return;
} }
if (registrationConsultantCubit.state.acceptOffer) { if (registrationConsultantCubit
.hasAcceptedRequiredAgreements) {
setState(() { setState(() {
isLoading = true; isLoading = true;
}); });
try { try {
await registrationConsultantCubit.registerConsultant(); await registrationConsultantCubit
.registerConsultant();
if (!context.mounted) return; if (!context.mounted) return;
if (registrationConsultantCubit.state.error != null) { if (registrationConsultantCubit
return; .state
} .error !=
await showModalBottomSheet( null) {
useRootNavigator: true, showDialog(
showDragHandle: true, context: context,
context: context, builder: (context) => AlertDialog(
builder: (context) => Container( actionsPadding:
padding: const EdgeInsets.all(16), const EdgeInsets.symmetric(
width: MediaQuery.of(context).size.width, horizontal: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
InkWell(
onTap: () {
Navigator.of(context).pop();
GoRouter.of(context).pop();
},
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
),
), ),
content: Text(
registrationConsultantCubit
.state
.error!,
),
actions: [
TextButton(
child: const Text('Ок'),
onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
), );
); return;
}
// Success sheet is shown by BlocListener when scoring completes.
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding:
const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(e.toString()), content: Text(e.toString()),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
onPressed: () => Navigator.of(context).pop(), onPressed: () =>
Navigator.of(context).pop(),
), ),
], ],
), ),
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
actionsPadding: const EdgeInsets.symmetric(horizontal: 4), actionsPadding:
content: const Text('Примите договор публичной офёрты'), const EdgeInsets.symmetric(
horizontal: 4,
),
content: Text(
state.isUpdating
? 'Примите договор публичной оферты'
: 'Примите оферту и согласие ФРДО',
),
actions: [ actions: [
TextButton( TextButton(
child: const Text('Ок'), child: const Text('Ок'),
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else { } else {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
setState(() { setState(() {
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut); pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
}); });
} }
}, },
label: isLoading label: submitDisabled && step == totalSteps
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: widget.isCanceled == true : widget.isCanceled == true && step != totalSteps
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400) ? Icon(
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)), Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
step == totalSteps ? 'Отправить' : 'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(
color: Colors.white,
),
),
), ),
), ),
], ],
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
leading: Builder( leading: Builder(
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()), builder: (context) => IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => GoRouter.of(context).pop(),
),
), ),
centerTitle: true, centerTitle: true,
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain), title: SvgPicture.asset(
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))], ImageConstant.imgLogoEatvio,
height: 48,
fit: BoxFit.contain,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('шаг $step из $totalSteps'),
),
],
bottom: PreferredSize( bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32), preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding( child: Padding(
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>( child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350), duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut, curve: Curves.easeInOut,
tween: Tween<double>(begin: 1 / 6, end: progressValue), tween: Tween<double>(
begin: 1 / totalSteps,
end: progressValue,
),
builder: (context, value, _) => LinearProgressIndicator( builder: (context, value, _) => LinearProgressIndicator(
value: value, value: value,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
minHeight: 8, minHeight: 8,
backgroundColor: Colors.grey.shade100, backgroundColor: Colors.grey.shade100,
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400), valueColor: AlwaysStoppedAnimation<Color>(
appTheme.blueA400,
),
), ),
), ),
), ),
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
onPageChanged: (index) { onPageChanged: (index) {
setState(() { setState(() {
step = index + 1; step = index + 1;
progressValue = step / 6; progressValue = step / totalSteps;
}); });
}, },
children: const [ children: _pages(),
CertificatesAndDiplomasScreen(),
PhotoOrScansScreen(),
AdditionalSettingsScreen(),
SocialNetworksScreen(),
RegistrationAsScreen(),
LastStepScreen(),
],
), ),
); );
}, },
),
), ),
); );
} }

View File

@@ -0,0 +1,28 @@
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:flutter/material.dart';
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
/// Pass [hasError] into [CustomTextFormField] from the parent.
class PassportFieldWithError extends StatelessWidget {
const PassportFieldWithError({required this.child, this.error, super.key});
final Widget child;
final String? error;
@override
Widget build(BuildContext context) {
final message = error?.trim();
final showError = message != null && message.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
if (showError) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: message),
],
],
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
class PassportSectionTitle extends StatelessWidget {
const PassportSectionTitle({required this.title, super.key});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: Text(
title,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
),
);
}
}
class PassportScreenTitle extends StatelessWidget {
const PassportScreenTitle({required this.title, super.key});
final String title;
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ScoringStatusWidget extends StatelessWidget {
const ScoringStatusWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
buildWhen: (previous, current) =>
previous.alfaScoringStatus != current.alfaScoringStatus ||
previous.isScoringPolling != current.isScoringPolling ||
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
builder: (context, state) {
final isWaiting =
state.awaitingScoringOutcome || state.isScoringPolling;
final status = state.alfaScoringStatus;
final showProcessing = status.isProcessing ||
(isWaiting && !status.isTerminal);
if (status == AlfaScoringStatus.none && !isWaiting) {
return const SizedBox.shrink();
}
final displayStatus = showProcessing && !status.isProcessing
? AlfaScoringStatus.beneficiaryCreating
: status;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: appTheme.greyD9),
color: appTheme.whiteA700,
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScoringStatusHeader(status: displayStatus),
const SizedBox(height: 12),
if (showProcessing) const ScoringProcessingBody(),
if (status.isComplete) const ScoringCompleteBody(),
if (status.isFailed)
ScoringFailedBody(
retryInProgress: state.scoringRetryInProgress,
onRetry: () => context
.read<RegisterConsultantCubit>()
.retryScoring(),
),
],
),
),
),
);
},
);
}
}
class ScoringStatusHeader extends StatelessWidget {
const ScoringStatusHeader({required this.status, super.key});
final AlfaScoringStatus status;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Text(
'Проверка данных',
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
),
),
Text(
ScoringStatusLabels.hintFor(status),
style: TextStyles.textSMedium.copyWith(
color: ScoringStatusLabels.colorFor(status),
),
),
],
);
}
}
class ScoringProcessingBody extends StatelessWidget {
const ScoringProcessingBody({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: appTheme.blueA400,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Идёт проверка…',
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
],
);
}
}
class ScoringCompleteBody extends StatelessWidget {
const ScoringCompleteBody({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(Icons.check_circle, color: appTheme.green, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text(
'Проверка успешно завершена',
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
),
),
],
);
}
}
class ScoringFailedBody extends StatelessWidget {
const ScoringFailedBody({
required this.retryInProgress,
required this.onRetry,
super.key,
});
final bool retryInProgress;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton(
onPressed: retryInProgress ? null : onRetry,
style: ElevatedButton.styleFrom(
backgroundColor: appTheme.blueA400,
disabledBackgroundColor: appTheme.greyD9,
foregroundColor: appTheme.whiteA700,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child: retryInProgress
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: appTheme.whiteA700,
),
)
: Text(
'Повторить',
style: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
),
),
),
],
);
}
}
abstract final class ScoringStatusLabels {
static String hintFor(AlfaScoringStatus status) {
return switch (status) {
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
AlfaScoringStatus.scoringPending => 'Ожидание результата',
AlfaScoringStatus.scoringComplete => 'Успешно',
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
AlfaScoringStatus.scoringFailed => 'Ошибка',
AlfaScoringStatus.none => '',
};
}
static Color colorFor(AlfaScoringStatus status) {
return switch (status) {
AlfaScoringStatus.scoringComplete => appTheme.green,
AlfaScoringStatus.beneficiaryFailed ||
AlfaScoringStatus.scoringFailed => appTheme.red,
AlfaScoringStatus.beneficiaryCreating ||
AlfaScoringStatus.scoringPending => appTheme.blueA400,
AlfaScoringStatus.none => appTheme.grey7B,
};
}
}

View File

@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
@@ -11,36 +12,53 @@ class ConsultantRepository {
const ConsultantRepository({required this.consultantService}); const ConsultantRepository({required this.consultantService});
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async { Future<void> registerConsultant({
required ConsultantProfileModel consultant,
bool update = false,
}) async {
if (update) { if (update) {
return await consultantService.consultantUpdate(consultantModel: consultant); return await consultantService.consultantUpdate(
consultantModel: consultant,
);
} else { } else {
return await consultantService.consultantRegistration(consultantModel: consultant); return await consultantService.consultantRegistration(
consultantModel: consultant,
);
} }
} }
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async { /// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
Future<ConsultationRequestModel> getConsultantRequestStatus(
String slug,
) async {
return await consultantService.getConsultantRequestStatus(slug: slug); return await consultantService.getConsultantRequestStatus(slug: slug);
} }
/// Своя заявка — education + legal/passport для регистрации и update.
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async { Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
return await consultantService.getMyRequestStatus(slug: slug); return await consultantService.getMyRequestStatus(slug: slug);
}
Future<List<SbpBankModel>> getSbpBanks() async {
return await consultantService.getSbpBanks();
} }
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async { Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
return await consultantService.getConsultantCertPhotos(); return await consultantService.getConsultantCertPhotos();
} }
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async { Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
required List<File> files,
}) async {
final multipartFiles = <MultipartFile>[]; final multipartFiles = <MultipartFile>[];
for (final file in files) { for (final file in files) {
final fileBytes = await file.readAsBytes(); final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream')); final multipartFile = MultipartFile.fromBytes(
fileBytes,
filename: file.path.split('/').last,
contentType: MediaType('application', 'octet-stream'),
);
multipartFiles.add(multipartFile); multipartFiles.add(multipartFile);
} }
@@ -48,22 +66,24 @@ class ConsultantRepository {
return []; return [];
} }
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles); return await consultantService.storeConsultantCertPhotos(
photos: multipartFiles,
);
} }
Future<void> hideConsultantCertPhoto(int id) async { Future<void> hideConsultantCertPhoto(int id) async {
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id}); await consultantService.hideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
} }
Future<void> unHideConsultantCertPhoto(int id) async { Future<void> unHideConsultantCertPhoto(int id) async {
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id}); await consultantService.unHideConsultantCertPhoto(
payload: {'cert_photo_id': id},
);
} }
Future<void> deletePhoto(int id) async { Future<void> deletePhoto(int id) async {
await consultantService.deletePhoto(id: id); await consultantService.deletePhoto(id: id);
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
part 'consultant_service.g.dart'; part 'consultant_service.g.dart';
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
abstract class ConsultantService { abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService; factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/consultant_request/registration') @POST('/api/v2/consultant_request/registration')
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantRegistration({
@Body() required ConsultantProfileModel consultantModel,
});
@POST('/api/consultant_request/photos/upload') @POST('/api/consultant_request/photos/upload')
@MultiPart() @MultiPart()
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos}); Future<List<String>> uploadPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data')
List<MultipartFile>? photos,
});
/// Публичный статус консультанта для просмотра профиля.
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
@GET('/api/profile/consultant/{slug}/status') @GET('/api/profile/consultant/{slug}/status')
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug}); Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
@GET('/api/consultant_request/{slug}/status') /// Свой статус заявки для редактирования/модерации.
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug}); /// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
@GET('/api/v2/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({
@Path() required String slug,
});
@PUT('/api/consultant_request/update') @PUT('/api/v2/consultant_request/update')
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel}); Future<void> consultantUpdate({
@Body() required ConsultantProfileModel consultantModel,
});
@GET('/api/finances/sbp_banks')
Future<List<SbpBankModel>> getSbpBanks();
@GET('/api/v2/consultant_request/cert_photos') @GET('/api/v2/consultant_request/cert_photos')
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos(); Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
@@ -32,14 +51,19 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/store') @POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart() @MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({ Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos, @Part(name: 'photos[]', contentType: 'multipart/form-data')
required List<MultipartFile> photos,
}); });
@POST('/api/v2/consultant_request/cert_photos/hide') @POST('/api/v2/consultant_request/cert_photos/hide')
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload}); Future<void> hideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/un_hide') @POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload}); Future<void> unHideConsultantCertPhoto({
@Body() required Map<String, dynamic> payload,
});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion') @POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id}); Future<void> deletePhoto({@Path() required int id});

View File

@@ -0,0 +1,170 @@
import 'package:dio/dio.dart';
/// Parsed Laravel 422 validation payload for consultant registration UI.
class LaravelValidationErrors {
const LaravelValidationErrors({
required this.summary,
this.fieldErrors = const <String, String>{},
});
final String summary;
final Map<String, String> fieldErrors;
static const LaravelValidationErrors empty = LaravelValidationErrors(
summary: '',
);
bool get hasFieldErrors => fieldErrors.isNotEmpty;
}
const _genericValidationSummary = 'Проверьте введённые данные';
const _genericNetworkSummary =
'Не удалось отправить данные. Попробуйте ещё раз';
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
/// Raw backend messages are never shown in the UI.
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
if (error is! DioException) {
return const LaravelValidationErrors(summary: _genericNetworkSummary);
}
final data = error.response?.data;
if (error.response?.statusCode == 422 && data is Map) {
final fieldErrors = <String, String>{};
final errors = data['errors'];
if (errors is Map) {
errors.forEach((key, value) {
final fieldKey = key.toString();
final raw = _rawErrorText(value);
fieldErrors[fieldKey] = mapConsultantValidationMessage(
fieldKey: fieldKey,
rawMessage: raw,
);
});
}
if (fieldErrors.isNotEmpty) {
final uniqueMessages = fieldErrors.values.toSet().toList();
return LaravelValidationErrors(
summary: uniqueMessages.join('\n'),
fieldErrors: fieldErrors,
);
}
return const LaravelValidationErrors(summary: _genericValidationSummary);
}
return const LaravelValidationErrors(summary: _genericNetworkSummary);
}
String _rawErrorText(Object? value) {
if (value is List && value.isNotEmpty) {
return value.map((e) => e.toString()).join(' ');
}
if (value is String) {
return value;
}
return '';
}
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
String mapConsultantValidationMessage({
required String fieldKey,
required String rawMessage,
}) {
final field = fieldKey.split('.').last.toLowerCase();
final raw = rawMessage.toLowerCase();
final isUnique =
raw.contains('taken') ||
raw.contains('unique') ||
raw.contains('already') ||
raw.contains('уже существ') ||
raw.contains('уже занят');
final isRequired =
raw.contains('required') ||
raw.contains('обязательн') ||
raw.contains('required_if');
final isAccepted = raw.contains('accepted') || field.contains('agreement');
final isUrl = raw.contains('url') || field.contains('link');
final isFormat =
raw.contains('format') ||
raw.contains('invalid') ||
raw.contains('regex') ||
raw.contains('digits') ||
raw.contains('size') ||
raw.contains('date') ||
raw.contains('before');
if (isAccepted) {
if (field.contains('frdo')) {
return 'Подтвердите согласие ФРДО';
}
return 'Примите условия оферты';
}
if (isUnique) {
return switch (field) {
'inn' => 'Такой ИНН существует',
'snils' => 'Такой СНИЛС существует',
'passport_number' || 'passport_series' => 'Такой паспорт существует',
'sbp_phone' => 'Такой телефон СБП существует',
_ => 'Такие данные уже существуют',
};
}
if (isRequired) {
return switch (field) {
'inn' => 'Укажите ИНН',
'snils' => 'Укажите СНИЛС',
'last_name' => 'Укажите фамилию',
'first_name' => 'Укажите имя',
'middle_name' => 'Укажите отчество',
'passport_series' => 'Укажите серию паспорта',
'passport_number' => 'Укажите номер паспорта',
'passport_issue_date' => 'Укажите дату выдачи паспорта',
'passport_issuer' => 'Укажите, кем выдан паспорт',
'passport_issuer_code' => 'Укажите код подразделения',
'birth_date' => 'Укажите дату рождения',
'birth_place' => 'Укажите место рождения',
'registration_address' => 'Укажите адрес регистрации',
'residence_address' => 'Укажите адрес проживания',
'citizenship' => 'Укажите гражданство',
'sbp_phone' => 'Укажите телефон СБП',
'sbp_bank_member_id' => 'Выберите банк СБП',
'completed_courses' => 'Добавьте хотя бы один курс',
'title' => 'Укажите название курса',
'date' => 'Укажите дату курса',
'agreement_accepted' => 'Примите условия оферты',
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
_ => 'Заполните поле',
};
}
if (isUrl) {
return 'Некорректная ссылка';
}
if (isFormat || raw.isNotEmpty) {
return switch (field) {
'inn' => 'Некорректный ИНН',
'snils' => 'Некорректный СНИЛС',
'passport_series' => 'Некорректная серия паспорта',
'passport_number' => 'Некорректный номер паспорта',
'passport_issue_date' => 'Некорректная дата выдачи',
'passport_issuer_code' => 'Некорректный код подразделения',
'last_name' => 'Некорректная фамилия',
'first_name' => 'Некорректное имя',
'middle_name' => 'Некорректное отчество',
'birth_date' => 'Некорректная дата рождения',
'citizenship' => 'Некорректный код гражданства',
'sbp_phone' => 'Некорректный телефон СБП',
'title' => 'Слишком короткое название курса',
'date' => 'Некорректная дата курса',
_ => 'Некорректное значение',
};
}
return 'Проверьте поле';
}

View File

@@ -0,0 +1,215 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
/// Client-side validation for individual-person passport fields.
///
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
/// look the same in the UI. Dates also reject non-calendar values and
/// dates after today.
abstract final class PassportDataValidator {
static const futureDateMessage = 'Дата не может быть в будущем';
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
static Map<String, String> validate(
ConsultantProfileModel consultant, {
DateTime? now,
}) {
final today = now ?? DateTime.now();
final errors = <String, String>{};
_requirePattern(
errors,
key: 'last_name',
value: consultant.lastName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите фамилию',
invalidMessage: 'Некорректная фамилия',
);
_requirePattern(
errors,
key: 'first_name',
value: consultant.firstName,
pattern: alfaPersonNamePattern,
emptyMessage: 'Укажите имя',
invalidMessage: 'Некорректное имя',
);
_optionalPattern(
errors,
key: 'middle_name',
value: consultant.middleName,
pattern: alfaPersonNamePattern,
invalidMessage: 'Некорректное отчество',
);
_requirePattern(
errors,
key: 'inn',
value: consultant.inn,
pattern: RegExp(r'^\d{12}$'),
emptyMessage: 'Укажите ИНН',
invalidMessage: 'Некорректный ИНН',
);
_requirePattern(
errors,
key: 'snils',
value: consultant.snils,
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
emptyMessage: 'Укажите СНИЛС',
invalidMessage: 'Некорректный СНИЛС',
);
_requirePattern(
errors,
key: 'passport_series',
value: consultant.passportSeries,
pattern: RegExp(r'^\d{4}$'),
emptyMessage: 'Укажите серию паспорта',
invalidMessage: 'Некорректная серия паспорта',
);
_requirePattern(
errors,
key: 'passport_number',
value: consultant.passportNumber,
pattern: RegExp(r'^\d{6}$'),
emptyMessage: 'Укажите номер паспорта',
invalidMessage: 'Некорректный номер паспорта',
);
_requireText(
errors,
key: 'passport_issuer',
value: consultant.passportIssuer,
emptyMessage: 'Укажите, кем выдан паспорт',
);
_requirePattern(
errors,
key: 'passport_issuer_code',
value: consultant.passportIssuerCode,
pattern: RegExp(r'^\d{3}-\d{3}$'),
emptyMessage: 'Укажите код подразделения',
invalidMessage: 'Некорректный код подразделения',
);
_requireDate(
errors,
key: 'passport_issue_date',
value: consultant.passportIssueDate,
emptyMessage: 'Укажите дату выдачи паспорта',
invalidMessage: 'Некорректная дата выдачи',
today: today,
);
_requireDate(
errors,
key: 'birth_date',
value: consultant.birthDate,
emptyMessage: 'Укажите дату рождения',
invalidMessage: 'Некорректная дата рождения',
today: today,
);
_requireText(
errors,
key: 'birth_place',
value: consultant.birthPlace,
emptyMessage: 'Укажите место рождения',
);
_requireText(
errors,
key: 'registration_address',
value: consultant.registrationAddress,
emptyMessage: 'Укажите адрес регистрации',
);
_requireText(
errors,
key: 'residence_address',
value: consultant.residenceAddress,
emptyMessage: 'Укажите адрес проживания',
);
_requirePattern(
errors,
key: 'citizenship',
value: consultant.citizenship,
pattern: RegExp(r'^[A-Za-z]{2}$'),
emptyMessage: 'Укажите гражданство',
invalidMessage: 'Некорректный код гражданства',
);
return errors;
}
static void _requireText(
Map<String, String> errors, {
required String key,
required String? value,
required String emptyMessage,
}) {
if (value == null || value.trim().isEmpty) {
errors[key] = emptyMessage;
}
}
static void _requirePattern(
Map<String, String> errors, {
required String key,
required String? value,
required RegExp pattern,
required String emptyMessage,
required String invalidMessage,
}) {
final raw = value?.trim() ?? '';
if (raw.isEmpty) {
errors[key] = emptyMessage;
return;
}
if (!pattern.hasMatch(raw)) {
errors[key] = invalidMessage;
}
}
static void _optionalPattern(
Map<String, String> errors, {
required String key,
required String? value,
required RegExp pattern,
required String invalidMessage,
}) {
final raw = value?.trim() ?? '';
if (raw.isEmpty) {
return;
}
if (!pattern.hasMatch(raw)) {
errors[key] = invalidMessage;
}
}
static void _requireDate(
Map<String, String> errors, {
required String key,
required String? value,
required String emptyMessage,
required String invalidMessage,
required DateTime today,
}) {
final raw = value?.trim() ?? '';
if (raw.isEmpty) {
errors[key] = emptyMessage;
return;
}
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
if (match == null) {
errors[key] = invalidMessage;
return;
}
final year = int.parse(match.group(1)!);
final month = int.parse(match.group(2)!);
final day = int.parse(match.group(3)!);
final parsed = DateTime(year, month, day);
if (parsed.year != year || parsed.month != month || parsed.day != day) {
errors[key] = invalidMessage;
return;
}
final todayDate = DateTime(today.year, today.month, today.day);
if (parsed.isAfter(todayDate)) {
errors[key] = futureDateMessage;
}
}
}

View File

@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.8 - 32, flex: 2,
child: CustomTextFormField( child: SizedBox(
controller: productNameController, child: CustomTextFormField(
autofocus: true, controller: productNameController,
hintText: 'Введите название продукта/блюда', autofocus: true,
onChanged: (query) { hintText: 'Введите название продукта/блюда',
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async { onChanged: (query) {
selectedGlossary = null; EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null; selectedGlossary = null;
if (query.isEmpty) {
glossariesList = [];
selectedGlossary = null;
setState(() {});
return;
}
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
setState(() {}); setState(() {});
return; });
} },
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query); ),
setState(() {});
});
},
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
SizedBox( Expanded(
width: MediaQuery.of(context).size.width * 0.2 - 4, flex: 1,
child: CustomTextFormField( child: SizedBox(
controller: quantityController, child: CustomTextFormField(
keyboardType: TextInputType.number, controller: quantityController,
hintText: selectedGlossary == null keyboardType: TextInputType.number,
? '100' hintText: selectedGlossary == null
: selectedGlossary!.grInPortion == 0 ? '100'
? '100' : selectedGlossary!.grInPortion == 0
: '1', ? '100'
suffixText: selectedGlossary == null : '1',
? 'гр' suffixText: selectedGlossary == null
: selectedGlossary?.grInPortion == 0 ? 'гр'
? 'гр' : selectedGlossary?.grInPortion == 0
: 'шт', ? 'гр'
: 'шт',
),
), ),
), ),
], ],
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
), ),
), ),
if (widget.portionIndex != null) const SizedBox(width: 8), if (widget.portionIndex != null) const SizedBox(width: 8),
InkWell( Expanded(
onTap: addPortion, child: InkWell(
borderRadius: BorderRadius.circular(20), onTap: addPortion,
child: Ink( borderRadius: BorderRadius.circular(20),
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92), child: Ink(
height: 52, width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), height: 52,
child: Center( decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: Text( child: Center(
widget.portionIndex == null ? 'Добавить' : 'Сохранить', child: Text(
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white), widget.portionIndex == null ? 'Добавить' : 'Сохранить',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
),
), ),
), ),
), ),

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
class CreatePlanScreen extends StatelessWidget { class CreatePlanScreen extends StatelessWidget {
static const name = 'createPlanScreen'; static const name = 'createPlanScreen';
static const path = '/create-Plan-screen'; static const path = '/create-Plan-screen';
const CreatePlanScreen({super.key}); const CreatePlanScreen({super.key, this.plan});
final PlanModel? plan;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent()); return BlocProvider(
create: (context) {
final cubit = sl<CreatePlanCubit>();
final existingPlan = plan;
if (existingPlan != null) {
cubit.loadExisting(existingPlan);
}
return cubit;
},
child: const CreatePlanScreenContent(),
);
} }
} }
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading, buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
builder: (context, state) { builder: (context, state) {
if (state.planCreated) { if (state.planCreated) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return; if (!context.mounted) return;
context.pop(); final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
context.pop(true);
showBottomSheetWithBlur( showBottomSheetWithBlur(
userRootNavigator: true, userRootNavigator: true,
context: context, context: context,
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true), child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
); );
}); });
} }
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
title: Text( title: Text(
s.adding_plan, isEditing ? 'Редактирование плана' : s.adding_plan,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: true, softWrap: true,
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
return Column( return Column(
children: [ children: [
CustomTextFormField( CustomTextFormField(
key: const ValueKey('plan-name'),
hintText: s.plan_label_desc, hintText: s.plan_label_desc,
initialValue: state.planData.name,
hasError: state.validationState.titleHasError, hasError: state.validationState.titleHasError,
onChanged: (value) { onChanged: (value) {
context.read<CreatePlanCubit>().setName(value); context.read<CreatePlanCubit>().setName(value);
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
children: [ children: [
MarkdownEditor( MarkdownEditor(
key: _mdKey, key: _mdKey,
initialText: state.planData.details,
hintText: s.plan_desc_example, hintText: s.plan_desc_example,
hasError: state.validationState.descriptionHasError, hasError: state.validationState.descriptionHasError,
minLines: 6, minLines: 6,
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
onPressed: () async { onPressed: () async {
await context.read<CreatePlanCubit>().publishPlan(); await context.read<CreatePlanCubit>().publishPlan();
}, },
text: s.publish, text: isEditing ? 'Обновить' : s.publish,
height: 52, height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700), buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle( buttonStyle: ButtonStyle(

View File

@@ -4,16 +4,57 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:eatvio_app/core/const.dart'; import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart'; import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
class CreatePlanCubit extends Cubit<CreatePlanState> { class CreatePlanCubit extends Cubit<CreatePlanState> {
final CreatePlanRepository createPlanRepository; final CreatePlanRepository createPlanRepository;
PlanModel? sourcePlan;
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial()); CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
void loadExisting(PlanModel plan) {
sourcePlan = plan;
final durationIndex = durationValues.indexOf(plan.duration);
emit(
state.copyWith(
durationIndex: durationIndex >= 0 ? durationIndex : null,
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
planData: state.planData.copyWith(
name: plan.name,
details: plan.details,
duration: plan.duration,
state: plan.state,
type: [
plan.type.slim ?? false,
plan.type.fit ?? false,
plan.type.wellness ?? false,
plan.type.behavior ?? false,
],
options: [
_packetOrEmpty(plan.packet1Options),
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
],
),
),
);
}
PacketOptions _packetOrEmpty(PacketOptions? option) {
if (option == null) {
return PacketOptions.empty();
}
final price = option.price;
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
return option.copyWith(price: normalizedPrice);
}
void setDetails(String text) { void setDetails(String text) {
emit(state.copyWith(planData: state.planData.copyWith(details: text))); emit(state.copyWith(planData: state.planData.copyWith(details: text)));
validateDetails(); validateDetails();
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
} }
bool validateImage() { bool validateImage() {
if (state.planData.image == null) { if (state.planData.image == null && sourcePlan == null) {
emit(state.copyWith.validationState(imageHasError: true)); emit(state.copyWith.validationState(imageHasError: true));
return false; return false;
} }
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'type[fit]': state.planData.type[1] ? '1' : '0', 'type[fit]': state.planData.type[1] ? '1' : '0',
'type[wellness]': state.planData.type[2] ? '1' : '0', 'type[wellness]': state.planData.type[2] ? '1' : '0',
'type[behavior]': state.planData.type[3] ? '1' : '0', 'type[behavior]': state.planData.type[3] ? '1' : '0',
'image': state.planData.image != null
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
filename: state.planData.image!.name)
: null
}); });
if (state.planData.image != null) {
final image = state.planData.image!;
planMap['image'] = await MultipartFile.fromFile(
(await ImageCompressUtility.compressFile(File(image.path))).path,
filename: image.name,
);
}
if (state.planData.options[1].short.isNotEmpty) { if (state.planData.options[1].short.isNotEmpty) {
planMap.addAll({ planMap.addAll({
'packets_count': 3, 'packets_count': 3,
'packet1_options': { 'packet1_options': {
'short': state.planData.options[0].short, 'short': state.planData.options[0].short,
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[0].price),
'description_md': state.planData.options[0].description, 'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items), 'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
}, },
'packet2_options': { 'packet2_options': {
'short': state.planData.options[1].short, 'short': state.planData.options[1].short,
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[1].price),
'description_md': state.planData.options[1].description, 'description_md': state.planData.options[1].description,
'items': Map.fromIterable(state.planData.options[1].items), 'items': Map.fromIterable(state.planData.options[1].items),
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
}, },
'packet3_options': { 'packet3_options': {
'short': state.planData.options[2].short, 'short': state.planData.options[2].short,
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[2].price),
'description_md': state.planData.options[2].description, 'description_md': state.planData.options[2].description,
'items': Map.fromIterable(state.planData.options[2].items), 'items': Map.fromIterable(state.planData.options[2].items),
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
'packets_count': 1, 'packets_count': 1,
'packet1_options': { 'packet1_options': {
'short': state.planData.options[0].short, 'short': state.planData.options[0].short,
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)), 'price': _optionPrice(state.planData.options[0].price),
'description_md': state.planData.options[0].description, 'description_md': state.planData.options[0].description,
'items': Map.fromIterable(state.planData.options[0].items), 'items': Map.fromIterable(state.planData.options[0].items),
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0', 'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
}, },
}); });
} }
await createPlanRepository.createPlan(model: planMap); final editingPlan = sourcePlan;
if (editingPlan == null) {
await createPlanRepository.createPlan(model: planMap);
} else {
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
}
emit(state.copyWith(isLoading: false, planCreated: true)); emit(state.copyWith(isLoading: false, planCreated: true));
} }
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
emit(state.copyWith(isLoading: false, planCreated: true)); emit(state.copyWith(isLoading: false, planCreated: false));
final isForbidden = e is DioException && e.response?.statusCode == 403;
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
} }
} }
int _optionPrice(dynamic price) {
if (price is num) {
return price.round();
}
return int.parse(price.toString());
}
} }

View File

@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
class _AddItemWidgetState extends State<AddItemWidget> { class _AddItemWidgetState extends State<AddItemWidget> {
final controller = TextEditingController(); final controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override @override
void didUpdateWidget(oldWidget) { void didUpdateWidget(oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final s = S.of(context); final s = S.of(context);
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) { if (widget.items.length > widget.index &&
controller.text != widget.items[widget.index]) {
controller.text = widget.items[widget.index]; controller.text = widget.items[widget.index];
} }
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
buildWhen: (previous, current) => previous.validationState != current.validationState, buildWhen: (previous, current) =>
previous.validationState != current.validationState,
builder: (context, state) { builder: (context, state) {
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty || final hasError =
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false); state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.isItemsEmpty ||
(state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false);
return Column( return Column(
children: [ children: [
Row( Row(
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
child: CustomTextFormField( child: CustomTextFormField(
controller: controller, controller: controller,
hasError: hasError, hasError: hasError,
hintText: widget.enableHint ? s.option_content_hint_text : null, hintText: widget.enableHint
? s.option_content_hint_text
: null,
onChanged: (value) { onChanged: (value) {
context.read<CreatePlanCubit>().setOptionContent(value, widget.index); context.read<CreatePlanCubit>().setOptionContent(
value,
widget.index,
);
}, },
), ),
), ),
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
onTap: () { onTap: () {
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index); context.read<CreatePlanCubit>().removeOptionContentItem(
widget.index,
);
}, },
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
color: appTheme.lightGray0, color: appTheme.lightGray0,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24), child: SvgPicture.asset(
'assets/icons/trash_can.svg',
width: 20,
height: 24,
),
), ),
), ),
], ],
], ],
), ),
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[ if (state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.isItemsEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.addAtLeastOneItem) ValidationErrorWidget(error: s.addAtLeastOneItem),
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[ ] else if (state
.validationState
.optionsValidationState[state.selectedOptionIndex ?? 0]
.itemsErrors[widget.index] ??
false) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols) ValidationErrorWidget(error: s.atLeast3Symbols),
] ],
], ],
); );
}, },

View File

@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart'; import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart'; import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
} }
class _OptionWidgetState extends State<OptionWidget> { class _OptionWidgetState extends State<OptionWidget> {
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽'; static const _optionsCount = 3;
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
final creatorPriceController = TextEditingController()..text = '1000₽'; final List<TextEditingController> _customerPriceControllers = List.generate(
_optionsCount,
final firstOptionTitleController = TextEditingController(); (_) => TextEditingController(text: '1200₽'),
final secondOptionTitleController = TextEditingController(); );
final thirdOptionTitleController = TextEditingController(); final List<TextEditingController> _titleControllers = List.generate(
_optionsCount,
final firstOptionDescriptionController = TextEditingController(); (_) => TextEditingController(),
final secondOptionDescriptionController = TextEditingController(); );
final thirdOptionDescriptionController = TextEditingController(); final List<TextEditingController> _descriptionControllers = List.generate(
_optionsCount,
(_) => TextEditingController(),
);
final creatorPriceController = TextEditingController(text: '1000₽');
int? _syncedOptionIndex;
bool firstExplanationVisible = false; bool firstExplanationVisible = false;
bool isChecked = false; bool isChecked = false;
bool secondExplanationVisible = false; bool secondExplanationVisible = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
firstOptionCustomerPriceController.addListener(() { for (final controller in _customerPriceControllers) {
if (!firstOptionCustomerPriceController.text.endsWith('₽')) { controller.addListener(() => _ensureRubleSuffix(controller));
final text = firstOptionCustomerPriceController.text.replaceAll('₽', ''); }
firstOptionCustomerPriceController.text = '$text₽'; }
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
} @override
}); void dispose() {
secondOptionCustomerPriceController.addListener(() { for (final controller in _customerPriceControllers) {
if (!secondOptionCustomerPriceController.text.endsWith('₽')) { controller.dispose();
final text = secondOptionCustomerPriceController.text.replaceAll('₽', ''); }
secondOptionCustomerPriceController.text = '$text₽'; for (final controller in _titleControllers) {
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length); controller.dispose();
} }
}); for (final controller in _descriptionControllers) {
thirdOptionCustomerPriceController.addListener(() { controller.dispose();
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) { }
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', ''); creatorPriceController.dispose();
thirdOptionCustomerPriceController.text = '$text₽'; super.dispose();
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length); }
}
}); void _ensureRubleSuffix(TextEditingController controller) {
if (!controller.text.endsWith('₽')) {
final text = controller.text.replaceAll('₽', '');
controller.text = '$text₽';
controller.selection = TextSelection.collapsed(offset: text.length);
}
}
void _syncController(TextEditingController controller, String value) {
if (controller.text == value) {
return;
}
controller.value = TextEditingValue(
text: value,
selection: TextSelection.collapsed(offset: value.length),
);
}
String _formatCustomerPrice(dynamic price) {
if (price is num) {
return '${price.toStringAsFixed(0)}₽';
}
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
}
void _applyOptionToControllers(int index, PacketOptions option) {
_syncController(_titleControllers[index], option.short);
_syncController(_descriptionControllers[index], option.description ?? '');
_syncController(
_customerPriceControllers[index],
_formatCustomerPrice(option.price),
);
}
void _syncSelectedOption(CreatePlanState state) {
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
final option = state.planData.options[selectedOptionIndex];
final price = double.tryParse(
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
);
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
if (_syncedOptionIndex != selectedOptionIndex) {
_syncedOptionIndex = selectedOptionIndex;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
_applyOptionToControllers(selectedOptionIndex, option);
creatorPriceController.text = creatorPrice;
});
return;
}
if (creatorPriceController.text != creatorPrice) {
creatorPriceController.text = creatorPrice;
}
} }
@override @override
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
return BlocBuilder<CreatePlanCubit, CreatePlanState>( return BlocBuilder<CreatePlanCubit, CreatePlanState>(
builder: (context, state) { builder: (context, state) {
final selectedOptionIndex = state.selectedOptionIndex ?? 0; final selectedOptionIndex = state.selectedOptionIndex ?? 0;
_syncSelectedOption(state);
final optionNameHintText = switch (selectedOptionIndex) { final optionNameHintText = switch (selectedOptionIndex) {
0 => s.economy, 0 => s.economy,
2 => s.comfort, 2 => s.comfort,
_ => s.standard, _ => s.standard,
}; };
final optionTitleController = switch (selectedOptionIndex) { final optionTitleController = _titleControllers[selectedOptionIndex];
0 => firstOptionTitleController, final optionDescriptionController =
2 => thirdOptionTitleController, _descriptionControllers[selectedOptionIndex];
_ => secondOptionTitleController, final customerPriceController =
}; _customerPriceControllers[selectedOptionIndex];
final optionDescriptionController = switch (selectedOptionIndex) { final optionItems = state.planData.options[selectedOptionIndex].items;
0 => firstOptionDescriptionController, final optionValidation =
2 => thirdOptionDescriptionController, state.validationState.optionsValidationState[selectedOptionIndex];
_ => secondOptionDescriptionController,
};
final customerPriceController = switch (selectedOptionIndex) {
0 => firstOptionCustomerPriceController,
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;
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
return MainBackgroundBouble( return MainBackgroundBouble(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
const SizedBox(height: 4), const SizedBox(height: 4),
CustomTextFormField( CustomTextFormField(
hintText: optionNameHintText, hintText: optionNameHintText,
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError, hasError: optionValidation.titleHasError,
controller: optionTitleController, controller: optionTitleController,
onChanged: (value) { onChanged: (value) {
context.read<CreatePlanCubit>().setOptionTitle(value); context.read<CreatePlanCubit>().setOptionTitle(value);
}, },
maxLength: 150, maxLength: 150,
), ),
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[ if (optionValidation.titleHasError) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols), ValidationErrorWidget(error: s.atLeast3Symbols),
], ],
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
child: CustomTextFormField( child: CustomTextFormField(
controller: optionDescriptionController, controller: optionDescriptionController,
textAlignVertical: TextAlignVertical.top, textAlignVertical: TextAlignVertical.top,
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError, hasError: optionValidation.descriptionHasError,
maxLength: 1000, maxLength: 1000,
minLines: null, minLines: null,
maxLines: null, maxLines: null,
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
}, },
), ),
), ),
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[ if (optionValidation.descriptionHasError) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
ValidationErrorWidget(error: s.atLeast3Symbols), ValidationErrorWidget(error: s.atLeast3Symbols),
], ],
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: true, softWrap: true,
style: TextStyles.textSMedium.copyWith(color: appTheme.black), style: TextStyles.textSMedium.copyWith(
color: appTheme.black,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const _PriceExplanationWidget(), const _PriceExplanationWidget(),
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
softWrap: true, softWrap: true,
style: TextStyles.textSMedium.copyWith(color: appTheme.black), style: TextStyles.textSMedium.copyWith(
color: appTheme.black,
),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
const _PriceExplanationWidget(), const _PriceExplanationWidget(),
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
const SizedBox(height: 4), const SizedBox(height: 4),
CustomTextFormField( CustomTextFormField(
controller: customerPriceController, controller: customerPriceController,
textInputType: const TextInputType.numberWithOptions(decimal: true), textInputType: const TextInputType.numberWithOptions(
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError, decimal: true,
),
hasError: optionValidation.priceHasError,
onChanged: (value) { onChanged: (value) {
final price = double.tryParse(value.replaceAll('₽', '')); final price = double.tryParse(value.replaceAll('₽', ''));
if (price != null) { if (price != null) {
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
separatorBuilder: (context, index) => const SizedBox(height: 8), separatorBuilder: (context, index) => const SizedBox(height: 8),
reverse: false, reverse: false,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final items = state.planData.options[selectedOptionIndex].items; if (optionItems.isEmpty) {
if (items.isEmpty) {
return AddItemWidget( return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
index: index, index: index,
canBeDeleted: false, canBeDeleted: false,
enableHint: true, enableHint: true,
items: items, items: optionItems,
); );
} else { } else {
if (index < items.length) { if (index < optionItems.length) {
return AddItemWidget( return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
canBeDeleted: true, canBeDeleted: true,
enableHint: false, enableHint: false,
index: index, index: index,
items: items, items: optionItems,
); );
} else { } else {
return AddItemWidget( return AddItemWidget(
key: ValueKey(
'plan-option-$selectedOptionIndex-item-$index',
),
canBeDeleted: false, canBeDeleted: false,
enableHint: false, enableHint: false,
index: index, index: index,
items: items, items: optionItems,
); );
} }
} }
}, },
itemCount: state.planData.options[selectedOptionIndex].items.length + 1, itemCount: optionItems.length + 1,
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
), ),
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
Row( Row(
children: [ children: [
Checkbox( Checkbox(
value: state.planData.options[selectedOptionIndex].hasPersonalDiet, value: state
.planData
.options[selectedOptionIndex]
.hasPersonalDiet,
activeColor: appTheme.gray, activeColor: appTheme.gray,
checkColor: appTheme.gray, checkColor: appTheme.gray,
fillColor: const WidgetStatePropertyAll(Colors.transparent), fillColor: const WidgetStatePropertyAll(Colors.transparent),
side: WidgetStateBorderSide.resolveWith( side: WidgetStateBorderSide.resolveWith((
(Set<WidgetState> states) { Set<WidgetState> states,
return BorderSide(width: 1, color: appTheme.gray); ) {
}, return BorderSide(width: 1, color: appTheme.gray);
), }),
onChanged: (bool? value) { onChanged: (bool? value) {
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState(); context
.read<CreatePlanCubit>()
.togglePersonalizedDietOptionState();
}, },
), ),
Expanded( Expanded(
child: Text( child: Text(
s.option_personal_diet_desc, s.option_personal_diet_desc,
style: TextStyles.textMMedium.copyWith(color: appTheme.black), style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
), ),
), ),
], ],
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
const _PriceExplanationWidget(); const _PriceExplanationWidget();
@override @override
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState(); State<_PriceExplanationWidget> createState() =>
_PriceExplanationWidgetState();
} }
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> { class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
color: visible ? appTheme.whiteA700 : Colors.transparent, color: visible ? appTheme.whiteA700 : Colors.transparent,
), ),
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
child: Text( child: Text(s.price_explanation),
s.price_explanation,
),
), ),
), ),
visible: visible, visible: visible,
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
visible = !visible; visible = !visible;
}); });
}, },
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12), child: SvgPicture.asset(
'assets/icons/question_mark.svg',
height: 12,
width: 12,
),
), ),
); );
} }

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