52 Commits

Author SHA1 Message Date
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: #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
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
7db9af9894 merge upstream 2026-06-23 08:56:24 +00: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
54c27c28cd feat(test): updated tests to increase tests coverage 2026-05-06 14:12:34 +03:00
misha
fe243965e3 feat(test): 70% tests coverage 2026-05-06 05:27:25 +03:00
misha
56313a4fcc Merge branch 'issue-974' of ssh://10.55.5.123:222/misha/eatvio_app into issue-974 2026-05-05 10:52:00 +03:00
misha
cb36ef8e07 Merge remote-tracking branch 'upstream/main' into issue-974 2026-05-05 10:51:20 +03:00
misha
b2072ab626 feat(video_calls): fixed falling test 2026-05-05 10:40:11 +03:00
5b72684c95 merge upstream 2026-05-05 05:17:22 +00:00
misha
91ad51d340 feat(video_calls): first impl for main video calls screen 2026-05-05 08:09:24 +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
155 changed files with 9421 additions and 1257 deletions

View File

@@ -152,9 +152,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

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

@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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"/>
@@ -86,5 +88,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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12.0833V7.91666C0 7.56944 0.121528 7.2743 0.364583 7.03125C0.607639 6.78819 0.902778 6.66666 1.25 6.66666C1.59722 6.66666 1.89236 6.78819 2.13542 7.03125C2.37847 7.2743 2.5 7.56944 2.5 7.91666V12.0833C2.5 12.4306 2.37847 12.7257 2.13542 12.9687C1.89236 13.2118 1.59722 13.3333 1.25 13.3333C0.902778 13.3333 0.607639 13.2118 0.364583 12.9687C0.121528 12.7257 0 12.4306 0 12.0833ZM5 12.0833V5.83333C5 5.48611 5.12153 5.19097 5.36458 4.94791C5.60764 4.70486 5.90278 4.58333 6.25 4.58333C6.59722 4.58333 6.89236 4.70486 7.13542 4.94791C7.37847 5.19097 7.5 5.48611 7.5 5.83333V12.0833C7.5 12.4306 7.37847 12.7257 7.13542 12.9687C6.89236 13.2118 6.59722 13.3333 6.25 13.3333C5.90278 13.3333 5.60764 13.2118 5.36458 12.9687C5.12153 12.7257 5 12.4306 5 12.0833ZM10 12.0833V3.75C10 3.40278 10.1215 3.10764 10.3646 2.86458C10.6076 2.62153 10.9028 2.5 11.25 2.5C11.5972 2.5 11.8924 2.62153 12.1354 2.86458C12.3785 3.10764 12.5 3.40278 12.5 3.75V12.0833C12.5 12.4306 12.3785 12.7257 12.1354 12.9687C11.8924 13.2118 11.5972 13.3333 11.25 13.3333C10.9028 13.3333 10.6076 13.2118 10.3646 12.9687C10.1215 12.7257 10 12.4306 10 12.0833ZM15 6.8125V1.25C15 0.902777 15.1215 0.607639 15.3646 0.364583C15.6076 0.121528 15.9028 0 16.25 0C16.5972 0 16.8924 0.121528 17.1354 0.364583C17.3785 0.607639 17.5 0.902777 17.5 1.25V6.54166C17.3611 6.51389 17.2257 6.49305 17.0938 6.47916C16.9618 6.46528 16.8194 6.45833 16.6667 6.45833C16.375 6.45833 16.0868 6.48958 15.8021 6.55208C15.5174 6.61458 15.25 6.70139 15 6.8125ZM16.0729 13.0937C15.9132 12.934 15.8333 12.7361 15.8333 12.5C15.8333 12.2639 15.9132 12.066 16.0729 11.9062C16.2326 11.7465 16.4306 11.6667 16.6667 11.6667C16.9028 11.6667 17.1007 11.7465 17.2604 11.9062C17.4201 12.066 17.5 12.2639 17.5 12.5C17.5 12.7361 17.4201 12.934 17.2604 13.0937C17.1007 13.2535 16.9028 13.3333 16.6667 13.3333C16.4306 13.3333 16.2326 13.2535 16.0729 13.0937ZM15.8333 10V8.75C15.8333 8.51389 15.9132 8.31597 16.0729 8.15625C16.2326 7.99652 16.4306 7.91666 16.6667 7.91666C16.9028 7.91666 17.1007 7.99652 17.2604 8.15625C17.4201 8.31597 17.5 8.51389 17.5 8.75V10C17.5 10.2361 17.4201 10.434 17.2604 10.5937C17.1007 10.7535 16.9028 10.8333 16.6667 10.8333C16.4306 10.8333 16.2326 10.7535 16.0729 10.5937C15.9132 10.434 15.8333 10.2361 15.8333 10Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.292969 2.29289C0.683493 1.90237 1.31651 1.90237 1.70703 2.29289L3.41406 3.99992H11.4424C12.3865 3.99991 13.1558 3.99969 13.7803 4.05071C14.4257 4.10345 15.0042 4.21564 15.543 4.49016C16.3897 4.92159 17.0783 5.61023 17.5098 6.45696C17.7843 6.99573 17.8965 7.57424 17.9492 8.21965C17.9518 8.25175 17.9517 8.28444 17.9541 8.31731L22.4189 5.12981C23.0808 4.65704 24 5.12991 24 5.94328V18.0566C24 18.8699 23.0808 19.3428 22.4189 18.87L18 15.7138V18.5859L19.707 20.2929C20.0976 20.6834 20.0976 21.3164 19.707 21.707C19.3165 22.0975 18.6835 22.0975 18.293 21.707L0.292969 3.70696C-0.097555 3.31643 -0.0975551 2.68342 0.292969 2.29289ZM1 7.99895C1.55212 7.99895 1.99974 8.44689 2 8.99895V13.3993C2 14.3959 2.00036 15.0836 2.04395 15.6171C2.08658 16.1387 2.16554 16.4249 2.27246 16.6347C2.51215 17.105 2.89491 17.4868 3.36523 17.7265C3.57511 17.8334 3.86102 17.9124 4.38281 17.955C4.91619 17.9986 5.60333 17.9989 6.59961 17.9989H11C11.5521 17.9989 11.9997 18.4469 12 18.9989C12 19.5512 11.5523 19.9989 11 19.9989H6.55762C5.61342 19.999 4.84421 19.9992 4.21973 19.9482C3.57428 19.8954 2.99583 19.7832 2.45703 19.5087C1.61035 19.0773 0.921646 18.3886 0.490235 17.5419C0.215871 17.0032 0.103504 16.4245 0.0507818 15.7792C-0.000207456 15.1548 -1.10191e-05 14.3854 5.0477e-07 13.4413V8.99895C0.00026378 8.44689 0.447878 7.99895 1 7.99895ZM16 16.5859V10.5995C16 9.60324 15.9986 8.91611 15.9551 8.38274C15.9125 7.86113 15.8344 7.57502 15.7275 7.36516C15.4879 6.89475 15.1052 6.51207 14.6348 6.27239C14.4249 6.16547 14.1389 6.0865 13.6172 6.04387C13.0837 6.00029 12.396 5.99992 11.3994 5.99992H5.41406L16 16.5859Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83398 9.03418C6.1464 8.72177 6.6534 8.72178 6.96582 9.03418C7.27824 9.3466 7.27824 9.8536 6.96582 10.166L2.73047 14.4004H5.59961C6.04144 14.4004 6.40039 14.7584 6.40039 15.2002C6.40032 15.642 6.04139 16 5.59961 16H0.799805C0.750353 16 0.702243 15.994 0.655273 15.9854C0.650431 15.9845 0.645453 15.9844 0.640625 15.9834C0.632394 15.9817 0.624348 15.9795 0.616211 15.9775C0.599845 15.9737 0.583526 15.9697 0.567383 15.9648C0.525709 15.9522 0.485753 15.936 0.447266 15.917C0.370564 15.8792 0.298188 15.8294 0.234375 15.7656C0.170233 15.7015 0.119834 15.6289 0.0820312 15.5518C0.0522722 15.4911 0.0292924 15.4265 0.015625 15.3584C0.0146554 15.3536 0.0145519 15.3486 0.0136719 15.3438C0.0052094 15.2971 7.59671e-06 15.2493 0 15.2002V10.4004C0 9.95863 0.358067 9.59972 0.799805 9.59961C1.24163 9.59961 1.59961 9.95856 1.59961 10.4004V13.2686L5.83398 9.03418ZM15.2002 0C15.2493 1.2747e-05 15.2971 0.0052045 15.3438 0.0136719C15.3486 0.0145519 15.3536 0.0146554 15.3584 0.015625C15.3666 0.017277 15.3747 0.0195822 15.3828 0.0214844C15.3992 0.0253083 15.4155 0.0293148 15.4316 0.0341797C15.4733 0.0467652 15.5133 0.0631386 15.5518 0.0820312C15.6289 0.119834 15.7015 0.170233 15.7656 0.234375C15.8294 0.298188 15.8792 0.370564 15.917 0.447266C15.9252 0.463964 15.9334 0.480716 15.9404 0.498047C15.9612 0.549032 15.9755 0.601773 15.9854 0.655273C15.994 0.702239 16 0.750358 16 0.799805V5.59961C16 6.04137 15.6419 6.40028 15.2002 6.40039C14.7584 6.40039 14.4004 6.04144 14.4004 5.59961V2.73047L10.166 6.96582C9.8536 7.27824 9.3466 7.27824 9.03418 6.96582C8.72178 6.6534 8.72177 6.1464 9.03418 5.83398L13.2695 1.59961H10.4004C9.95856 1.59961 9.59961 1.24163 9.59961 0.799805C9.59972 0.358067 9.95863 0 10.4004 0H15.2002Z" fill="#EFF0FA"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 6.3V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.04004 14.19C9.77004 15 10.83 15.5 12 15.5C14.21 15.5 16 13.71 16 11.5V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.77979 16.9499C8.14979 18.2199 9.97978 18.9999 11.9998 18.9999C16.2198 18.9999 19.6498 15.5699 19.6498 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.3501 9.6499V11.3499C4.3501 12.4099 4.5601 13.4099 4.9501 14.3299" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0702 2.83984L3.93018 18.9898" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 3V6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15.5C14.21 15.5 16 13.71 16 11.5V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11.5C8 13.71 9.79 15.5 12 15.5Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.35059 9.6499V11.3499C4.35059 15.5699 7.78059 18.9999 12.0006 18.9999C16.2206 18.9999 19.6506 15.5699 19.6506 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.40023 8.79965C6.44894 8.79967 6.49651 8.80496 6.54281 8.81332C6.54796 8.81425 6.5533 8.81424 6.55844 8.81527C6.56666 8.81692 6.57472 8.81923 6.58285 8.82113C6.5992 8.82496 6.61555 8.82896 6.63168 8.83383C6.64056 8.83651 6.64929 8.83963 6.65805 8.84262C6.69881 8.85648 6.73865 8.87374 6.77719 8.89437C6.80436 8.90894 6.83012 8.9257 6.85531 8.9432C6.89387 8.97001 6.93129 8.99965 6.96566 9.03402C6.9997 9.06805 7.02889 9.10526 7.05551 9.1434C7.05897 9.14836 7.06289 9.153 7.06625 9.15804C7.12722 9.24979 7.16631 9.35128 7.18539 9.4559C7.19389 9.50255 7.20001 9.55035 7.20004 9.59945V14.4002C7.19984 14.8418 6.84178 15.1998 6.40023 15.2C5.95852 15.2 5.59964 14.8419 5.59945 14.4002V11.5311L1.36508 15.7655C1.05264 16.0777 0.546577 16.0778 0.234217 15.7655C-0.0781257 15.4531 -0.0780189 14.947 0.234217 14.6346L4.46762 10.4002H1.59945C1.15782 10.4 0.799647 10.0411 0.799647 9.59945C0.799876 9.15796 1.15796 8.79988 1.59945 8.79965H6.40023ZM14.6346 0.234217C14.947 -0.0780189 15.4531 -0.0781257 15.7655 0.234217C16.0778 0.546577 16.0777 1.05264 15.7655 1.36508L11.5321 5.59945H14.4002C14.8419 5.59964 15.2 5.95852 15.2 6.40023C15.1998 6.84178 14.8418 7.19984 14.4002 7.20004H9.59945C9.55035 7.20001 9.50255 7.19389 9.4559 7.18539C9.45074 7.18445 9.44541 7.18448 9.44027 7.18344C9.43204 7.18177 9.42399 7.17949 9.41586 7.17758C9.3995 7.17372 9.38317 7.16978 9.36703 7.16488C9.35943 7.16257 9.35207 7.1596 9.34457 7.15707C9.30221 7.14282 9.2605 7.12587 9.22054 7.10433C9.19365 7.08982 9.16833 7.0729 9.1434 7.05551C9.10526 7.02889 9.06805 6.9997 9.03402 6.96566C8.99965 6.93129 8.97001 6.89387 8.9432 6.85531C8.9257 6.83012 8.90894 6.80436 8.89437 6.77719C8.85465 6.70298 8.82796 6.62378 8.81332 6.54281C8.80496 6.49651 8.79967 6.44894 8.79965 6.40023V1.59945C8.79988 1.15796 9.15796 0.799876 9.59945 0.799647C10.0411 0.799647 10.4 1.15782 10.4002 1.59945V4.46762L14.6346 0.234217Z" fill="#EFF0FA"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 0C15.523 0 20 4.477 20 10C20 15.523 15.523 20 10 20C4.477 20 0 15.523 0 10C0 4.477 4.477 0 10 0ZM10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 12.1217 2.84285 14.1566 4.34315 15.6569C5.84344 17.1571 7.87827 18 10 18C12.1217 18 14.1566 17.1571 15.6569 15.6569C17.1571 14.1566 18 12.1217 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2ZM10 13C10.2652 13 10.5196 13.1054 10.7071 13.2929C10.8946 13.4804 11 13.7348 11 14C11 14.2652 10.8946 14.5196 10.7071 14.7071C10.5196 14.8946 10.2652 15 10 15C9.73478 15 9.48043 14.8946 9.29289 14.7071C9.10536 14.5196 9 14.2652 9 14C9 13.7348 9.10536 13.4804 9.29289 13.2929C9.48043 13.1054 9.73478 13 10 13ZM10 4C10.2652 4 10.5196 4.10536 10.7071 4.29289C10.8946 4.48043 11 4.73478 11 5V11C11 11.2652 10.8946 11.5196 10.7071 11.7071C10.5196 11.8946 10.2652 12 10 12C9.73478 12 9.48043 11.8946 9.29289 11.7071C9.10536 11.5196 9 11.2652 9 11V5C9 4.73478 9.10536 4.48043 9.29289 4.29289C9.48043 4.10536 9.73478 4 10 4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -35,6 +35,8 @@
<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> <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>
<string>Нужен для звука в видеозвонках с консультантом</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string></string> <string></string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
@@ -72,6 +74,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>

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

@@ -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;
@@ -135,6 +136,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';
@@ -33,6 +35,7 @@ import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.da
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart'; import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart'; import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:eatvio_app/features/settings/repositories/service_repository.dart'; import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
/// Регистрация всех Repository в системе внедрения зависимостей /// Регистрация всех Repository в системе внедрения зависимостей
@@ -49,6 +52,7 @@ class RepositoryRegistrations {
static void _registerSingletonRepositories() { static void _registerSingletonRepositories() {
// Core repositories // Core repositories
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl())); sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl())); sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl())); sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl())); sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
@@ -72,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,6 +36,7 @@ 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_service.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
/// Регистрация всех Service в системе внедрения зависимостей /// Регистрация всех Service в системе внедрения зависимостей
@@ -50,6 +52,7 @@ class ServiceRegistrations {
static void _registerSingletonServices() { static void _registerSingletonServices() {
// Core services // Core services
sl.registerLazySingleton<ChatService>(() => ChatService(sl())); sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
sl.registerLazySingleton<FeedService>(() => FeedService(sl())); sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl())); sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl())); sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
@@ -80,6 +83,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

@@ -45,6 +45,7 @@ class ErrorReportPolicy {
case DioExceptionType.sendTimeout: case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout: case DioExceptionType.receiveTimeout:
case DioExceptionType.connectionError: case DioExceptionType.connectionError:
case DioExceptionType.transformTimeout:
return true; return true;
case DioExceptionType.unknown: case DioExceptionType.unknown:
case DioExceptionType.badResponse: case DioExceptionType.badResponse:

View File

@@ -14,6 +14,7 @@ import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart';
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/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';
@@ -210,13 +211,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(
@@ -224,6 +235,27 @@ class OtherRoutes {
name: 'emptyChatScreen', name: 'emptyChatScreen',
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel), builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
), ),
GoRoute(
path: RouteConstants.videoCallScreen,
name: VideoCallScreen.name,
builder: (context, state) {
final extra = state.extra;
if (extra is Map<String, dynamic>) {
return VideoCallScreen(
conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?,
localParticipantName: extra['localParticipantName'] as String?,
onBackToChat: extra['onBackToChat'] as void Function()?,
onHangUp: extra['onHangUp'] as void Function()?,
);
}
final uuid = state.uri.queryParameters['uuid'];
if (uuid != null) {
return VideoCallScreen(conversationUuid: uuid);
}
throw ArgumentError('VideoCallScreen: pass extra Map with conversationUuid or query uuid');
},
),
// Consultant routes // Consultant routes
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),

View File

@@ -53,6 +53,7 @@ class RouteConstants {
static const String photoViewScreen = '/photo-view-screen'; static const String photoViewScreen = '/photo-view-screen';
static const String chatScreen = '/chat-screen'; static const String chatScreen = '/chat-screen';
static const String emptyChatScreen = '/empty-chat-screen'; static const String emptyChatScreen = '/empty-chat-screen';
static const String videoCallScreen = '/video-call-screen';
static const String glossaryScreen = '/glossary-screen'; static const String glossaryScreen = '/glossary-screen';
static const String blockedUsersScreen = '/blocked-users-screen'; static const String blockedUsersScreen = '/blocked-users-screen';
static const String notificationsSettingsScreen = '/notifications-settings-screen'; static const String notificationsSettingsScreen = '/notifications-settings-screen';

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Helper class for managing themes and colors. /// Helper class for managing themes and colors.
class ThemeHelper { class ThemeHelper {
// The current app theme // The current app theme
@@ -200,6 +199,12 @@ class PrimaryColors {
Color get grey7B => const Color(0xFF7B7B7B); Color get grey7B => const Color(0xFF7B7B7B);
Color get greyD9 => const Color(0xFFD9D9D9); Color get greyD9 => const Color(0xFFD9D9D9);
Color get videoCallControlOffBackground => const Color(0xFFED2F2F);
Color get videoCallPipIconBackground => const Color(0xFF565656);
Color get videoCallNoticeBannerBackground => const Color(0xFF9D9D9D);
} }
PrimaryColors get appTheme => ThemeHelper().themeColor(); PrimaryColors get appTheme => ThemeHelper().themeColor();

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

@@ -91,17 +91,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
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');
}
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);
emit( emit(
ChatRoomState.loaded( ChatRoomState.loaded(
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan), chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
@@ -115,6 +136,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
if (chosenPlan?.consultantId == chatRoom.users.sender.id) { if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
await checkCards(); await checkCards();
} }
return resolvedUuid;
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
log(e.toString()); log(e.toString());
@@ -122,6 +144,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
messagesSubscription?.cancel(); messagesSubscription?.cancel();
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных')); emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null;
} }
} }
@@ -297,7 +320,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
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);

View File

@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -35,10 +36,11 @@ import 'package:intl/intl.dart';
import 'package:visibility_detector/visibility_detector.dart'; import 'package:visibility_detector/visibility_detector.dart';
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
final String uuid; final String? uuid;
final String? orderId;
final bool? sendInitialMessage; final bool? sendInitialMessage;
const ChatScreen({required this.uuid, super.key, required this.sendInitialMessage}); const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); State<ChatScreen> createState() => _ChatScreenState();
@@ -54,6 +56,11 @@ class _ChatScreenState extends State<ChatScreen> {
late AppLifecycleListener _listener; late AppLifecycleListener _listener;
List<File> pickedFiles = []; List<File> pickedFiles = [];
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
String? _effectiveUuid;
String? get _conversationUuid => _effectiveUuid ?? widget.uuid;
void _onHide() { void _onHide() {
chatRoomCubit.cancelTimer(); chatRoomCubit.cancelTimer();
} }
@@ -63,26 +70,41 @@ class _ChatScreenState extends State<ChatScreen> {
chatRoomCubit.setTimer(); chatRoomCubit.setTimer();
} }
Future<void> _initChatRoom() async {
final uuid = await chatRoomCubit.initChat(
_conversationUuid,
orderId: _effectiveUuid == null ? widget.orderId : null,
sendInitialMessage: widget.sendInitialMessage ?? false,
);
if (uuid != null && mounted) {
setState(() => _effectiveUuid = uuid);
}
}
@override @override
void didUpdateWidget(ChatScreen oldWidget) { void didUpdateWidget(ChatScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.uuid != widget.uuid) { if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) {
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false); _effectiveUuid = widget.uuid;
_initChatRoom();
} }
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_effectiveUuid = widget.uuid;
scrollController.addListener(_onScroll); scrollController.addListener(_onScroll);
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false); _initChatRoom();
chatRoomCubit.setTimer(); chatRoomCubit.setTimer();
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume); _listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
} }
void _onScroll() { void _onScroll() {
final uuid = _conversationUuid;
if (uuid == null) return;
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) { if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
chatRoomCubit.loadNextPage(widget.uuid); chatRoomCubit.loadNextPage(uuid);
} }
} }
@@ -116,7 +138,7 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
), ),
), ),
body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)), body: DataErrorWidget(onReload: _initChatRoom),
); );
} else { } else {
return Scaffold( return Scaffold(
@@ -233,6 +255,23 @@ class _ChatScreenState extends State<ChatScreen> {
], ],
), ),
), ),
actions: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(
onPressed: () {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': _conversationUuid,
'counterpartName': state.chatRoom.users.receiver.name,
'localParticipantName': state.chatRoom.users.sender.name,
},
);
},
),
),
],
), ),
body: SafeArea( body: SafeArea(
child: Stack( child: Stack(
@@ -741,3 +780,35 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
} }
class _ChatHeaderVideoCallButton extends StatelessWidget {
const _ChatHeaderVideoCallButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tooltipMessage = S.maybeOf(context)?.video_call;
final button = Material(
color: theme.colorScheme.onPrimary,
borderRadius: BorderRadius.circular(20),
elevation: 0,
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: SizedBox(
width: 40,
height: 40,
child: Center(
child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
),
),
),
);
if (tooltipMessage == null || tooltipMessage.isEmpty) {
return button;
}
return Tooltip(message: tooltipMessage, child: button);
}
}

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

@@ -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,6 +3,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/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -65,6 +66,12 @@ 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/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

@@ -106,9 +106,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
backgroundColor: appTheme.gray100, backgroundColor: appTheme.gray100,
resizeToAvoidBottomInset: true, resizeToAvoidBottomInset: true,
bottomSheet: Container( bottomSheet: Container(
padding: EdgeInsets.only( padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
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))),

View File

@@ -1,4 +1,5 @@
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/post_feed_pagination_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'feed_blog_post_model.freezed.dart'; part 'feed_blog_post_model.freezed.dart';
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
@JsonKey(name: "total_views") required int totalViews, @JsonKey(name: "total_views") required int totalViews,
@JsonKey(name: "total_comments") required int totalComments, @JsonKey(name: "total_comments") required int totalComments,
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe, @JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
@Default([]) List<FeedGalleryImage> images,
@JsonKey(name: "video_embed_url") String? videoEmbedUrl,
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
}) = _FeedBlogPostModel; }) = _FeedBlogPostModel;
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json); factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);

View File

@@ -0,0 +1,41 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
class PostFeedMapper {
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
return FeedBlogPostModel(
id: item.postId,
state: item.postState,
image: resolveMediaUrl(item.postImageUrl),
description: item.postTextMd ?? item.postShortDescription ?? '',
author: Author(
authorSlug: item.postAuthorDetails?.slug ?? '',
authorName: item.postAuthorName,
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
),
videoUrl: '',
createdAt: item.postCreatedAt,
updatedAt: item.updatedAt,
title: item.postTitle,
totalLikes: item.postLikesCount,
totalViews: item.postViewsCount,
totalComments: item.postCommentsCount,
wasLikedByMe: item.postIsLikedByUser,
images: item.images,
videoEmbedUrl: item.videoEmbedUrl,
videoUrlThumb: item.videoUrlThumb,
);
}
static String resolveMediaUrl(String? url) {
if (url == null || url.isEmpty) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
return '${sl<AppConfig>().baseUrl}/storage/$url';
}
}

View File

@@ -0,0 +1,93 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'post_feed_pagination_model.freezed.dart';
part 'post_feed_pagination_model.g.dart';
@freezed
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
const factory PostFeedPaginationModel({
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
required CursorLinks links,
required CursorMeta meta,
}) = _PostFeedPaginationModel;
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
}
@freezed
abstract class PostFeedItemModel with _$PostFeedItemModel {
const factory PostFeedItemModel({
required int id,
@JsonKey(name: 'post_id') required int postId,
@JsonKey(name: 'post_author_id') required int postAuthorId,
@JsonKey(name: 'post_author_name') required String postAuthorName,
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
@JsonKey(name: 'post_title') required String postTitle,
@JsonKey(name: 'post_short_description') String? postShortDescription,
@JsonKey(name: 'post_text_md') String? postTextMd,
@JsonKey(name: 'post_image_url') String? postImageUrl,
@Default([]) List<FeedGalleryImage> images,
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
@JsonKey(name: 'post_views_count') required int postViewsCount,
@JsonKey(name: 'post_likes_count') required int postLikesCount,
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
@JsonKey(name: 'post_url') String? postUrl,
@JsonKey(name: 'post_state') required String postState,
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
}) = _PostFeedItemModel;
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
}
@freezed
abstract class FeedGalleryImage with _$FeedGalleryImage {
const factory FeedGalleryImage({
required String url,
required String orientation,
}) = _FeedGalleryImage;
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
}
@freezed
abstract class PostAuthorDetails with _$PostAuthorDetails {
const factory PostAuthorDetails({
required String slug,
@JsonKey(name: 'is_consultant') required bool isConsultant,
}) = _PostAuthorDetails;
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
}
@freezed
abstract class CursorLinks with _$CursorLinks {
const factory CursorLinks({
required String? first,
required String? last,
required String? prev,
required String? next,
}) = _CursorLinks;
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
}
@freezed
abstract class CursorMeta with _$CursorMeta {
const factory CursorMeta({
required String path,
@JsonKey(name: 'per_page') required int perPage,
@JsonKey(name: 'next_cursor') String? nextCursor,
@JsonKey(name: 'prev_cursor') String? prevCursor,
}) = _CursorMeta;
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
}
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();

View File

@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class FeedCubit extends Cubit<FeedState> { class FeedCubit extends Cubit<FeedState> {
final FeedRepository feedRepository; final FeedRepository feedRepository;
int currentPage = 1; String? _nextCursor;
late int lastPage; bool _hasMore = true;
bool isLoading = false; bool isLoading = false;
String? _query; String? _query;
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
} }
} }
Future<void> loadFeedBlogPostList({String? query}) async { Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
try { try {
_query = query; _query = query;
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query); _nextCursor = null;
lastPage = feedBlogPosts.meta.lastPage; _hasMore = true;
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed) { if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false)); emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
} }
} catch (e, st) { } catch (e, st) {
await reportFrom(e, st, this); await reportFrom(e, st, this);
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
} }
} }
Future<void> loadNextPage() async { Future<void> loadNextPage({int? currentUserId}) async {
if (currentPage < lastPage && !isLoading && !isClosed) { if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
isLoading = true; return;
}
if (!isClosed) { isLoading = true;
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading)); emit((state as FeedStateLoaded).copyWith(isLoading: true));
}
try { try {
if (currentPage < lastPage && !isClosed) { final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
currentPage++; _nextCursor = feed.nextCursor;
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query); _hasMore = _nextCursor != null;
isLoading = false; if (!isClosed && state is FeedStateLoaded) {
if (!isClosed) { emit(
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading)); FeedState.loaded(
} feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
} isLoading: false,
} catch (e, st) { ),
await reportFrom(e, st, this); );
if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
}
} }
} catch (e, st) {
await reportFrom(e, st, this);
if (!isClosed && state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
}
} finally {
isLoading = false;
} }
} }

View File

@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
final state = feedCubit.state; final state = feedCubit.state;
// Загружаем данные, если они еще не загружены // Загружаем данные, если они еще не загружены
if (state is! FeedStateLoaded) { if (state is! FeedStateLoaded) {
feedCubit.loadFeedBlogPostList(); feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
} }
}); });
super.initState(); super.initState();
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
switch (_selectedChipIndex) { switch (_selectedChipIndex) {
case 0: case 0:
context.read<FeedCubit>().loadFeedBlogPostList(query: query); context.read<FeedCubit>().loadFeedBlogPostList(
query: query,
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
);
break; break;
case 1 when !hidePlans: case 1 when !hidePlans:
context.read<PlansCubit>().loadPlans(query: query); context.read<PlansCubit>().loadPlans(query: query);
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () { EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
switch (_selectedChipIndex) { switch (_selectedChipIndex) {
case 0: case 0:
context.read<FeedCubit>().loadFeedBlogPostList(); context.read<FeedCubit>().loadFeedBlogPostList(
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
);
break; break;
case 1 when !hidePlans: case 1 when !hidePlans:
context.read<PlansCubit>().loadPlans(); context.read<PlansCubit>().loadPlans();

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
import 'package:dismissible_page/dismissible_page.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';
@@ -9,8 +12,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/feed_icon_button.dart'; import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart'; import 'package:eatvio_app/core/widgets/markdown_html_widget.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/feed/models/feed_blog_post_model.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/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.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';
@@ -19,9 +24,9 @@ import 'package:go_router/go_router.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
class BlogPostWidget extends StatefulWidget { class BlogPostWidget extends StatefulWidget {
final int index; final FeedBlogPostModel post;
const BlogPostWidget({super.key, required this.index}); const BlogPostWidget({super.key, required this.post});
@override @override
State<BlogPostWidget> createState() => _BlogPostWidgetState(); State<BlogPostWidget> createState() => _BlogPostWidgetState();
@@ -30,243 +35,448 @@ class BlogPostWidget extends StatefulWidget {
class _BlogPostWidgetState extends State<BlogPostWidget> { class _BlogPostWidgetState extends State<BlogPostWidget> {
late bool isLiked; late bool isLiked;
late int totalLikes; late int totalLikes;
bool isMoreOptionsVisible = false; int currentGalleryIndex = 0;
FeedBlogPostModel get post => widget.post;
@override
void initState() {
super.initState();
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
}
@override
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.post.id != post.id) {
currentGalleryIndex = 0;
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
return;
}
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
isLiked = post.wasLikedByMe;
totalLikes = post.totalLikes;
}
}
void _openGallery(List<String> imageUrls, int index) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
}
Future<void> _toggleLike() async {
final nextLiked = !isLiked;
setState(() {
isLiked = nextLiked;
nextLiked ? totalLikes++ : totalLikes--;
});
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
if (nextLiked) {
await context.read<FeedCubit>().likePost(post.id);
} else {
await context.read<FeedCubit>().unlikedPost(post.id);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<FeedCubit, FeedState>( final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
builder: (BuildContext context, FeedState state) { final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList();
if (state is FeedStateLoaded) { final embedUrl = post.videoEmbedUrl?.trim();
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
},
child: CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: NetworkImage(
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
? state.feedBlogPostList[widget.index].author.authorProfileIcon
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
),
const SizedBox(width: 8),
// Имя и дата внутри Expanded
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.feedBlogPostList[widget.index].author.authorName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
),
],
),
),
// Кнопка подписки (если нужно)
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
!state.feedBlogPostList[widget.index].author.authorIsMe)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
),
// Кнопка меню
CustomPopup(
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
id: state.feedBlogPostList[widget.index].id,
feedBlogPostModel: state.feedBlogPostList[widget.index],
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
),
],
),
),
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
state.feedBlogPostList[widget.index].title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
),
),
],
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
const SizedBox(height: 8),
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Builder(
builder: (context) {
String description = state.feedBlogPostList[widget.index].description;
final isLong = description.length > 200;
if (isLong) {
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
int cutPoint = 200;
// Проверяем, есть ли незакрытые markdown теги
String preview = description.substring(0, cutPoint);
// Считаем открывающие и закрывающие теги
int boldOpen = (preview.split('**').length - 1) ~/ 2;
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
bool hasUnclosedBold = boldOpen > boldClose;
// Если есть незакрытый bold, ищем закрытие
if (hasUnclosedBold) {
int searchEnd = (cutPoint + 100).clamp(0, description.length);
int boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
// Если не нашли, обрезаем до последнего пробела
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
// Обрезаем до последнего пробела для красоты
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
description = description.substring(0, cutPoint);
}
return MarkdownHtmlWidget( return InkWell(
markdownText: description, borderRadius: BorderRadius.circular(12),
); onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
}, child: Material(
), borderRadius: BorderRadius.circular(12),
), color: Colors.white,
if (state.feedBlogPostList[widget.index].description.length > 200) child: Column(
const Padding( mainAxisSize: MainAxisSize.min,
padding: EdgeInsets.symmetric(horizontal: 12), crossAxisAlignment: CrossAxisAlignment.start,
child: Text( children: [
'Показать ещё', const SizedBox(height: 12),
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600), _BlogPostHeader(post: post),
), _BlogPostTextContent(post: post),
), _BlogPostMediaContent(
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty) galleryUrls: galleryUrls,
const SizedBox(height: 8), imageUrl: imageUrl,
GestureDetector( videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
onTap: () { videoUrlThumb: post.videoUrlThumb,
context.pushTransparentRoute( currentGalleryIndex: currentGalleryIndex,
rootNavigator: true, onOpenGallery: _openGallery,
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0), onGalleryChanged: (index) {
); setState(() {
}, currentGalleryIndex = index;
child: Hero( });
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}', },
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: () async {
setState(() {
isLiked = !isLiked;
isLiked ? totalLikes++ : totalLikes--;
});
// Update feed list immediately by ID (no reload)
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
// Fire network call without introducing UI delay
if (isLiked) {
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
} else {
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
}
},
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
'assets/icons/comment.svg',
height: 20,
width: 20,
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
text: state.feedBlogPostList[widget.index].totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(
height: 15,
width: 12,
'assets/icons/repost.svg',
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
state.feedBlogPostList[widget.index].totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
),
const SizedBox(height: 8),
],
),
), ),
); const SizedBox(height: 12),
} _BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
return const SizedBox.shrink(); const SizedBox(height: 8),
}, ],
),
),
); );
} }
} }
class _BlogPostHeader extends StatelessWidget {
const _BlogPostHeader({required this.post});
final FeedBlogPostModel post;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
child: CircleAvatar(
radius: 20,
backgroundColor: Colors.black12,
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
post.author.authorName,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
),
],
),
),
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
Padding(
padding: const EdgeInsets.only(right: 8),
child: SubscribeButtonWidget(slug: post.author.authorSlug),
),
CustomPopup(
isAuthor: post.author.authorIsMe,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${post.id}',
id: post.id,
feedBlogPostModel: post,
userSlug: post.author.authorSlug,
),
],
),
);
}
}
class _BlogPostTextContent extends StatelessWidget {
const _BlogPostTextContent({required this.post});
final FeedBlogPostModel post;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (post.title.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
post.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
),
),
],
if (post.title.isEmpty) const SizedBox(height: 8),
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
if (post.description.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
),
if (post.description.length > 200)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
),
),
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
],
);
}
}
class _BlogPostMediaContent extends StatefulWidget {
const _BlogPostMediaContent({
required this.galleryUrls,
required this.imageUrl,
required this.videoEmbedUrl,
required this.currentGalleryIndex,
required this.onOpenGallery,
required this.onGalleryChanged,
required this.videoUrlThumb,
});
final List<String> galleryUrls;
final String imageUrl;
final String? videoEmbedUrl;
final String? videoUrlThumb;
final int currentGalleryIndex;
final void Function(List<String> urls, int index) onOpenGallery;
final ValueChanged<int> onGalleryChanged;
@override
State<_BlogPostMediaContent> createState() => _BlogPostMediaContentState();
}
class _BlogPostMediaContentState extends State<_BlogPostMediaContent> {
static const _counterDisplayDuration = Duration(seconds: 1);
bool _isCounterVisible = false;
Timer? _counterHideTimer;
bool get _isCurrentSlideVideo =>
widget.videoEmbedUrl != null && widget.currentGalleryIndex == widget.galleryUrls.length;
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
@override
void dispose() {
_counterHideTimer?.cancel();
super.dispose();
}
@override
void didUpdateWidget(covariant _BlogPostMediaContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
_scheduleCounterHideOnVideo();
}
}
void _scheduleCounterHideOnVideo() {
_counterHideTimer?.cancel();
if (!_isCurrentSlideVideo) return;
_counterHideTimer = Timer(_counterDisplayDuration, () {
if (mounted && _isCurrentSlideVideo) {
setState(() => _isCounterVisible = false);
}
});
}
bool _handleCarouselScroll(ScrollNotification notification) {
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
_counterHideTimer?.cancel();
if (!_isCounterVisible) {
setState(() => _isCounterVisible = true);
}
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
_scheduleCounterHideOnVideo();
}
return false;
}
@override
Widget build(BuildContext context) {
final hasVideo = widget.videoEmbedUrl != null;
final mediaContentCount = hasVideo ? widget.galleryUrls.length + 1 : widget.galleryUrls.length;
final showCarousel = mediaContentCount > 1;
return Column(
children: [
if (showCarousel) ...[
Stack(
children: [
NotificationListener<ScrollNotification>(
onNotification: _handleCarouselScroll,
child: CarouselSlider.builder(
itemCount: mediaContentCount,
itemBuilder: (context, itemIndex, _) {
if (itemIndex == widget.galleryUrls.length) {
if (widget.videoEmbedUrl != null) {
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
}
return const SizedBox.shrink();
}
final itemUrl = widget.galleryUrls[itemIndex];
return GestureDetector(
onTap: () => widget.onOpenGallery(widget.galleryUrls, itemIndex),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
imageUrl: itemUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
);
},
options: CarouselOptions(
viewportFraction: 1,
height: 260,
enableInfiniteScroll: false,
onPageChanged: (index, _) => widget.onGalleryChanged(index),
),
),
),
Positioned(
top: 12,
right: 12,
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _shouldShowCounter ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: mediaContentCount),
),
),
),
],
),
] else if (widget.galleryUrls.length == 1) ...[
GestureDetector(
onTap: () => widget.onOpenGallery(widget.galleryUrls, 0),
child: CachedNetworkImage(
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
height: 260,
imageUrl: widget.galleryUrls.first,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
] else if (widget.imageUrl.isNotEmpty) ...[
GestureDetector(
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
child: Hero(
tag: widget.imageUrl,
child: CachedNetworkImage(
fit: BoxFit.fitWidth,
width: MediaQuery.of(context).size.width,
imageUrl: widget.imageUrl,
errorWidget: (context, str, object) => const SizedBox.shrink(),
),
),
),
],
],
);
}
}
class _GalleryPhotoCounter extends StatelessWidget {
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
final int currentIndex;
final int total;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
const SizedBox(width: 8),
Text(
'${currentIndex + 1} / $total',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
),
],
),
);
}
}
class _BlogPostActionsRow extends StatelessWidget {
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
final FeedBlogPostModel post;
final bool isLiked;
final int totalLikes;
final Future<void> Function() onToggleLike;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
FeedIconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
onTap: onToggleLike,
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
text: post.totalComments.toString(),
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
),
const SizedBox(width: 8),
FeedIconButton(
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
onTap: () {
final box = context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
const SizedBox(width: 4),
Text(
post.totalViews.toString(),
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
),
],
),
);
}
}
String _truncateDescription(String description) {
final isLong = description.length > 200;
if (!isLong) return description;
var cutPoint = 200;
final preview = description.substring(0, cutPoint);
final boldOpen = (preview.split('**').length - 1) ~/ 2;
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
final hasUnclosedBold = boldOpen > boldClose;
if (hasUnclosedBold) {
final searchEnd = (cutPoint + 100).clamp(0, description.length);
final boldClosePos = description.indexOf('**', cutPoint);
if (boldClosePos != -1 && boldClosePos < searchEnd) {
cutPoint = boldClosePos + 2;
} else {
cutPoint = description.lastIndexOf(' ', 200);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
} else {
cutPoint = description.lastIndexOf(' ', cutPoint);
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
}
return description.substring(0, cutPoint);
}

View File

@@ -3,6 +3,7 @@ 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/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/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart'; import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart'; import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
class BlogPosts extends StatelessWidget { class BlogPosts extends StatelessWidget {
const BlogPosts({super.key, required this.handleVisibility}); const BlogPosts({super.key, required this.handleVisibility});
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility; final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(), onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
child: BlocBuilder<FeedCubit, FeedState>( child: BlocBuilder<FeedCubit, FeedState>(
builder: (BuildContext context, FeedState state) { builder: (BuildContext context, FeedState state) {
if (state is FeedStateLoaded) { if (state is FeedStateLoaded) {
return NotificationListener<ScrollNotification>( return NotificationListener<ScrollNotification>(
onNotification: (notification) { onNotification: (notification) {
loadNewContent(notification, context.read<FeedCubit>().loadNextPage); loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
return false; return false;
}, },
child: ListView.separated( child: ListView.separated(
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
if (index == state.feedBlogPostList.length) { if (index == state.feedBlogPostList.length) {
return const Loader(); return const Loader();
} }
final post = state.feedBlogPostList[index];
return VisibilityDetector( return VisibilityDetector(
key: ValueKey(post.id),
onVisibilityChanged: (info) { onVisibilityChanged: (info) {
final id = state.feedBlogPostList[index].id; handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
}, },
key: ValueKey(index), child: BlogPostWidget(post: post),
child: BlogPostWidget(index: index),
); );
}, },
separatorBuilder: (context, index) => const SizedBox(height: 8), separatorBuilder: (context, index) => const SizedBox(height: 8),
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
), ),
); );
} else if (state is FeedStateError) { } else if (state is FeedStateError) {
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList()); return DataErrorWidget(
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
);
} else { } else {
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader()); return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
} }

View File

@@ -0,0 +1,70 @@
import 'dart:convert';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
enum FeedVideoEmbedProvider { vk, other }
/// Сборка URL и HTML для embed-плеера ленты.
abstract final class FeedVideoEmbed {
static FeedVideoEmbedProvider detectProvider(Uri uri) {
final host = uri.host.toLowerCase();
final isVk = host == 'vk.com' ||
host.endsWith('.vk.com') ||
host == 'vk.ru' ||
host.endsWith('.vk.ru') ||
host == 'vkvideo.ru' ||
host.endsWith('.vkvideo.ru');
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
}
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
static Uri uriForPlayback(String url) {
final uri = Uri.parse(url);
final params = Map<String, String>.from(uri.queryParameters);
params['autoplay'] = '1';
params['mute'] = '1';
params.putIfAbsent('playsinline', () => '1');
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
params['js_api'] = '1';
}
return uri.replace(queryParameters: params);
}
static String buildHtml(String embedUrl) {
final uri = Uri.parse(embedUrl);
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
</style>
$headScripts
</head>
<body>
<iframe
id="player"
src="$src"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
<script>
$bootstrap
</script>
</body>
</html>
''';
}
}

View File

@@ -0,0 +1,70 @@
/// JS-фрагменты для embed-плеера ленты.
///
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
abstract final class FeedVideoEmbedScripts {
static const readyChannel = 'EmbedReady';
/// Уведомляет Flutter, что embed готов к показу.
static String get notifyReady =>
'''
function notifyReady() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
} catch (e) {}
}
''';
/// Уведомляет Flutter об ошибке воспроизведения.
static String get notifyError =>
'''
function notifyError() {
try {
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
} catch (e) {}
}
''';
/// Rutube: готовность через postMessage `player:canPlay`.
static String get rutubeCanPlayListener => '''
window.addEventListener('message', function (event) {
try {
var type = JSON.parse(event.data).type;
if (type === 'player:canPlay') {
notifyReady();
}
} catch (e) {}
});
''';
/// VK Video JS API: воспроизведение началось на событии `started`.
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
static String get vkPlayerReadyHandler => '''
try {
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
vkPlayer.on('started', function () {
notifyReady();
});
vkPlayer.on('error', function () {
notifyError();
});
}
} catch (e) {}
''';
/// Внешний скрипт VK VideoPlayer API.
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
static String bootstrap({required bool includeVk}) {
final vkPart = includeVk ? vkPlayerReadyHandler : '';
return '''
(function () {
$notifyReady
$notifyError
$rutubeCanPlayListener
$vkPart
})();
''';
}
}

View File

@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Embed-видео в ленте.
///
/// 1) Пока пользователь не нажал play — только превью (без WebView).
/// 2) После play — превью + лоадер, WebView грузится скрыто.
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
///
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
class FeedVideoPlayer extends StatefulWidget {
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
final String embedUrl;
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
final String? videoUrlThumb;
@override
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
}
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
static const Duration _readyFallbackDelay = Duration(seconds: 4);
/// Пока null — WebView не рендерится. Заполняется по жесту play.
InAppWebViewInitialData? _initialData;
bool _isLoading = false;
bool _isReady = false;
bool _hasError = false;
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
Timer? _readyFallback;
@override
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
_reset();
}
}
@override
void dispose() {
_readyFallback?.cancel();
super.dispose();
}
void _reset() {
_readyFallback?.cancel();
_readyFallback = null;
_initialData = null;
_isLoading = false;
_isReady = false;
_hasError = false;
}
void _markReady() {
if (!mounted || _isReady || _hasError) return;
_readyFallback?.cancel();
_readyFallback = null;
setState(() {
_isReady = true;
_isLoading = false;
});
}
void _markError() {
if (!mounted || _isReady) return;
_readyFallback?.cancel();
_readyFallback = null;
setState(() {
_hasError = true;
_isLoading = false;
_initialData = null;
});
}
Future<void> _onPlayPressed() async {
if (_isLoading || _isReady || !mounted) return;
setState(() {
_isLoading = true;
_hasError = false;
});
try {
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
if (!mounted) return;
setState(() {
_initialData = InAppWebViewInitialData(
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
mimeType: 'text/html',
encoding: 'utf8',
baseUrl: WebUri(baseUrl),
);
});
} catch (e, st) {
if (kDebugMode) {
debugPrint('FeedVideoPlayer load failed: $e\n$st');
}
_markError();
}
}
InAppWebViewSettings get _settings => InAppWebViewSettings(
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
mediaPlaybackRequiresUserGesture: false,
allowsInlineMediaPlayback: true,
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
iframeAllowFullscreen: true,
transparentBackground: false,
);
void _onWebViewCreated(InAppWebViewController controller) {
controller.addJavaScriptHandler(
handlerName: FeedVideoEmbedScripts.readyChannel,
callback: (args) {
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
if (status == 'error') {
_markError();
} else {
_markReady();
}
},
);
}
void _onLoadStop(InAppWebViewController _, WebUri? _) {
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
if (_isReady || _hasError || !mounted) return;
_readyFallback?.cancel();
_readyFallback = Timer(_readyFallbackDelay, () {
if (mounted && !_isReady && !_hasError) _markReady();
});
}
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
if (!mounted) return;
if (request.isForMainFrame != true) return;
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
if (_isReady) return;
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
_markError();
}
void _onEnterFullscreen(InAppWebViewController _) {
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
}
void _onExitFullscreen(InAppWebViewController _) {
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
SystemChrome.setPreferredOrientations([]);
}
@override
Widget build(BuildContext context) {
final player = SizedBox(
height: 220,
width: MediaQuery.sizeOf(context).width,
child: Stack(
fit: StackFit.expand,
children: [
// WebView создаётся только после play и до ready остаётся скрытым под превью.
if (_initialData != null)
Opacity(
opacity: _isReady ? 1 : 0,
child: IgnorePointer(
ignoring: !_isReady,
child: InAppWebView(
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
initialData: _initialData,
initialSettings: _settings,
onWebViewCreated: _onWebViewCreated,
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
onLoadStop: _onLoadStop,
onReceivedError: _onReceivedError,
onEnterFullscreen: _onEnterFullscreen,
onExitFullscreen: _onExitFullscreen,
),
),
),
if (!_isReady)
FeedVideoPreviewPlaceholder(
previewUrl: widget.videoUrlThumb,
isLoading: _isLoading,
hasError: _hasError,
onPlay: _onPlayPressed,
onRetry: _onPlayPressed,
),
],
),
);
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
if (_isReady) return player;
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class FeedVideoPreviewPlaceholder extends StatelessWidget {
const FeedVideoPreviewPlaceholder({
super.key,
required this.previewUrl,
required this.isLoading,
required this.hasError,
required this.onPlay,
required this.onRetry,
});
final String? previewUrl;
final bool isLoading;
final bool hasError;
final VoidCallback onPlay;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
if (previewUrl != null && previewUrl!.isNotEmpty)
CachedNetworkImage(
imageUrl: previewUrl!,
fit: BoxFit.cover,
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
)
else
const ColoredBox(color: Color(0xFF2C2C2C)),
if (hasError)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
const SizedBox(height: 8),
TextButton(
onPressed: onRetry,
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
),
],
),
)
else if (isLoading)
const Center(child: CircularProgressIndicator(color: Colors.white))
else
Center(
child: GestureDetector(
onTap: onPlay,
child: Container(
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
padding: const EdgeInsets.all(12),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
),
),
),
],
);
}
}

View File

@@ -0,0 +1 @@
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';

View File

@@ -1,9 +1,9 @@
import 'package:eatvio_app/features/feed/models/comment_model.dart'; import 'package:eatvio_app/features/feed/models/comment_model.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/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/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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/models/view_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart';
import 'package:eatvio_app/features/feed/services/feed_service.dart'; import 'package:eatvio_app/features/feed/services/feed_service.dart';
@@ -13,9 +13,12 @@ class FeedRepository {
FeedRepository({required this.feedService}); FeedRepository({required this.feedService});
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async { Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
return (await feedService.getBlogPostList(page: page, query: query)); final response = await feedService.getFeed(search: search, cursor: cursor);
return (
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
nextCursor: response.meta.nextCursor,
);
} }
Future<FeedBlogPostModel> getBlogPost({required int id}) async { Future<FeedBlogPostModel> getBlogPost({required int id}) async {

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart'; import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart'; import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/view_model.dart'; import 'package:eatvio_app/features/feed/models/view_model.dart';
import 'package:retrofit/retrofit.dart'; import 'package:retrofit/retrofit.dart';
@@ -18,6 +19,12 @@ abstract class FeedService {
@GET('/api/blogPosts/index') @GET('/api/blogPosts/index')
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query}); Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
@GET('/api/feed')
Future<PostFeedPaginationModel> getFeed({
@Query('search') String? search,
@Query('cursor') String? cursor,
});
@GET('/api/blogPosts/show/{postId}') @GET('/api/blogPosts/show/{postId}')
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId}); Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});

View File

@@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit<OrderInfoState> {
try { try {
final plan = state.chosenPlan; final plan = state.chosenPlan;
if (plan == null) return null; if (plan == null) return null;
final paymentModel = await chatRepository.getPaymentLink(id: plan.id); final paymentModel = await chatRepository.getPaymentLink(id: plan.id, 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);

View File

@@ -0,0 +1,98 @@
import 'dart:developer';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sbp/models/c2bmembers_model.dart';
/// Cubit оплаты консультации через СБП: старт оплаты, список банков, редирект в банк.
///
/// Не хранит [planId] как поле — он передаётся в [startPayment].
/// Polling статуса оплаты остаётся в ChatRoomCubit.
class ConsultationPaymentCubit extends Cubit<ConsultationPaymentState> {
final PaymentRepository paymentRepository;
final SbpRepository sbpRepository;
ConsultationPaymentCubit({
required this.paymentRepository,
required this.sbpRepository,
}) : super(ConsultationPaymentState());
/// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП.
Future<void> startPayment({required int planId}) async {
try {
emit(
state.copyWith(
isLoading: true,
errorMessage: null,
banks: const [],
checkoutUrl: null,
orderId: null,
),
);
final payment = await paymentRepository.startPaymentProcess(planId: planId);
emit(
state.copyWith(
checkoutUrl: payment.paymentUrl,
orderId: payment.orderId,
),
);
try {
final banks = await sbpRepository.getInstalledBanks();
if (isClosed) return;
emit(
state.copyWith(
banks: banks,
isLoading: false,
errorMessage: null,
),
);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (isClosed) return;
emit(
state.copyWith(
isLoading: false,
banks: const [],
errorMessage: e.toString(),
),
);
}
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
if (isClosed) return;
emit(
state.copyWith(
isLoading: false,
banks: const [],
checkoutUrl: null,
orderId: null,
errorMessage: e.toString(),
),
);
}
}
/// Открывает банковское приложение [bank] с текущим [ConsultationPaymentState.checkoutUrl].
Future<bool> openBank({required C2bmemberModel bank}) async {
final checkoutUrl = state.checkoutUrl;
if (checkoutUrl == null || checkoutUrl.isEmpty) {
return false;
}
try {
return await sbpRepository.openBank(url: checkoutUrl, bank: bank);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return false;
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:sbp/models/c2bmembers_model.dart';
part 'consultation_payment_state.freezed.dart';
@freezed
abstract class ConsultationPaymentState with _$ConsultationPaymentState {
factory ConsultationPaymentState({
@Default(false) bool isLoading,
@Default([]) List<C2bmemberModel> banks,
String? checkoutUrl,
String? orderId,
String? errorMessage,
}) = _ConsultationPaymentState;
}

View File

@@ -0,0 +1,49 @@
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Оркестрирует флоу оплаты консультации:
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
Future<void> showConsultationPaymentFlow(
BuildContext context, {
required int planId,
Future<bool?> Function(BuildContext)? showGate,
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
Future<void> Function(BuildContext, {required int planId})? showSbp,
}) async {
final gate = showGate ?? _showGate;
final form = showForm ?? _showForm;
final sbp = showSbp ?? _showSbp;
final s = S.of(context);
final gateChoice = await gate(context);
if (gateChoice == null || !context.mounted) return;
if (gateChoice) {
final formResult = await form(
context,
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
);
if (formResult == null || !context.mounted) return;
}
if (!context.mounted) return;
await sbp(context, planId: planId);
}
Future<bool?> _showGate(BuildContext context) {
return showPromoCodeGateBottomSheet(context);
}
Future<PromoCodeResult?> _showForm(
BuildContext context, {
Future<PromoValidation> Function(String)? onValidate,
}) {
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
}
Future<void> _showSbp(BuildContext context, {required int planId}) {
return showSbpBanksBottomSheet(context, planId: planId);
}

View File

@@ -0,0 +1,97 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Показывает bottom sheet «У вас есть промокод?» с кнопками Да/Нет.
///
/// Возвращает `true` если пользователь нажал «Да», `false` если «Нет»,
/// `null` если закрыл шторку (бекдроп/крестик).
Future<bool?> showPromoCodeGateBottomSheet(BuildContext context) {
return showModalBottomSheet<bool?>(
context: context,
isScrollControlled: true,
barrierColor: appTheme.transparentBlack,
backgroundColor: Colors.transparent,
elevation: 0,
useSafeArea: true,
builder: (_) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: _PromoCodeGateSheet(),
),
);
}
/// Виджет-гейт выбора промокода.
class PromoCodeGateBottomSheet extends StatelessWidget {
const PromoCodeGateBottomSheet({super.key, required this.onResult});
final ValueChanged<bool?> onResult;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: BottomSheetHandlerBar()),
const SizedBox(height: 24),
Text(
s.have_promo_code,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
SizedBox(
height: 50,
child: FilledButton(
onPressed: () => onResult(true),
style: FilledButton.styleFrom(
backgroundColor: appTheme.blueA400,
foregroundColor: appTheme.whiteA700,
shape: const StadiumBorder(),
),
child: Text(
s.yes,
style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
),
),
),
const SizedBox(height: 8),
SizedBox(
height: 50,
child: OutlinedButton(
onPressed: () => onResult(false),
style: OutlinedButton.styleFrom(
foregroundColor: appTheme.blueA400,
side: BorderSide(color: appTheme.blueA400),
shape: const StadiumBorder(),
),
child: Text(
s.no,
style: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400),
),
),
),
],
),
);
}
}
/// Внутренняя реализация модалки — обёртка над [PromoCodeGateBottomSheet].
class _PromoCodeGateSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PromoCodeGateBottomSheet(
onResult: (result) => Navigator.of(context).pop(result),
);
}
}

View File

@@ -0,0 +1,281 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
/// Результат работы модалки промокода.
class PromoCodeResult {
const PromoCodeResult({this.code});
/// Применённый промокод или null, если пользователь продолжил без него.
final String? code;
bool get hasPromoCode => code != null;
}
/// Ответ проверки промокода.
class PromoValidation {
const PromoValidation.success() : error = null;
const PromoValidation.failure(this.error);
final String? error;
bool get isValid => error == null;
}
/// Модальное окно ввода промокода.
class ItvioPromoCodeModal extends StatefulWidget {
const ItvioPromoCodeModal({super.key, this.onValidate});
final Future<PromoValidation> Function(String code)? onValidate;
static Future<PromoCodeResult?> show(
BuildContext context, {
Future<PromoValidation> Function(String code)? onValidate,
}) {
return showModalBottomSheet<PromoCodeResult>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
);
}
@override
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
}
enum _ModalState { input, loading }
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
_ModalState _state = _ModalState.input;
String? _errorText;
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _apply() async {
final code = _controller.text.trim().toUpperCase();
if (code.isEmpty) {
setState(() => _errorText = S.of(context).enter_promo_code);
return;
}
setState(() {
_state = _ModalState.loading;
_errorText = null;
});
PromoValidation validation;
try {
validation = widget.onValidate != null
? await widget.onValidate!(code)
: const PromoValidation.success();
} catch (_) {
validation = const PromoValidation.failure(
'Не удалось проверить промокод. Попробуйте позже',
);
}
if (!mounted) return;
if (!validation.isValid) {
setState(() {
_state = _ModalState.input;
_errorText = validation.error;
});
_focusNode.requestFocus();
return;
}
Navigator.of(context).pop(PromoCodeResult(code: code));
}
void _onSkip() {
Navigator.of(context).pop(const PromoCodeResult());
}
bool get _isLoading => _state == _ModalState.loading;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final s = S.of(context);
return Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: BottomSheetHandlerBar()),
const SizedBox(height: 16),
_buildHeader(),
const SizedBox(height: 16),
const Text(
'Промокод',
style: TextStyle(
fontSize: 13,
color: Color(0xFF6B7684),
),
),
const SizedBox(height: 8),
_buildField(),
const SizedBox(height: 8),
_buildHint(),
const SizedBox(height: 12),
_buildApplyButton(s),
const SizedBox(height: 6),
_buildSkipButton(s),
],
),
),
);
}
Widget _buildHeader() {
final s = S.of(context);
return Text(
s.have_promo_code_form,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
);
}
Widget _buildField() {
final hasError = _errorText != null;
final s = S.of(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
decoration: BoxDecoration(
color: appTheme.gray100,
borderRadius: BorderRadius.circular(12),
border: Border.all(
width: 2,
color: hasError
? appTheme.red
: (_focusNode.hasFocus
? appTheme.blueA400
: Colors.transparent),
),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _focusNode,
enabled: !_isLoading,
textCapitalization: TextCapitalization.characters,
onChanged: (_) {
if (_errorText != null) {
setState(() => _errorText = null);
}
},
onSubmitted: (_) => _apply(),
decoration: InputDecoration(
hintText: s.enter_promo_code,
hintStyle: const TextStyle(
color: Color(0xFF9AA3AF),
),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
style: const TextStyle(
fontSize: 15,
color: Color(0xFF24303F),
),
),
),
],
),
);
}
Widget _buildHint() {
final String text;
final Color color;
if (_errorText != null) {
text = _errorText!;
color = appTheme.red;
} else {
text = '';
color = Colors.transparent;
}
return SizedBox(
height: 19,
child: Text(
text,
style: TextStyle(fontSize: 13, color: color),
overflow: TextOverflow.ellipsis,
),
);
}
Widget _buildApplyButton(S s) {
return SizedBox(
height: 50,
child: FilledButton(
onPressed: _isLoading ? null : _apply,
style: FilledButton.styleFrom(
backgroundColor: appTheme.blueA400,
foregroundColor: appTheme.whiteA700,
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
shape: const StadiumBorder(),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Text(s.apply),
),
);
}
Widget _buildSkipButton(S s) {
return SizedBox(
height: 46,
child: TextButton(
onPressed: _isLoading ? null : _onSkip,
style: TextButton.styleFrom(
foregroundColor: appTheme.blueA400,
shape: const StadiumBorder(),
textStyle: const TextStyle(fontSize: 14),
),
child: Text(s.skip),
),
);
}
}

View File

@@ -0,0 +1,224 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:sbp/models/c2bmembers_model.dart';
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
final height = MediaQuery.sizeOf(context).height * 0.55;
return showBottomSheetWithBlur(
context: context,
height: height,
child: SbpBanksBottomSheet(planId: planId),
);
}
/// Bottom sheet со списком установленных банков СБП.
///
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
/// и показывает [Loader], пока загружаются checkout_url и список банков.
class SbpBanksBottomSheet extends StatefulWidget {
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
final int planId;
final ConsultationPaymentCubit? cubit;
@override
State<SbpBanksBottomSheet> createState() => _SbpBanksBottomSheetState();
}
class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
late final ConsultationPaymentCubit _cubit;
late final bool _ownsCubit;
@override
void initState() {
super.initState();
_ownsCubit = widget.cubit == null;
_cubit = widget.cubit ?? sl<ConsultationPaymentCubit>();
WidgetsBinding.instance.addPostFrameCallback((_) {
_cubit.startPayment(planId: widget.planId);
});
}
@override
void dispose() {
if (_ownsCubit) {
_cubit.close();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return BlocProvider<ConsultationPaymentCubit>.value(
value: _cubit,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(
s.choose_bank_for_payment,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Expanded(
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
builder: (context, state) {
if (state.isLoading) {
return const Loader();
}
if (state.errorMessage != null) {
return _ErrorContent(
message: s.sbp_payment_error,
onOk: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
},
);
}
if (state.banks.isEmpty) {
return Center(
child: Text(
s.no_installed_banks,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
);
}
return ListView.separated(
itemCount: state.banks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final bank = state.banks[index];
return _BankTile(bank: bank, onTap: () => _onBankTap(bank));
},
);
},
),
),
const SizedBox(height: 16),
],
),
),
);
}
Future<void> _onBankTap(C2bmemberModel bank) async {
final opened = await _cubit.openBank(bank: bank);
if (opened && mounted) {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
}
}
}
}
class _BankTile extends StatelessWidget {
const _BankTile({required this.bank, required this.onTap});
final C2bmemberModel bank;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final name = bank.localName ?? bank.bankName;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
_BankIcon(bank: bank),
const SizedBox(width: 12),
Expanded(
child: Text(name, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
),
],
),
),
);
}
}
class _BankIcon extends StatelessWidget {
const _BankIcon({required this.bank});
final C2bmemberModel bank;
@override
Widget build(BuildContext context) {
if (bank.icon.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: bank.logoURL,
width: 40,
height: 40,
placeholder: (context, url) => _placeholder(),
errorWidget: (context, url, error) => _placeholder(),
),
);
}
return _placeholder();
}
Widget _placeholder() {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
child: Icon(Icons.account_balance, color: appTheme.blueA400, size: 22),
);
}
}
class _ErrorContent extends StatelessWidget {
const _ErrorContent({required this.message, required this.onOk});
final String message;
final VoidCallback onOk;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
message,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
onPressed: onOk,
),
],
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart';
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
class PaymentRepository {
final ChatService chatService;
PaymentRepository({required this.chatService});
/// Запускает процесс оплаты: POST `/api/chosen_plans/{id}/start_payment_process`
/// с `payment_provider: alfa-bank`.
Future<PaymentModel> startPaymentProcess({required int planId}) async {
return chatService.getPaymentLink(
id: planId,
body: const {'payment_provider': 'alfa-bank'},
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
import 'package:sbp/models/c2bmembers_model.dart';
/// Репозиторий оплаты по СБП: обёртка над [SbpService] с graceful-обработкой
/// ошибок нативного плагина (отсутствие установленных банков, недоступность
/// сервисов на устройстве, в т.ч. на Huawei HMS и т.п.).
///
/// Возвращает управляемые значения вместо проброса исключений, чтобы UI мог
/// показать fallback (например, открыть checkout_url в браузере/WebView).
class SbpRepository {
final SbpService sbpService;
SbpRepository({required this.sbpService});
/// Возвращает установленные банки СБП либо пустой список при ошибке плагина.
Future<List<C2bmemberModel>> getInstalledBanks() async {
return await sbpService.getInstalledBanks();
}
/// Открывает банк для оплаты. Возвращает `false` при ошибке плагина.
Future<bool> openBank({
required String url,
required C2bmemberModel bank,
}) async {
return await sbpService.openBank(url: url, bank: bank);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:sbp/data/c2bmembers_data.dart';
import 'package:sbp/models/c2bmembers_model.dart';
import 'package:sbp/sbp.dart' as sbp;
/// Контракт обёртки над нативным плагином СБП (Система Быстрых Платежей).
///
/// Статичные методы класса [Sbp] из пакета `sbp` работают через MethodChannel,
/// поэтому не поддаются прямому мокированию в тестах. Этот интерфейс вводится
/// для тестируемости и возможности подмены реализации (см. [SbpServiceImpl]).
abstract class SbpService {
/// Возвращает список банков, установленных на устройстве пользователя
/// и поддерживающих оплату по СБП.
Future<List<C2bmemberModel>> getInstalledBanks();
/// Открывает банковское приложение [bank] для оплаты по ссылке [url]
/// (checkout_url вида `https://qr.nspk.ru/...`).
///
/// Возвращает `true`, если приложение банка успешно запущено.
Future<bool> openBank({required String url, required C2bmemberModel bank});
}
/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`.
class SbpServiceImpl implements SbpService {
const SbpServiceImpl();
@override
Future<List<C2bmemberModel>> getInstalledBanks() {
// Используем встроенный справочник банков c2bmembersData (получаемый с
// https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что
// установлены на устройстве. Локальные иконки/названия включены, чтобы UI
// не зависел от сетевой загрузки логотипов.
return sbp.Sbp.getInstalledBanks(
C2bmembersModel.fromJson(c2bmembersData),
useAndroidLocalIcons: true,
useAndroidLocalNames: true,
);
}
@override
Future<bool> openBank({required String url, required C2bmemberModel bank}) {
return sbp.Sbp.openBank(url, bank);
}
}

View File

@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
@JsonValue('personal_diet_setted') @JsonValue('personal_diet_setted')
personalDietSet, personalDietSet,
@JsonValue('charge_was_collected') @JsonValue('charge_was_collected')
stateChargeWasCollected; stateChargeWasCollected,
@JsonValue('payment_failed')
statePaymentFailed;
static List<ChosenPlanStateEnum> getShowableChatBadgeStates() { static List<ChosenPlanStateEnum> getShowableChatBadgeStates() {
return [ return [
@@ -53,6 +55,7 @@ enum ChosenPlanStateEnum {
case ChosenPlanStateEnum.stateInCoordination: case ChosenPlanStateEnum.stateInCoordination:
case ChosenPlanStateEnum.stateStartDateHasBeenSet: case ChosenPlanStateEnum.stateStartDateHasBeenSet:
case ChosenPlanStateEnum.statePendingPayment: case ChosenPlanStateEnum.statePendingPayment:
case ChosenPlanStateEnum.statePaymentFailed:
return false; return false;
default: default:
return true; return true;
@@ -68,7 +71,7 @@ enum ChosenPlanStateEnum {
ChosenPlanStateEnum.stateLastDayFinished, ChosenPlanStateEnum.stateLastDayFinished,
ChosenPlanStateEnum.stateComplained, ChosenPlanStateEnum.stateComplained,
ChosenPlanStateEnum.stateDone, ChosenPlanStateEnum.stateDone,
ChosenPlanStateEnum.personalDietSet ChosenPlanStateEnum.personalDietSet,
]; ];
} }
} }

View File

@@ -0,0 +1,37 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'video_call_models.freezed.dart';
part 'video_call_models.g.dart';
@freezed
abstract class VideoCallTokenResponse with _$VideoCallTokenResponse {
const factory VideoCallTokenResponse({
required String token,
required String roomName,
required String wsUrl,
}) = _VideoCallTokenResponse;
factory VideoCallTokenResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallTokenResponseFromJson(json);
}
@freezed
abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse {
const factory VideoCallAdminTokenResponse({
required String adminToken,
required String roomName,
required String apiUrl,
}) = _VideoCallAdminTokenResponse;
factory VideoCallAdminTokenResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallAdminTokenResponseFromJson(json);
}
@freezed
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
const factory VideoCallEndedResponse({required bool sent}) =
_VideoCallEndedResponse;
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
}

View File

@@ -0,0 +1,531 @@
import 'dart:async';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart';
import 'package:permission_handler/permission_handler.dart';
class VideoCallScreen extends StatefulWidget {
const VideoCallScreen({
super.key,
required this.conversationUuid,
this.counterpartName,
this.localParticipantName,
this.onBackToChat,
this.onHangUp,
});
static const String path = '/video-call-screen';
static const String name = 'videoCallScreen';
final String conversationUuid;
final String? counterpartName;
final String? localParticipantName;
final VoidCallback? onBackToChat;
final VoidCallback? onHangUp;
@override
State<VideoCallScreen> createState() => _VideoCallScreenState();
}
class _VideoCallScreenState extends State<VideoCallScreen> {
static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600);
static const double _mutedMicAudioLevelThreshold = 0.008;
static const double _mutedMicEnergyDelta = 1e-6;
final lk.Room _room = lk.Room(
roomOptions: const lk.RoomOptions(
adaptiveStream: true,
defaultCameraCaptureOptions: lk.CameraCaptureOptions(params: lk.VideoParametersPresets.h1080_169),
defaultAudioCaptureOptions: _micCaptureOptions,
),
);
final VideoCallRepository _videoCallRepository = sl();
bool _micEnabled = true;
bool _videoEnabled = true;
bool _splitParticipantView = true;
bool _mainStageShowsLocalVideo = false;
bool _useFrontCamera = true;
String? _connectError;
bool _liveKitDisposed = false;
bool _callEndedNotified = false;
lk.EventsListener<lk.RoomEvent>? _roomEventsListener;
OverlayEntry? _poorConnectionEntry;
Timer? _mutedMicPollTimer;
DateTime? _lastMutedMicOverlayAt;
bool _recordingOverlayScheduled = false;
bool _mutedMicStatsInFlight = false;
num? _lastMutedMicTotalEnergy;
lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) {
return lk.CameraCaptureOptions(
cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
params: lk.VideoParametersPresets.h1080_169,
);
}
@override
void initState() {
super.initState();
_attachRoomEventListeners();
_mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return;
if (_room.connectionState != lk.ConnectionState.connected) return;
_mutedMicStatsInFlight = true;
unawaited(
_pollMutedMicFromWebRtcStats().whenComplete(() {
_mutedMicStatsInFlight = false;
}),
);
});
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_applyVideoCallSystemUi());
_scheduleRecordingNoticeOnce();
});
unawaited(_connectLiveKit());
}
void _attachRoomEventListeners() {
_roomEventsListener = _room.createListener()
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != _room.localParticipant) return;
if (!mounted) return;
_syncPoorConnectionOverlay(e.connectionQuality);
})
..on<lk.ActiveSpeakersChangedEvent>((e) {
if (!mounted || _micEnabled) return;
final lp = _room.localParticipant;
if (lp == null) return;
if (e.speakers.any((p) => p.sid == lp.sid)) {
_maybeShowMutedMicOverlay();
}
});
}
void _scheduleRecordingNoticeOnce() {
if (_recordingOverlayScheduled || !mounted) return;
_recordingOverlayScheduled = true;
VideoCallTopOverlay.insertBanner(
context,
message: S.of(context).video_call_recording_notice,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 3),
);
}
void _syncPoorConnectionOverlay(lk.ConnectionQuality q) {
final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
if (bad) {
if (_poorConnectionEntry != null) return;
if (!mounted) return;
_poorConnectionEntry = VideoCallTopOverlay.insertBanner(
context,
message: S.of(context).video_call_poor_connection,
backgroundColor: appTheme.strongRed,
svgAsset: VideoCallTopOverlay.poorConnectionSvg,
);
} else {
final e = _poorConnectionEntry;
if (e != null && e.mounted) {
e.remove();
}
_poorConnectionEntry = null;
}
}
void _maybeShowMutedMicOverlay() {
if (!mounted || _micEnabled) return;
final now = DateTime.now();
if (_lastMutedMicOverlayAt != null && now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) {
return;
}
_lastMutedMicOverlayAt = now;
VideoCallTopOverlay.insertBanner(
context,
message: S.of(context).video_call_muted_mic_warning,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 2),
);
}
Future<void> _pollMutedMicFromWebRtcStats() async {
if (!mounted || _liveKitDisposed || _micEnabled) return;
if (_room.connectionState != lk.ConnectionState.connected) return;
final lp = _room.localParticipant;
if (lp == null) return;
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
final track = pub?.track;
if (track is! lk.LocalAudioTrack) return;
if (!track.muted) return;
try {
final stats = await track.getSenderStats();
if (!mounted || _liveKitDisposed || _micEnabled) return;
final src = stats?.audioSourceStats;
if (src == null) return;
final level = src.audioLevel;
if (level != null && level >= _mutedMicAudioLevelThreshold) {
_maybeShowMutedMicOverlay();
_lastMutedMicTotalEnergy = src.totalAudioEnergy;
return;
}
final energy = src.totalAudioEnergy;
if (energy != null) {
final prev = _lastMutedMicTotalEnergy;
if (prev != null && energy > prev + _mutedMicEnergyDelta) {
_maybeShowMutedMicOverlay();
}
_lastMutedMicTotalEnergy = energy;
}
} catch (_) {}
}
Future<void> _applyVideoCallSystemUi() async {
try {
await SystemChrome.setPreferredOrientations(const [
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
if (!mounted) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} on PlatformException catch (_) {
if (!mounted) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
}
Future<void> _restoreSystemUiAfterCall() async {
try {
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
} on PlatformException catch (_) {}
}
Future<void> _connectLiveKit() async {
setState(() => _connectError = null);
try {
final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid);
if (!mounted) return;
var micOn = _micEnabled;
var videoOn = _videoEnabled;
if (micOn) {
final granted = await _ensureMicrophonePermission();
if (!mounted) return;
if (!granted) micOn = false;
}
if (videoOn) {
final granted = await _ensureCameraPermission();
if (!mounted) return;
if (!granted) videoOn = false;
}
if (micOn != _micEnabled || videoOn != _videoEnabled) {
setState(() {
_micEnabled = micOn;
_videoEnabled = videoOn;
});
}
await _room.connect(tokenResponse.wsUrl, tokenResponse.token);
if (!mounted || _liveKitDisposed) {
return;
}
var effectiveMic = micOn;
var effectiveVideo = videoOn;
final lp = _room.localParticipant;
if (micOn) {
try {
await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions);
} catch (_) {
effectiveMic = false;
}
}
if (!mounted || _liveKitDisposed) return;
if (videoOn) {
try {
await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions());
} catch (_) {
effectiveVideo = false;
}
}
if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) {
setState(() {
_micEnabled = effectiveMic;
_videoEnabled = effectiveVideo;
});
}
if (mounted) {
_syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown);
}
} catch (e) {
if (mounted) setState(() => _connectError = e.toString());
}
}
Future<void> _disconnectLiveKitOnly() async {
if (_liveKitDisposed) return;
_liveKitDisposed = true;
try {
if (_room.connectionState != lk.ConnectionState.disconnected) {
await _room.disconnect();
}
} catch (_) {}
try {
await _room.dispose();
} catch (_) {}
}
Future<void> _notifyCallEndedOnce() async {
if (_callEndedNotified) return;
_callEndedNotified = true;
try {
await _videoCallRepository.notifyCallEnded(widget.conversationUuid);
} catch (_) {}
}
Future<void> _cleanupLiveKitAndNotifyBackend() async {
await _disconnectLiveKitOnly();
await _notifyCallEndedOnce();
}
@override
void dispose() {
_mutedMicPollTimer?.cancel();
final listener = _roomEventsListener;
_roomEventsListener = null;
if (listener != null) {
unawaited(listener.dispose());
}
final poor = _poorConnectionEntry;
_poorConnectionEntry = null;
if (poor != null && poor.mounted) {
poor.remove();
}
unawaited(_cleanupLiveKitAndNotifyBackend());
unawaited(_restoreSystemUiAfterCall());
super.dispose();
}
Future<void> _handleBackToChat() async {
widget.onBackToChat?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _handleHangUp() async {
widget.onHangUp?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _toggleMic() async {
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
setState(() => _micEnabled = !_micEnabled);
_lastMutedMicTotalEnergy = null;
return;
}
final next = !_micEnabled;
if (next) {
final granted = await _ensureMicrophonePermission();
if (!mounted) return;
if (!granted) return;
}
setState(() => _micEnabled = next);
_lastMutedMicTotalEnergy = null;
await _room.localParticipant?.setMicrophoneEnabled(next, audioCaptureOptions: _micCaptureOptions);
}
Future<void> _toggleVideo() async {
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
setState(() => _videoEnabled = !_videoEnabled);
return;
}
final next = !_videoEnabled;
if (next) {
final granted = await _ensureCameraPermission();
if (!mounted) return;
if (!granted) return;
}
setState(() => _videoEnabled = next);
final lp = _room.localParticipant;
if (lp == null) return;
if (next) {
await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions());
} else {
await lp.setCameraEnabled(false);
}
}
Future<void> _switchCameraFacing() async {
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
setState(() => _useFrontCamera = !_useFrontCamera);
return;
}
if (!_videoEnabled) return;
final lp = _room.localParticipant;
if (lp == null) return;
final currentPosition = _useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back;
final newPosition = currentPosition.switched();
final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera);
final track = pub?.track;
if (track is lk.LocalVideoTrack) {
try {
await track.setCameraPosition(newPosition);
if (mounted) {
setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front);
}
} catch (_) {}
return;
}
try {
await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions(cameraPosition: newPosition));
if (mounted) {
setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front);
}
} catch (_) {}
}
bool _permissionNeedsSettings(PermissionStatus status) => status.isPermanentlyDenied || status.isRestricted;
Future<bool> _ensureCameraPermission() async {
var status = await Permission.camera.status;
if (status.isGranted) return true;
status = await Permission.camera.request();
if (status.isGranted) return true;
if (!mounted) return false;
final s = S.of(context);
await showErrorDialog(
context,
title: s.video_call_permission_camera_title,
description: s.video_call_permission_camera_description,
buttonTitle: s.allowAccess,
needToOpenSettings: _permissionNeedsSettings(status),
);
return false;
}
Future<bool> _ensureMicrophonePermission() async {
var status = await Permission.microphone.status;
if (status.isGranted) return true;
status = await Permission.microphone.request();
if (status.isGranted) return true;
if (!mounted) return false;
final s = S.of(context);
await showErrorDialog(
context,
title: s.video_call_permission_microphone_title,
description: s.video_call_permission_microphone_description,
buttonTitle: s.allowAccess,
needToOpenSettings: _permissionNeedsSettings(status),
);
return false;
}
String _resolvedLocalDisplayName(BuildContext context) {
final fromNav = widget.localParticipantName?.trim();
if (fromNav != null && fromNav.isNotEmpty) return fromNav;
final n = (_room.localParticipant?.name ?? '').trim();
if (n.isNotEmpty) return n;
return S.of(context).video_call_you;
}
String _resolvedRemoteDisplayName(BuildContext context, String fallback) {
for (final p in _room.remoteParticipants.values) {
final n = p.name.trim();
if (n.isNotEmpty) return n;
}
final c = widget.counterpartName?.trim();
if (c != null && c.isNotEmpty) return c;
return fallback;
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
final viewPadding = MediaQuery.viewPaddingOf(context);
final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0;
final bottomInset = viewPadding.bottom;
final counterpartFallback = s.video_call_counterpart_default;
final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
),
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: ListenableBuilder(
listenable: _room,
builder: (context, _) {
return VideoCallStageLayout(
room: _room,
connectError: _connectError,
localDisplayName: _resolvedLocalDisplayName(context),
remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback),
micEnabled: _micEnabled,
videoEnabled: _videoEnabled,
splitParticipantView: _splitParticipantView,
mainStageShowsLocalVideo: _mainStageShowsLocalVideo,
useFrontCamera: _useFrontCamera,
onExpandLocal: () => setState(() {
_splitParticipantView = false;
_mainStageShowsLocalVideo = true;
}),
onExpandRemote: () => setState(() {
_splitParticipantView = false;
_mainStageShowsLocalVideo = false;
}),
onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true),
onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo),
onChat: () => unawaited(_handleBackToChat()),
onToggleMic: () => unawaited(_toggleMic()),
onToggleVideo: () => unawaited(_toggleVideo()),
onHangUp: () => unawaited(_handleHangUp()),
onSwitchCamera: () => unawaited(_switchCameraFacing()),
controlLabelStyle: controlLabelStyle,
labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave),
topInset: topInset,
bottomInset: bottomInset,
);
},
),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
typedef VideoCallControlLabels = ({String chat, String sound, String video, String leave});
class VideoCallControlColumn extends StatelessWidget {
const VideoCallControlColumn({
super.key,
required this.micEnabled,
required this.videoEnabled,
required this.onChat,
required this.onToggleMic,
required this.onToggleVideo,
required this.onHangUp,
required this.labels,
required this.controlLabelStyle,
});
final bool micEnabled;
final bool videoEnabled;
final VoidCallback onChat;
final VoidCallback onToggleMic;
final VoidCallback onToggleVideo;
final VoidCallback onHangUp;
final VideoCallControlLabels labels;
final TextStyle controlLabelStyle;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
VideoCallControlTile(
label: labels.chat,
labelStyle: controlLabelStyle,
onTap: onChat,
face: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset('assets/icons/video_call/vc_control_chat.png', width: 48, height: 48, fit: BoxFit.cover),
),
),
const SizedBox(height: 22),
VideoCallControlTile(
label: labels.sound,
labelStyle: controlLabelStyle,
onTap: onToggleMic,
face: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 48,
height: 48,
child: micEnabled
? ColoredBox(
color: appTheme.blueA400,
child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_on.svg', width: 28, height: 28)),
)
: ColoredBox(
color: appTheme.videoCallControlOffBackground,
child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_off.svg', width: 28, height: 28)),
),
),
),
),
const SizedBox(height: 22),
VideoCallControlTile(
label: labels.video,
labelStyle: controlLabelStyle,
onTap: onToggleVideo,
face: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 48,
height: 48,
child: videoEnabled
? Image.asset('assets/icons/video_call/vc_control_video.png', fit: BoxFit.cover)
: ColoredBox(
color: appTheme.videoCallControlOffBackground,
child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_cam_off.svg', width: 28, height: 28)),
),
),
),
),
const SizedBox(height: 22),
VideoCallControlTile(
label: labels.leave,
labelStyle: controlLabelStyle,
onTap: onHangUp,
face: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 48,
height: 48,
child: Image.asset('assets/icons/video_call/vc_control_leave.png', fit: BoxFit.cover, alignment: const Alignment(0, -0.82)),
),
),
),
],
);
}
}
class VideoCallControlTile extends StatelessWidget {
const VideoCallControlTile({super.key, required this.label, required this.labelStyle, required this.onTap, required this.face});
final String label;
final TextStyle labelStyle;
final VoidCallback onTap;
final Widget face;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(width: 48, height: 48, child: face),
const SizedBox(height: 6),
Text(label, style: labelStyle),
],
),
);
}
}
class VideoCallPipStyleIconButton extends StatelessWidget {
const VideoCallPipStyleIconButton({super.key, required this.onPressed, required this.child});
final VoidCallback onPressed;
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
color: appTheme.videoCallPipIconBackground,
borderRadius: BorderRadius.circular(8),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: Padding(padding: const EdgeInsets.all(8), child: child),
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:livekit_client/livekit_client.dart' as lk;
abstract final class VideoCallLiveKitTrackUtils {
static lk.VideoTrack? asVideoTrack(lk.Track? track) {
if (track == null) return null;
return track is lk.VideoTrack ? track : null;
}
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
if (pub == null || pub.muted) return null;
final v = asVideoTrack(pub.track);
if (v == null || v.muted) return null;
return v;
}
static lk.VideoTrack? remoteCameraVideoTrack(lk.Room room) {
for (final participant in room.remoteParticipants.values) {
final fromCameraPub = visibleVideoFromPublication(
participant.getTrackPublicationBySource(lk.TrackSource.camera),
);
if (fromCameraPub != null) return fromCameraPub;
for (final pub in participant.videoTrackPublications) {
if (pub.source == lk.TrackSource.screenShareVideo ||
pub.source == lk.TrackSource.screenShareAudio) {
continue;
}
final v = visibleVideoFromPublication(pub);
if (v != null) return v;
}
}
return null;
}
static lk.VideoTrack? localCameraVideoTrack(lk.Room room) {
return visibleVideoFromPublication(
room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera),
);
}
}

View File

@@ -0,0 +1,43 @@
import 'dart:ui';
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 VideoCallParticipantNameLabel extends StatelessWidget {
const VideoCallParticipantNameLabel({super.key, required this.name});
final String name;
@override
Widget build(BuildContext context) {
return Positioned(
left: 12,
bottom: 12,
right: 12,
child: Align(
alignment: Alignment.bottomLeft,
child: ClipRRect(
borderRadius: BorderRadius.circular(60),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
child: Container(
constraints: const BoxConstraints(maxWidth: 240),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: appTheme.transparentBlack,
borderRadius: BorderRadius.circular(60),
),
child: Text(
name,
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
class VideoCallNoVideoBackground extends StatelessWidget {
const VideoCallNoVideoBackground({super.key, this.logoSize = 96});
final double logoSize;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: appTheme.gray100,
child: Center(
child: Image.asset(
'assets/icons/video_call/eatvio_c.png',
width: logoSize,
height: logoSize,
fit: BoxFit.contain,
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
class VideoCallRemoteVideoStage extends StatelessWidget {
const VideoCallRemoteVideoStage({
super.key,
required this.room,
required this.connectError,
});
final lk.Room room;
final String? connectError;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: room,
builder: (context, _) {
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
if (remote != null) {
return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover);
}
return Stack(
fit: StackFit.expand,
children: [
const VideoCallNoVideoBackground(),
if (connectError != null)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
connectError!,
textAlign: TextAlign.center,
style: TextStyles.textSMedium.copyWith(color: appTheme.red),
),
),
)
else if (room.connectionState != lk.ConnectionState.connected)
Center(
child: SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
strokeWidth: 3,
color: appTheme.blueA400,
),
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,489 @@
import 'dart:math' as math;
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
const _kExpandShrinkOuterPadding = EdgeInsets.all(6);
abstract final class _VideoCallStageAssets {
static const String shrink = 'assets/icons/video_call/vc_shrink.svg';
static const String expand = 'assets/icons/video_call/vc_expand.svg';
static const String cameraSwitch = 'assets/icons/video_call/vc_camera_switch.png';
}
class VideoCallStageLayout extends StatefulWidget {
const VideoCallStageLayout({
super.key,
required this.room,
required this.connectError,
required this.localDisplayName,
required this.remoteDisplayName,
required this.micEnabled,
required this.videoEnabled,
required this.splitParticipantView,
required this.mainStageShowsLocalVideo,
required this.useFrontCamera,
required this.onExpandLocal,
required this.onExpandRemote,
required this.onShrinkFromFullscreen,
required this.onToggleStageSwap,
required this.onChat,
required this.onToggleMic,
required this.onToggleVideo,
required this.onHangUp,
required this.onSwitchCamera,
required this.controlLabelStyle,
required this.labels,
required this.topInset,
this.bottomInset = 0,
});
final lk.Room room;
final String? connectError;
final String localDisplayName;
final String remoteDisplayName;
final bool micEnabled;
final bool videoEnabled;
final bool splitParticipantView;
final bool mainStageShowsLocalVideo;
final bool useFrontCamera;
final VoidCallback onExpandLocal;
final VoidCallback onExpandRemote;
final VoidCallback onShrinkFromFullscreen;
final VoidCallback onToggleStageSwap;
final VoidCallback onChat;
final VoidCallback onToggleMic;
final VoidCallback onToggleVideo;
final VoidCallback onHangUp;
final VoidCallback onSwitchCamera;
final TextStyle controlLabelStyle;
final VideoCallControlLabels labels;
final double topInset;
final double bottomInset;
@override
State<VideoCallStageLayout> createState() => _VideoCallStageLayoutState();
}
class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
static const double _pipW = 142;
static const double _pipH = 201;
static const double _mainStageCornerRadius = 24;
static const double _edgeInset = 8;
static const double _controlsHorizontalInset = 22;
static const double _shrinkExpandExtraTop = 12;
static const double _pipInitialExtraLeft = 28;
static const double _pipInitialExtraDown = 16;
static const double _bottomGradientHeight = 140;
static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop;
double? _pipLeft;
double? _pipTop;
Offset? _pipPanPointerGlobal;
double? _pipPanOriginLeft;
double? _pipPanOriginTop;
bool _pipHasVisibleVideo() {
if (widget.mainStageShowsLocalVideo) {
return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null;
}
return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (widget.splitParticipantView) {
return _buildSplitLayout(constraints);
}
return _buildFullscreenLayout(constraints);
},
);
}
Widget _buildSplitLayout(BoxConstraints constraints) {
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
child: Column(
children: [
Expanded(
child: _SplitParticipantPane(
room: widget.room,
displayName: widget.localDisplayName,
isLocal: true,
connectError: null,
videoEnabled: widget.videoEnabled,
useFrontCamera: widget.useFrontCamera,
onExpand: widget.onExpandLocal,
onSwitchCamera: widget.onSwitchCamera,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(_mainStageCornerRadius),
topRight: Radius.circular(_mainStageCornerRadius),
bottomLeft: Radius.zero,
bottomRight: Radius.zero,
),
),
),
const SizedBox(height: _edgeInset),
Expanded(
child: _SplitParticipantPane(
room: widget.room,
displayName: widget.remoteDisplayName,
isLocal: false,
connectError: widget.connectError,
videoEnabled: widget.videoEnabled,
useFrontCamera: widget.useFrontCamera,
onExpand: widget.onExpandRemote,
onSwitchCamera: widget.onSwitchCamera,
borderRadius: const BorderRadius.only(
topLeft: Radius.zero,
topRight: Radius.zero,
bottomLeft: Radius.circular(_mainStageCornerRadius),
bottomRight: Radius.circular(_mainStageCornerRadius),
),
),
),
],
),
),
),
..._bottomBarGradient(),
..._bottomControlsRow(),
],
);
}
Widget _buildFullscreenLayout(BoxConstraints constraints) {
final maxW = constraints.maxWidth;
final maxH = constraints.maxHeight;
final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft;
final minPipTop = widget.topInset + _edgeInset;
final defaultPipTop = minPipTop + _pipInitialExtraDown;
final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset);
final maxPipTop = math.max(minPipTop, maxH - _pipH - _edgeInset - widget.bottomInset);
final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble();
final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble();
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
child: Material(
type: MaterialType.transparency,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)),
child: Stack(
fit: StackFit.expand,
children: [
_VideoCallMainStage(
room: widget.room,
connectError: widget.connectError,
mainShowsLocal: widget.mainStageShowsLocalVideo,
videoEnabled: widget.videoEnabled,
),
VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName),
],
),
),
),
),
..._bottomBarGradient(),
..._bottomControlsRow(),
Positioned(
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
left: _controlsHorizontalInset,
child: Padding(
padding: _kExpandShrinkOuterPadding,
child: VideoCallPipStyleIconButton(
onPressed: widget.onShrinkFromFullscreen,
child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain),
),
),
),
ListenableBuilder(
listenable: widget.room,
builder: (context, _) {
if (!_pipHasVisibleVideo()) {
return const SizedBox.shrink();
}
return Positioned(
left: pipLeft,
top: pipTop,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onToggleStageSwap,
onPanStart: (details) {
_pipPanPointerGlobal = details.globalPosition;
_pipPanOriginLeft = pipLeft;
_pipPanOriginTop = pipTop;
},
onPanUpdate: (details) {
if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) {
return;
}
final delta = details.globalPosition - _pipPanPointerGlobal!;
setState(() {
final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft);
final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop);
_pipLeft = (nx as num).toDouble();
_pipTop = (ny as num).toDouble();
});
},
onPanEnd: (_) {
_resetPipPanState();
},
onPanCancel: _resetPipPanState,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: _pipW,
height: _pipH,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: widget.mainStageShowsLocalVideo
? _VideoCallPipRemoteContent(room: widget.room)
: _VideoCallPipLocalContent(
room: widget.room,
videoEnabled: widget.videoEnabled,
useFrontCamera: widget.useFrontCamera,
onSwitchCamera: widget.onSwitchCamera,
),
),
),
),
);
},
),
],
);
}
List<Widget> _bottomBarGradient() {
return [
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _bottomGradientHeight + widget.bottomInset,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black.withValues(alpha: 0.55), Colors.black.withValues(alpha: 0)],
),
),
),
),
),
];
}
void _resetPipPanState() {
_pipPanPointerGlobal = null;
_pipPanOriginLeft = null;
_pipPanOriginTop = null;
}
List<Widget> _bottomControlsRow() {
return [
Positioned(
left: _controlsHorizontalInset,
right: _controlsHorizontalInset,
bottom: _edgeInset + 4 + widget.bottomInset,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
const Expanded(child: SizedBox.shrink()),
const SizedBox(width: 14),
VideoCallControlColumn(
micEnabled: widget.micEnabled,
videoEnabled: widget.videoEnabled,
onChat: widget.onChat,
onToggleMic: widget.onToggleMic,
onToggleVideo: widget.onToggleVideo,
onHangUp: widget.onHangUp,
labels: widget.labels,
controlLabelStyle: widget.controlLabelStyle,
),
],
),
),
];
}
}
class _SplitParticipantPane extends StatelessWidget {
const _SplitParticipantPane({
required this.room,
required this.displayName,
required this.isLocal,
required this.connectError,
required this.videoEnabled,
required this.useFrontCamera,
required this.onExpand,
required this.onSwitchCamera,
required this.borderRadius,
});
final lk.Room room;
final String displayName;
final bool isLocal;
final String? connectError;
final bool videoEnabled;
final bool useFrontCamera;
final VoidCallback onExpand;
final VoidCallback onSwitchCamera;
final BorderRadius borderRadius;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ListenableBuilder(
listenable: room,
builder: (context, _) {
if (isLocal) {
if (!videoEnabled) {
return const VideoCallNoVideoBackground();
}
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
);
}
return const VideoCallNoVideoBackground();
}
return VideoCallRemoteVideoStage(room: room, connectError: connectError);
},
),
),
VideoCallParticipantNameLabel(name: displayName),
Positioned(
left: 0,
top: _VideoCallStageLayoutState._splitExpandButtonTop,
child: Padding(
padding: _kExpandShrinkOuterPadding,
child: VideoCallPipStyleIconButton(
onPressed: onExpand,
child: SvgPicture.asset(_VideoCallStageAssets.expand, width: 20, height: 20, fit: BoxFit.contain),
),
),
),
if (isLocal && videoEnabled)
Positioned(
right: 10,
top: 10,
child: Padding(
padding: _kExpandShrinkOuterPadding,
child: VideoCallPipStyleIconButton(
onPressed: onSwitchCamera,
child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)),
),
),
),
],
),
);
}
}
class _VideoCallMainStage extends StatelessWidget {
const _VideoCallMainStage({required this.room, required this.connectError, required this.mainShowsLocal, required this.videoEnabled});
final lk.Room room;
final String? connectError;
final bool mainShowsLocal;
final bool videoEnabled;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: room,
builder: (context, _) {
if (mainShowsLocal) {
if (!videoEnabled) {
return const VideoCallNoVideoBackground();
}
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
);
}
return const VideoCallNoVideoBackground();
}
return VideoCallRemoteVideoStage(room: room, connectError: connectError);
},
);
}
}
class _VideoCallPipRemoteContent extends StatelessWidget {
const _VideoCallPipRemoteContent({required this.room});
final lk.Room room;
@override
Widget build(BuildContext context) {
return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null));
}
}
class _VideoCallPipLocalContent extends StatelessWidget {
const _VideoCallPipLocalContent({required this.room, required this.videoEnabled, required this.useFrontCamera, required this.onSwitchCamera});
final lk.Room room;
final bool videoEnabled;
final bool useFrontCamera;
final VoidCallback onSwitchCamera;
@override
Widget build(BuildContext context) {
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
return Stack(
fit: StackFit.expand,
children: [
if (!videoEnabled)
const VideoCallNoVideoBackground(logoSize: 56)
else if (local != null)
IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
)
else
const VideoCallNoVideoBackground(logoSize: 56),
if (videoEnabled)
Positioned(
right: 10,
top: 10,
child: VideoCallPipStyleIconButton(
onPressed: onSwitchCamera,
child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)),
),
),
],
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
abstract final class VideoCallTopOverlay {
static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg';
static const String warningSvg = 'assets/icons/video_call/warning.svg';
static OverlayEntry insertBanner(
BuildContext context, {
required String message,
required Color backgroundColor,
required String svgAsset,
Duration? autoRemoveAfter,
}) {
final overlayState = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => Positioned(
left: 16,
right: 16,
top: MediaQuery.viewPaddingOf(ctx).top + 8,
child: Material(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
elevation: 6,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SvgPicture.asset(svgAsset, width: 22, height: 22),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(
color: appTheme.whiteA700,
fontSize: 14,
height: 1.25,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
);
overlayState.insert(entry);
if (autoRemoveAfter != null) {
Future<void>.delayed(autoRemoveAfter, () {
if (entry.mounted) {
entry.remove();
}
});
}
return entry;
}
}

View File

@@ -0,0 +1,27 @@
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
class VideoCallRepository {
VideoCallRepository({required this.videoCallService});
final VideoCallService videoCallService;
static const Map<String, dynamic> _emptyJsonBody = <String, dynamic>{};
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
return videoCallService.getParticipantToken(
conversationUuid: conversationUuid,
);
}
Future<VideoCallAdminTokenResponse> getAdminToken(String conversationUuid) {
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
}
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
return videoCallService.notifyCallEnded(
conversationUuid: conversationUuid,
body: _emptyJsonBody,
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
import 'package:retrofit/retrofit.dart';
part 'video_call_service.g.dart';
@RestApi()
abstract class VideoCallService {
factory VideoCallService(Dio dio, {String baseUrl}) = _VideoCallService;
@GET('/api/v2/chat/{conversation}/video-call/token')
Future<VideoCallTokenResponse> getParticipantToken({
@Path('conversation') required String conversationUuid,
});
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
Future<VideoCallAdminTokenResponse> getAdminToken({
@Path('conversation') required String conversationUuid,
});
@POST('/api/v2/chat/{conversation}/video-call/ended')
Future<VideoCallEndedResponse> notifyCallEnded({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -123,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary {
"another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"), "another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"),
"another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"), "another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"),
"answer": MessageLookupByLibrary.simpleMessage("Ответить"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"),
"apply": MessageLookupByLibrary.simpleMessage("Apply"),
"archived": MessageLookupByLibrary.simpleMessage("В архиве"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"),
"atLeast3Symbols": MessageLookupByLibrary.simpleMessage( "atLeast3Symbols": MessageLookupByLibrary.simpleMessage(
"Минимум 3 символа", "Минимум 3 символа",
@@ -173,6 +174,9 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
"Выберите банк",
),
"choose_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"), "choose_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
"choose_diet_duration": MessageLookupByLibrary.simpleMessage( "choose_diet_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок диеты", "Выберите срок диеты",
@@ -378,6 +382,9 @@ class MessageLookup extends MessageLookupByLibrary {
"enter_information": MessageLookupByLibrary.simpleMessage( "enter_information": MessageLookupByLibrary.simpleMessage(
"Введите информацию...", "Введите информацию...",
), ),
"enter_promo_code": MessageLookupByLibrary.simpleMessage(
"Enter promo code",
),
"error_desc": MessageLookupByLibrary.simpleMessage( "error_desc": MessageLookupByLibrary.simpleMessage(
"Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже", "Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже",
), ),
@@ -463,6 +470,12 @@ class MessageLookup extends MessageLookupByLibrary {
"hatred_description": MessageLookupByLibrary.simpleMessage( "hatred_description": MessageLookupByLibrary.simpleMessage(
"Враждебные высказывания", "Враждебные высказывания",
), ),
"have_promo_code": MessageLookupByLibrary.simpleMessage(
"Do you have a promo code?",
),
"have_promo_code_form": MessageLookupByLibrary.simpleMessage(
"Have a promo code?",
),
"height": MessageLookupByLibrary.simpleMessage("Рост"), "height": MessageLookupByLibrary.simpleMessage("Рост"),
"height_error": MessageLookupByLibrary.simpleMessage("Введите рост"), "height_error": MessageLookupByLibrary.simpleMessage("Введите рост"),
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
@@ -588,6 +601,9 @@ class MessageLookup extends MessageLookupByLibrary {
"no_default_cards": MessageLookupByLibrary.simpleMessage( "no_default_cards": MessageLookupByLibrary.simpleMessage(
"У вас нет привязанных карт. Что бы начать работу, привяжите карту", "У вас нет привязанных карт. Что бы начать работу, привяжите карту",
), ),
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
"Нет установленных банков",
),
"not_liked_groceries": MessageLookupByLibrary.simpleMessage( "not_liked_groceries": MessageLookupByLibrary.simpleMessage(
"Не любимые продукты", "Не любимые продукты",
), ),
@@ -699,6 +715,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио", "Разница в ценах, это комиссия Итвио",
), ),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"), "profile": MessageLookupByLibrary.simpleMessage("Профиль"),
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
"promo_invalid": MessageLookupByLibrary.simpleMessage(
"Promo code not found or expired",
),
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"), "prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
@@ -737,6 +757,9 @@ class MessageLookup extends MessageLookupByLibrary {
"reply": MessageLookupByLibrary.simpleMessage("Ответить"), "reply": MessageLookupByLibrary.simpleMessage("Ответить"),
"resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"), "resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"),
"save": MessageLookupByLibrary.simpleMessage("Сохранить"), "save": MessageLookupByLibrary.simpleMessage("Сохранить"),
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"send": MessageLookupByLibrary.simpleMessage("Отправить"), "send": MessageLookupByLibrary.simpleMessage("Отправить"),
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"), "send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
@@ -873,6 +896,41 @@ class MessageLookup extends MessageLookupByLibrary {
"vegan_type_desc": MessageLookupByLibrary.simpleMessage( "vegan_type_desc": MessageLookupByLibrary.simpleMessage(
"Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.",
), ),
"video_call": MessageLookupByLibrary.simpleMessage("Video call"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Participant",
),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Attention!\nYour microphone is muted",
),
"video_call_permission_camera_description":
MessageLookupByLibrary.simpleMessage(
"Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.",
),
"video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage(
"Camera access",
),
"video_call_permission_microphone_description":
MessageLookupByLibrary.simpleMessage(
"Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.",
),
"video_call_permission_microphone_title":
MessageLookupByLibrary.simpleMessage("Microphone access"),
"video_call_poor_connection": MessageLookupByLibrary.simpleMessage(
"You have a poor internet connection",
),
"video_call_recording_notice": MessageLookupByLibrary.simpleMessage(
"The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.",
),
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Eatvio consultant",
),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Video"),
"video_call_you": MessageLookupByLibrary.simpleMessage("You"),
"violenceAndHostility": MessageLookupByLibrary.simpleMessage( "violenceAndHostility": MessageLookupByLibrary.simpleMessage(
"Насилие и вражда", "Насилие и вражда",
), ),
@@ -909,6 +967,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage( "wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя", "Неверные данные пользователя",
), ),
"yes": MessageLookupByLibrary.simpleMessage("Yes"),
"youCanComplain": MessageLookupByLibrary.simpleMessage( "youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу", "Если вас что-то не устроило, вы можете подать жалобу",
), ),

View File

@@ -45,6 +45,7 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"), "another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"),
"answer": MessageLookupByLibrary.simpleMessage("Ответить"), "answer": MessageLookupByLibrary.simpleMessage("Ответить"),
"apply": MessageLookupByLibrary.simpleMessage("Применить"),
"archived": MessageLookupByLibrary.simpleMessage("В архиве"), "archived": MessageLookupByLibrary.simpleMessage("В архиве"),
"authentificate": MessageLookupByLibrary.simpleMessage("Войти"), "authentificate": MessageLookupByLibrary.simpleMessage("Войти"),
"authorization": MessageLookupByLibrary.simpleMessage("Авторизация"), "authorization": MessageLookupByLibrary.simpleMessage("Авторизация"),
@@ -62,6 +63,9 @@ class MessageLookup extends MessageLookupByLibrary {
"chat": MessageLookupByLibrary.simpleMessage("Чат"), "chat": MessageLookupByLibrary.simpleMessage("Чат"),
"chest": MessageLookupByLibrary.simpleMessage("Грудь"), "chest": MessageLookupByLibrary.simpleMessage("Грудь"),
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"), "chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
"Выберите банк",
),
"choose_diet_duration": MessageLookupByLibrary.simpleMessage( "choose_diet_duration": MessageLookupByLibrary.simpleMessage(
"Выберите срок диеты", "Выберите срок диеты",
), ),
@@ -151,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary {
"enter_information": MessageLookupByLibrary.simpleMessage( "enter_information": MessageLookupByLibrary.simpleMessage(
"Введите информацию...", "Введите информацию...",
), ),
"enter_promo_code": MessageLookupByLibrary.simpleMessage(
"Введите промокод",
),
"exit": MessageLookupByLibrary.simpleMessage("Выйти"), "exit": MessageLookupByLibrary.simpleMessage("Выйти"),
"expand": MessageLookupByLibrary.simpleMessage("Показать полностью"), "expand": MessageLookupByLibrary.simpleMessage("Показать полностью"),
"fats": MessageLookupByLibrary.simpleMessage("Жиры"), "fats": MessageLookupByLibrary.simpleMessage("Жиры"),
@@ -181,6 +188,12 @@ class MessageLookup extends MessageLookupByLibrary {
"gallery": MessageLookupByLibrary.simpleMessage("Галерея"), "gallery": MessageLookupByLibrary.simpleMessage("Галерея"),
"gr": MessageLookupByLibrary.simpleMessage("гр"), "gr": MessageLookupByLibrary.simpleMessage("гр"),
"grams": MessageLookupByLibrary.simpleMessage("гр"), "grams": MessageLookupByLibrary.simpleMessage("гр"),
"have_promo_code": MessageLookupByLibrary.simpleMessage(
"У вас есть промокод?",
),
"have_promo_code_form": MessageLookupByLibrary.simpleMessage(
"Есть промокод?",
),
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"), "height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
"hide": MessageLookupByLibrary.simpleMessage("Свернуть"), "hide": MessageLookupByLibrary.simpleMessage("Свернуть"),
"hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"), "hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"),
@@ -243,6 +256,9 @@ class MessageLookup extends MessageLookupByLibrary {
), ),
"no": MessageLookupByLibrary.simpleMessage("Нет"), "no": MessageLookupByLibrary.simpleMessage("Нет"),
"no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"), "no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"),
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
"Нет установленных банков",
),
"notifications": MessageLookupByLibrary.simpleMessage("Уведомления"), "notifications": MessageLookupByLibrary.simpleMessage("Уведомления"),
"one_option": MessageLookupByLibrary.simpleMessage("1 опция"), "one_option": MessageLookupByLibrary.simpleMessage("1 опция"),
"option": MessageLookupByLibrary.simpleMessage("Опция"), "option": MessageLookupByLibrary.simpleMessage("Опция"),
@@ -306,6 +322,10 @@ class MessageLookup extends MessageLookupByLibrary {
"Разница в ценах, это комиссия Итвио", "Разница в ценах, это комиссия Итвио",
), ),
"profile": MessageLookupByLibrary.simpleMessage("Профиль"), "profile": MessageLookupByLibrary.simpleMessage("Профиль"),
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
"promo_invalid": MessageLookupByLibrary.simpleMessage(
"Промокод не найден или истёк",
),
"proteins": MessageLookupByLibrary.simpleMessage("Белки"), "proteins": MessageLookupByLibrary.simpleMessage("Белки"),
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"), "public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"), "publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
@@ -314,6 +334,9 @@ class MessageLookup extends MessageLookupByLibrary {
"Снять с публикации", "Снять с публикации",
), ),
"save": MessageLookupByLibrary.simpleMessage("Сохранить"), "save": MessageLookupByLibrary.simpleMessage("Сохранить"),
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
"Не удалось начать оплату. Попробуйте позже",
),
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"), "services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
"settings": MessageLookupByLibrary.simpleMessage("Настройки"), "settings": MessageLookupByLibrary.simpleMessage("Настройки"),
"share": MessageLookupByLibrary.simpleMessage("Поделиться"), "share": MessageLookupByLibrary.simpleMessage("Поделиться"),
@@ -353,6 +376,41 @@ class MessageLookup extends MessageLookupByLibrary {
"vegan_type_desc": MessageLookupByLibrary.simpleMessage( "vegan_type_desc": MessageLookupByLibrary.simpleMessage(
"Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.",
), ),
"video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Собеседник",
),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Внимание!\nУ вас выключен микрофон",
),
"video_call_permission_camera_description":
MessageLookupByLibrary.simpleMessage(
"Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
),
"video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage(
"Доступ к камере",
),
"video_call_permission_microphone_description":
MessageLookupByLibrary.simpleMessage(
"Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
),
"video_call_permission_microphone_title":
MessageLookupByLibrary.simpleMessage("Доступ к микрофону"),
"video_call_poor_connection": MessageLookupByLibrary.simpleMessage(
"У вас плохое интернет соединение",
),
"video_call_recording_notice": MessageLookupByLibrary.simpleMessage(
"Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.",
),
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Консультант Итвио",
),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Видео"),
"video_call_you": MessageLookupByLibrary.simpleMessage("Вы"),
"waist": MessageLookupByLibrary.simpleMessage("Талия"), "waist": MessageLookupByLibrary.simpleMessage("Талия"),
"waist_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"), "waist_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"),
"weight": MessageLookupByLibrary.simpleMessage("Вес"), "weight": MessageLookupByLibrary.simpleMessage("Вес"),
@@ -369,6 +427,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage( "wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя", "Неверные данные пользователя",
), ),
"yes": MessageLookupByLibrary.simpleMessage("Да"),
"you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"), "you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"),
"your_blood_pressure": MessageLookupByLibrary.simpleMessage( "your_blood_pressure": MessageLookupByLibrary.simpleMessage(
"Ваше артериальное давление?", "Ваше артериальное давление?",

View File

@@ -2649,6 +2649,36 @@ class S {
return Intl.message('Оплатить', name: 'pay', desc: '', args: []); return Intl.message('Оплатить', name: 'pay', desc: '', args: []);
} }
/// `Выберите банк`
String get choose_bank_for_payment {
return Intl.message(
'Выберите банк',
name: 'choose_bank_for_payment',
desc: '',
args: [],
);
}
/// `Нет установленных банков`
String get no_installed_banks {
return Intl.message(
'Нет установленных банков',
name: 'no_installed_banks',
desc: '',
args: [],
);
}
/// `Не удалось начать оплату. Попробуйте позже`
String get sbp_payment_error {
return Intl.message(
'Не удалось начать оплату. Попробуйте позже',
name: 'sbp_payment_error',
desc: '',
args: [],
);
}
/// `Подробнее о заказе` /// `Подробнее о заказе`
String get more_about_order { String get more_about_order {
return Intl.message( return Intl.message(
@@ -4018,6 +4048,191 @@ class S {
args: [], args: [],
); );
} }
/// `Video call`
String get video_call {
return Intl.message('Video call', name: 'video_call', desc: '', args: []);
}
/// `Eatvio consultant`
String get video_call_role_consultant {
return Intl.message(
'Eatvio consultant',
name: 'video_call_role_consultant',
desc: '',
args: [],
);
}
/// `Participant`
String get video_call_counterpart_default {
return Intl.message(
'Participant',
name: 'video_call_counterpart_default',
desc: '',
args: [],
);
}
/// `You`
String get video_call_you {
return Intl.message('You', name: 'video_call_you', desc: '', args: []);
}
/// `You have a poor internet connection`
String get video_call_poor_connection {
return Intl.message(
'You have a poor internet connection',
name: 'video_call_poor_connection',
desc: '',
args: [],
);
}
/// `The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.`
String get video_call_recording_notice {
return Intl.message(
'The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.',
name: 'video_call_recording_notice',
desc: '',
args: [],
);
}
/// `Attention!\nYour microphone is muted`
String get video_call_muted_mic_warning {
return Intl.message(
'Attention!\nYour microphone is muted',
name: 'video_call_muted_mic_warning',
desc: '',
args: [],
);
}
/// `Chat`
String get video_call_chat {
return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []);
}
/// `Sound`
String get video_call_sound {
return Intl.message('Sound', name: 'video_call_sound', desc: '', args: []);
}
/// `Video`
String get video_call_video {
return Intl.message('Video', name: 'video_call_video', desc: '', args: []);
}
/// `Leave`
String get video_call_leave {
return Intl.message('Leave', name: 'video_call_leave', desc: '', args: []);
}
/// `?`
String get video_call_unknown_initial {
return Intl.message(
'?',
name: 'video_call_unknown_initial',
desc: '',
args: [],
);
}
/// `Camera access`
String get video_call_permission_camera_title {
return Intl.message(
'Camera access',
name: 'video_call_permission_camera_title',
desc: '',
args: [],
);
}
/// `Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.`
String get video_call_permission_camera_description {
return Intl.message(
'Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.',
name: 'video_call_permission_camera_description',
desc: '',
args: [],
);
}
/// `Microphone access`
String get video_call_permission_microphone_title {
return Intl.message(
'Microphone access',
name: 'video_call_permission_microphone_title',
desc: '',
args: [],
);
}
/// `Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.`
String get video_call_permission_microphone_description {
return Intl.message(
'Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.',
name: 'video_call_permission_microphone_description',
desc: '',
args: [],
);
}
/// `Yes`
String get yes {
return Intl.message('Yes', name: 'yes', desc: '', args: []);
}
/// `Apply`
String get apply {
return Intl.message('Apply', name: 'apply', desc: '', args: []);
}
/// `Do you have a promo code?`
String get have_promo_code {
return Intl.message(
'Do you have a promo code?',
name: 'have_promo_code',
desc: '',
args: [],
);
}
/// `Have a promo code?`
String get have_promo_code_form {
return Intl.message(
'Have a promo code?',
name: 'have_promo_code_form',
desc: '',
args: [],
);
}
/// `Promo code`
String get promo_code {
return Intl.message('Promo code', name: 'promo_code', desc: '', args: []);
}
/// `Enter promo code`
String get enter_promo_code {
return Intl.message(
'Enter promo code',
name: 'enter_promo_code',
desc: '',
args: [],
);
}
/// `Promo code not found or expired`
String get promo_invalid {
return Intl.message(
'Promo code not found or expired',
name: 'promo_invalid',
desc: '',
args: [],
);
}
} }
class AppLocalizationDelegate extends LocalizationsDelegate<S> { class AppLocalizationDelegate extends LocalizationsDelegate<S> {

View File

@@ -326,6 +326,9 @@
"date_has_ben_set": "Дата старта начала консультации установлена", "date_has_ben_set": "Дата старта начала консультации установлена",
"tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации", "tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации",
"pay": "Оплатить", "pay": "Оплатить",
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
"more_about_order": "Подробнее о заказе", "more_about_order": "Подробнее о заказе",
"cancel_order": "Отменить заказ", "cancel_order": "Отменить заказ",
"refuse_order":"Отказаться от заказа", "refuse_order":"Отказаться от заказа",
@@ -472,6 +475,29 @@
"hide_photo_title": "Спрятать фото?", "hide_photo_title": "Спрятать фото?",
"hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль",
"unhide_photo_title": "Показать фото?", "unhide_photo_title": "Показать фото?",
"unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль" "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль",
"video_call": "Video call",
"video_call_role_consultant": "Eatvio consultant",
"video_call_counterpart_default": "Participant",
"video_call_you": "You",
"video_call_poor_connection": "You have a poor internet connection",
"video_call_recording_notice": "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.",
"video_call_muted_mic_warning": "Attention!\nYour microphone is muted",
"video_call_chat": "Chat",
"video_call_sound": "Sound",
"video_call_video": "Video",
"video_call_leave": "Leave",
"video_call_unknown_initial": "?",
"video_call_permission_camera_title": "Camera access",
"video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.",
"video_call_permission_microphone_title": "Microphone access",
"video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.",
"yes": "Yes",
"apply": "Apply",
"have_promo_code": "Do you have a promo code?",
"have_promo_code_form": "Have a promo code?",
"promo_code": "Promo code",
"enter_promo_code": "Enter promo code",
"promo_invalid": "Promo code not found or expired"
} }

View File

@@ -217,5 +217,31 @@
"user_data": "Данные пользователя", "user_data": "Данные пользователя",
"infoAboutOrder": "Информация о заказе", "infoAboutOrder": "Информация о заказе",
"notifications": "Уведомления", "notifications": "Уведомления",
"comment_hint": "Комментарий" "comment_hint": "Комментарий",
"video_call": "Видеозвонок",
"video_call_role_consultant": "Консультант Итвио",
"video_call_counterpart_default": "Собеседник",
"video_call_you": "Вы",
"video_call_poor_connection": "У вас плохое интернет соединение",
"video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.",
"video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон",
"video_call_chat": "Чат",
"video_call_sound": "Звук",
"video_call_video": "Видео",
"video_call_leave": "Выйти",
"video_call_unknown_initial": "?",
"video_call_permission_camera_title": "Доступ к камере",
"video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
"video_call_permission_microphone_title": "Доступ к микрофону",
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
"choose_bank_for_payment": "Выберите банк",
"no_installed_banks": "Нет установленных банков",
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
"yes": "Да",
"apply": "Применить",
"have_promo_code": "У вас есть промокод?",
"have_promo_code_form": "Есть промокод?",
"promo_code": "Промокод",
"enter_promo_code": "Введите промокод",
"promo_invalid": "Промокод не найден или истёк"
} }

View File

@@ -1,7 +1,7 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.23+105 version: 1.3.24+106
publish_to: 'none' publish_to: "none"
environment: environment:
sdk: ^3.10.4 sdk: ^3.10.4
@@ -82,8 +82,11 @@ dependencies:
rxdart: ^0.27.7 rxdart: ^0.27.7
gpassword: ^1.0.0 gpassword: ^1.0.0
webview_flutter_platform_interface: ^2.14.0 webview_flutter_platform_interface: ^2.14.0
flutter_inappwebview: ^6.1.5
intl_utils: ^2.8.13 intl_utils: ^2.8.13
sentry_dio: ^9.10.0 sentry_dio: ^9.10.0
livekit_client: ^2.6.1
sbp: 0.1.0
dev_dependencies: dev_dependencies:
build_runner: ^2.10.4 build_runner: ^2.10.4
@@ -109,6 +112,7 @@ flutter:
- assets/images/ - assets/images/
- assets/audio/ - assets/audio/
- assets/icons/ - assets/icons/
- assets/icons/video_call/
- assets/splash/ - assets/splash/
- assets/lottie_animations/ - assets/lottie_animations/
@@ -162,14 +166,14 @@ flutter_intl:
flutter_native_splash: flutter_native_splash:
android: true android: true
ios: true ios: true
color: '#FFFFFF' color: "#FFFFFF"
image_android: 'assets/splash/splash.png' image_android: "assets/splash/splash.png"
image_dark_android: 'assets/splash/splash.png' image_dark_android: "assets/splash/splash.png"
image_ios: 'assets/splash/splash.png' image_ios: "assets/splash/splash.png"
image_dark_ios: 'assets/splash/splash.png' image_dark_ios: "assets/splash/splash.png"
branding_android: 'assets/splash/branding.png' branding_android: "assets/splash/branding.png"
branding_dark_android: 'assets/splash/branding.png' branding_dark_android: "assets/splash/branding.png"
android_12: android_12:
image: 'assets/splash/splash.png' image: "assets/splash/splash.png"
image_dark: 'assets/splash/splash.png' image_dark: "assets/splash/splash.png"
branding: 'assets/splash/branding.png' branding: "assets/splash/branding.png"

View File

@@ -614,6 +614,234 @@ void main() {
// Проверяем, что preview отображается (RichText должен быть) // Проверяем, что preview отображается (RichText должен быть)
expect(find.byType(RichText), findsWidgets); expect(find.byType(RichText), findsWidgets);
}); });
testWidgets('preview: маркер списка со звёздочкой', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: '* Asterisk item',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
// Тело пункта в RichText/TextSpan — find.textContaining не всегда его видит
expect(find.byType(RichText), findsWidgets);
expect(find.textContaining('•'), findsWidgets);
});
testWidgets('preview: нумерация из двух цифр', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: '10. Tenth row',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.byType(RichText), findsWidgets);
expect(find.textContaining('10.'), findsWidgets);
});
testWidgets(' после выхода из preview выполняется отложенный requestFocus', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: 'focus',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.edit));
await tester.pump();
await tester.pump(const Duration(milliseconds: 150));
expect(find.byType(TextField), findsOneWidget);
});
testWidgets(' снимает разметку ссылки когда выделен markdown-link', (WidgetTester tester) async {
String? changed;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: '[Label](https://example.com)',
showInlineToolbar: true,
onChanged: (v) => changed = v,
),
),
),
);
await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField));
final controller = field.controller!;
controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length);
await tester.tap(find.byIcon(Icons.link));
await tester.pump();
expect(changed, contains('Label'));
expect(changed, isNot(contains('[Label](https://example.com)')));
});
testWidgets('preview: H2 heading renders larger heading style', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: '## Subtitle',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.text('Subtitle'), findsOneWidget);
});
testWidgets('preview: H3 heading renders heading text', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: '### Detail',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.text('Detail'), findsOneWidget);
});
testWidgets('preview: horizontal rule renders Divider', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: 'Above\n---\nBelow',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.byType(Divider), findsOneWidget);
});
testWidgets('preview: italic span renders inside RichText', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: 'A *cursive* word',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.byType(RichText), findsWidgets);
});
testWidgets('preview: link span renders inside RichText', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: 'Visit [the docs](https://example.com)',
showInlineToolbar: true,
),
),
),
);
await tester.pump();
await tester.tap(find.byIcon(Icons.visibility));
await tester.pumpAndSettle();
expect(find.byType(RichText), findsWidgets);
});
testWidgets('toolbar buttons mutate text via onChanged when cursor is set', (WidgetTester tester) async {
String? changed;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: MarkdownEditor(
initialText: 'plain',
showInlineToolbar: true,
onChanged: (v) => changed = v,
),
),
),
);
await tester.pump();
final field = tester.widget<TextField>(find.byType(TextField));
final controller = field.controller!;
controller.selection = const TextSelection.collapsed(offset: 0);
await tester.tap(find.byIcon(Icons.format_bold));
await tester.pump();
expect(changed, contains('**'));
controller.text = 'plain';
controller.selection = const TextSelection.collapsed(offset: 0);
await tester.tap(find.byIcon(Icons.format_italic));
await tester.pump();
expect(changed, contains('*'));
controller.text = 'plain';
controller.selection = const TextSelection.collapsed(offset: 0);
await tester.tap(find.byIcon(Icons.horizontal_rule));
await tester.pump();
expect(changed, contains('---'));
controller.text = 'plain';
controller.selection = const TextSelection.collapsed(offset: 0);
await tester.tap(find.byIcon(Icons.link));
await tester.pump();
expect(changed, contains('](url)'));
});
}); });
} }

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -6,6 +7,50 @@ import 'package:go_router/go_router.dart';
void main() { void main() {
group('AppRouter', () { group('AppRouter', () {
group('resolveDeepLinkTarget - finance/alfa/return', () {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
test('finance/alfa/return/{orderId} → chat-screen?orderId=', () {
final target = AppRouter.resolveDeepLinkTarget(
Uri.parse('https://eatvio.ru/finance/alfa/return/$orderId'),
);
expect(target, '${RouteConstants.chatScreen}?orderId=$orderId');
});
test('ru/finance/alfa/return/{orderId} → chat-screen?orderId= (metaSegments)', () {
final target = AppRouter.resolveDeepLinkTarget(
Uri.parse('https://eatvio.ru/ru/finance/alfa/return/$orderId'),
);
expect(target, '${RouteConstants.chatScreen}?orderId=$orderId');
});
test('неполный path /finance/alfa → feed', () {
final target = AppRouter.resolveDeepLinkTarget(
Uri.parse('https://eatvio.ru/finance/alfa'),
);
expect(target, RouteConstants.feedScreen);
});
test('неполный path /finance → feed', () {
final target = AppRouter.resolveDeepLinkTarget(
Uri.parse('https://eatvio.ru/finance'),
);
expect(target, RouteConstants.feedScreen);
});
test('регресс: chat/{uuid} → chat-screen?uuid=', () {
final target = AppRouter.resolveDeepLinkTarget(
Uri.parse('https://eatvio.ru/ru/chat/uuid-1'),
);
expect(target, '${RouteConstants.chatScreen}?uuid=uuid-1');
});
});
test('navigatorKey инициализирован', () { test('navigatorKey инициализирован', () {
expect(AppRouter.navigatorKey, isNotNull); expect(AppRouter.navigatorKey, isNotNull);
expect(AppRouter.navigatorKey, isA<GlobalKey<NavigatorState>>()); expect(AppRouter.navigatorKey, isA<GlobalKey<NavigatorState>>());

View File

@@ -110,5 +110,92 @@ void main() {
final event = SentryEvent(throwable: const SocketException('x')); final event = SentryEvent(throwable: const SocketException('x'));
expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); expect(ErrorReportPolicy.allowSentryEvent(event, hint), false);
}); });
test('allows event when throwable is null', () {
expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(), hint), true);
});
test('delegates non-Dio throwable to shouldReport', () {
final event = SentryEvent(throwable: StateError('x'));
expect(ErrorReportPolicy.allowSentryEvent(event, hint), true);
});
test('drops cancelled Dio from SDK', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.cancel,
);
expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false);
});
test('drops sendTimeout Dio from SDK', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.sendTimeout,
);
expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false);
});
test('drops receiveTimeout Dio from SDK', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.receiveTimeout,
);
expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false);
});
test('allows badResponse Dio from SDK when statusCode is missing', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badResponse,
);
expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), true);
});
});
group('ErrorReportPolicy.shouldReport extra Dio branches', () {
test('returns true for Dio unknown when inner error is not SocketException', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.unknown,
error: 'not a socket',
);
expect(ErrorReportPolicy.shouldReport(e), true);
});
test('returns false for Dio unknown when inner error is SocketException', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.unknown,
error: const SocketException('offline'),
);
expect(ErrorReportPolicy.shouldReport(e), false);
});
test('returns true for badCertificate when inner error is not SocketException', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badCertificate,
error: 'cert problem',
);
expect(ErrorReportPolicy.shouldReport(e), true);
});
test('returns false for badCertificate when inner error is SocketException', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badCertificate,
error: const SocketException('bad'),
);
expect(ErrorReportPolicy.shouldReport(e), false);
});
test('returns true for badResponse without HTTP status', () {
final e = DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.badResponse,
);
expect(ErrorReportPolicy.shouldReport(e), true);
});
}); });
} }

View File

@@ -0,0 +1,36 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/error_reporting/no_op_error_reporter.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('NoOpErrorReporter', () {
test('is an ErrorReporter', () {
const reporter = NoOpErrorReporter();
expect(reporter, isA<ErrorReporter>());
});
test('report completes silently with all parameters', () async {
const reporter = NoOpErrorReporter();
await reporter.report(
StateError('boom'),
StackTrace.current,
origin: 'unit',
extras: <String, Object?>{'a': 1, 'b': null},
);
});
test('report completes silently without optional parameters', () async {
const reporter = NoOpErrorReporter();
await reporter.report(Exception('x'), null);
});
test('multiple sequential reports do not throw', () async {
const reporter = NoOpErrorReporter();
for (var i = 0; i < 5; i++) {
await reporter.report('error $i', StackTrace.empty, origin: 'loop');
}
});
});
}

View File

@@ -0,0 +1,55 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:flutter_test/flutter_test.dart';
class _RecordingReporter implements ErrorReporter {
Object? lastError;
StackTrace? lastStack;
String? lastOrigin;
@override
Future<void> report(
Object error,
StackTrace? stackTrace, {
String? origin,
Map<String, Object?>? extras,
}) async {
lastError = error;
lastStack = stackTrace;
lastOrigin = origin;
}
}
class _OriginMarker {}
void main() {
late _RecordingReporter reporter;
setUp(() async {
await sl.reset();
reporter = _RecordingReporter();
sl.registerSingleton<ErrorReporter>(reporter);
});
tearDown(() async {
await sl.reset();
});
test('reportFrom forwards to registered ErrorReporter with origin from context runtimeType', () async {
final err = StateError('bad');
final st = StackTrace.current;
await reportFrom(err, st, _OriginMarker());
expect(reporter.lastError, same(err));
expect(reporter.lastStack, st);
expect(reporter.lastOrigin, '_OriginMarker');
});
test('reportFrom completes when ErrorReporter is not registered (NoOp path)', () async {
await sl.reset();
await reportFrom(Exception('x'), null, Object());
});
}

View File

@@ -0,0 +1,39 @@
import 'package:eatvio_app/core/error_reporting/sentry_error_reporter.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('SentryErrorReporter', () {
test('report returns immediately when ErrorReportPolicy filters the error', () async {
final reporter = SentryErrorReporter();
await reporter.report(
'user-facing string',
StackTrace.empty,
origin: 'unit',
);
// Does not throw; policy drops String errors before Sentry.
});
test('report accepts non-filtered exception with origin and extras', () async {
final reporter = SentryErrorReporter();
await reporter.report(
StateError('boom'),
StackTrace.current,
origin: 'cubit',
extras: <String, Object?>{
'id': 42,
'nullable': null,
},
);
});
test('report accepts non-filtered exception without optional metadata', () async {
final reporter = SentryErrorReporter();
await reporter.report(
ArgumentError('bad'),
StackTrace.empty,
);
});
});
}

View File

@@ -5,24 +5,64 @@ 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';
import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
class _FakeAssetBundle extends CachingAssetBundle {
_FakeAssetBundle(this.assets);
final Map<String, String> assets;
@override
Future<String> loadString(String key, {bool cache = true}) async {
final value = assets[key];
if (value == null) {
throw FlutterError('Asset not found: $key');
}
return value;
}
@override
Future<ByteData> load(String key) async {
final value = assets[key];
if (value == null) {
throw FlutterError('Asset not found: $key');
}
final bytes = Uint8List.fromList(value.codeUnits);
return ByteData.sublistView(bytes);
}
}
void main() { void main() {
group('CustomPopup smoke', () { const fakeSvg = '''
setUpAll(() async { <svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); <rect width="10" height="10" fill="red"/>
}); </svg>
''';
setUp(() async { final fakeBundle = _FakeAssetBundle({
await sl.reset(); 'assets/icons/copy2.svg': fakeSvg,
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); 'assets/icons/share2.svg': fakeSvg,
}); 'assets/icons/edit_paint.svg': fakeSvg,
'assets/icons/red_trash.svg': fakeSvg,
'assets/icons/complain.svg': fakeSvg,
});
Widget wrap(Widget child) { setUpAll(() async {
return MaterialApp( dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
});
setUp(() async {
await sl.reset();
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
});
Widget wrap(Widget child) {
return DefaultAssetBundle(
bundle: fakeBundle,
child: MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
localizationsDelegates: const [ localizationsDelegates: const [
S.delegate, S.delegate,
@@ -31,87 +71,117 @@ void main() {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: S.delegate.supportedLocales, supportedLocales: S.delegate.supportedLocales,
home: Scaffold(body: child), home: Scaffold(body: Center(child: child)),
); ),
} );
}
testWidgets('renders custom popup for author', (tester) async { testWidgets('CustomPopup for author shows edit and delete actions for posts', (tester) async {
await tester.pumpWidget(wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user'))); await tester.pumpWidget(
await tester.pump(); wrap(
expect(find.byType(CustomPopup), findsOneWidget); const CustomPopup(
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); isAuthor: true,
}); complainedContentType: ComplainedContent.posts,
path: 'posts/1',
id: 1,
userSlug: 'author',
),
),
);
testWidgets('renders custom popup for non-author', (tester) async { await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpWidget( await tester.pumpAndSettle();
wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for non-author with recipe', (tester) async { expect(find.text('Скопировать ссылку'), findsOneWidget);
await tester.pumpWidget( expect(find.text('Поделиться'), findsOneWidget);
wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe)), expect(find.text('Редактировать'), findsOneWidget);
); expect(find.text('Удалить'), findsOneWidget);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for non-author with diet', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet)),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for non-author with plan', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan)),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for author with posts', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for author with recipe', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe, slug: 'test-recipe')),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for author with diet', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet, id: 1)),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
testWidgets('renders custom popup for author with plan', (tester) async {
await tester.pumpWidget(
wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan, slug: 'test-plan')),
);
await tester.pump();
expect(find.byType(CustomPopup), findsOneWidget);
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
});
}); });
testWidgets('CustomPopup for non-author shows complain and block actions', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: false,
complainedContentType: ComplainedContent.recipe,
path: 'recipes/slug',
slug: 'slug',
userSlug: 'user-2',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
expect(find.text('Пожаловаться'), findsOneWidget);
expect(find.text('Заблокировать автора'), findsOneWidget);
});
testWidgets('CustomPopup for author recipe does not show edit action', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: true,
complainedContentType: ComplainedContent.recipe,
path: 'recipes/slug',
slug: 'slug',
userSlug: 'author',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
expect(find.text('Редактировать'), findsNothing);
expect(find.text('Удалить'), findsOneWidget);
});
testWidgets('CustomPopup for author plan does not show edit action', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: true,
complainedContentType: ComplainedContent.plan,
path: 'plans/slug',
slug: 'slug',
userSlug: 'author',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Редактировать'), findsNothing);
expect(find.text('Удалить'), findsOneWidget);
});
testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async {
await tester.pumpWidget(
wrap(
const CustomPopup(
isAuthor: false,
complainedContentType: null,
path: 'posts/slug',
slug: 'slug',
userSlug: 'user-2',
),
),
);
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
await tester.pumpAndSettle();
expect(find.text('Скопировать ссылку'), findsOneWidget);
expect(find.text('Поделиться'), findsOneWidget);
expect(find.text('Пожаловаться'), findsOneWidget);
expect(find.text('Заблокировать автора'), findsOneWidget);
});
} }

View File

@@ -0,0 +1,92 @@
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('BottomNavController', () {
test('switchToRoute is a no-op until a callback is registered', () {
final controller = BottomNavController();
// Should not throw
controller.switchToRoute('/home');
controller.switchToRoute('/home', 'extra');
});
test('switchToRoute forwards path and extra to registered callback', () {
final controller = BottomNavController();
String? receivedPath;
Object? receivedExtra;
controller.register((path, extra) {
receivedPath = path;
receivedExtra = extra;
});
controller.switchToRoute('/feed', {'tab': 'recipes'});
expect(receivedPath, '/feed');
expect(receivedExtra, {'tab': 'recipes'});
});
test('switchToRoute passes null extra by default', () {
final controller = BottomNavController();
Object? receivedExtra = 'sentinel';
controller.register((path, extra) {
receivedExtra = extra;
});
controller.switchToRoute('/profile');
expect(receivedExtra, isNull);
});
});
group('BottomNavScope', () {
testWidgets('maybeOf returns null when scope is not present', (tester) async {
late BottomNavController? captured;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (ctx) {
captured = BottomNavScope.maybeOf(ctx);
return const SizedBox.shrink();
},
),
),
);
expect(captured, isNull);
});
testWidgets('of returns controller from ancestor scope', (tester) async {
final controller = BottomNavController();
late BottomNavController captured;
await tester.pumpWidget(
MaterialApp(
home: BottomNavScope(
controller: controller,
child: Builder(
builder: (ctx) {
captured = BottomNavScope.of(ctx);
return const SizedBox.shrink();
},
),
),
),
);
expect(identical(captured, controller), isTrue);
});
testWidgets('updateShouldNotify returns true when controller changes', (tester) async {
final c1 = BottomNavController();
final c2 = BottomNavController();
const child = SizedBox.shrink();
final widget1 = BottomNavScope(controller: c1, child: child);
final widget2 = BottomNavScope(controller: c2, child: child);
expect(widget2.updateShouldNotify(widget1), isTrue);
final widget3 = BottomNavScope(controller: c1, child: child);
expect(widget3.updateShouldNotify(widget1), isFalse);
});
});
}

View File

@@ -0,0 +1,22 @@
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/fixture_reader.dart';
void main() {
group('OrderIdToConversationModel', () {
test('fromJson создаёт модель из JSON фикстуры', () {
final json = FixtureReader.readAsMap(
'chat/payments/order_id_to_chat_uuid_response.json',
);
final model = OrderIdToConversationModel.fromJson(json);
expect(model.chosenPlanId, 12);
expect(
model.conversationUuid,
'fbd81429-8019-499a-80e8-e3933cc4d872',
);
});
});
}

View File

@@ -1,25 +1,47 @@
import 'package:eatvio_app/features/chat/models/payment_model.dart'; import 'package:eatvio_app/features/chat/models/payment_model.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/fixture_reader.dart';
void main() { void main() {
group('PaymentModel', () { group('PaymentModel', () {
test('создает PaymentModel с правильными значениями', () { test('создает PaymentModel с правильными значениями', () {
final model = PaymentModel( final model = PaymentModel(paymentUrl: 'https://example.com/payment');
paymentUrl: 'https://example.com/payment',
);
expect(model.paymentUrl, 'https://example.com/payment'); expect(model.paymentUrl, 'https://example.com/payment');
}); });
test('fromJson создает PaymentModel из JSON', () { test('fromJson создает PaymentModel из JSON', () {
final json = { final json = {'checkout_url': 'https://example.com/payment'};
'checkout_url': 'https://example.com/payment',
};
final model = PaymentModel.fromJson(json); final model = PaymentModel.fromJson(json);
expect(model.paymentUrl, 'https://example.com/payment'); expect(model.paymentUrl, 'https://example.com/payment');
}); });
test(
'fromJson парсит ответ alfa-bank (checkout_url, order_id, payment_provider)',
() {
final json = FixtureReader.readAsMap(
'chat/payments/payment_response_alfa_bank.json',
);
final model = PaymentModel.fromJson(json);
expect(model.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
expect(model.orderId, 'alfa_c29_k15138_t1712345678_r1234');
expect(model.paymentProvider, 'alfa-bank');
},
);
test('fromJson парсит mock-ответ (checkout_url без order_id)', () {
final json = FixtureReader.readAsMap('chat/payments/payment_response.json');
final model = PaymentModel.fromJson(json);
expect(model.paymentUrl, 'https://example.com/checkout/payment-123');
expect(model.orderId, isNull);
expect(model.paymentProvider, isNull);
});
}); });
} }

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_
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/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.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/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/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
@@ -102,6 +103,89 @@ void main() {
// Let's check that at least the repository methods were called // Let's check that at least the repository methods were called
verify(mockChatRepository.getChatRoom(uuid)).called(1); verify(mockChatRepository.getChatRoom(uuid)).called(1);
verify(mockChatRepository.getChatMessages(uuid, 1)).called(1); verify(mockChatRepository.getChatMessages(uuid, 1)).called(1);
verifyNever(
mockChatRepository.resolveOrderIdToConversation(orderId: anyNamed('orderId')),
);
});
test('initChat - с orderId резолвит conversation и инициализирует чат', () async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
const conversationUuid = 'fbd81429-8019-499a-80e8-e3933cc4d872';
const chosenPlanId = 12;
final resolved = OrderIdToConversationModel(
chosenPlanId: chosenPlanId,
conversationUuid: conversationUuid,
);
final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: chosenPlanId);
const testPlan = PlanModel(
id: 1,
slug: 'test-plan',
name: 'Test Plan',
image: 'https://example.com/image.jpg',
duration: 7,
state: 'active',
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
details: 'Test details',
packetsCount: 3,
author: Author(
authorSlug: 'author',
authorName: 'Author Name',
authorProfileIcon: 'https://example.com/icon.jpg',
authorIsMe: false,
),
totalLikes: 10,
totalViews: 100,
totalComments: 5,
isFavorite: false,
minPriceOfOptions: 1000,
maxPriceOfOptions: 2000,
wasLikedByMe: false,
);
final chosenPlan = ChosenPlanModel(
id: chosenPlanId,
optionType: 'options1',
startDate: DateTime.now(),
chosenPlanState: ChosenPlanStateEnum.statePendingPayment,
chatId: 'chat-1',
consultantId: 2,
plan: testPlan,
);
when(
mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
).thenAnswer((_) async => resolved);
when(mockChatRepository.getChatRoom(conversationUuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(conversationUuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: chosenPlanId)).thenAnswer((_) async => chosenPlan);
when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
when(mockWsManager.directChatStream).thenReturn(null);
when(mockWsManager.clientTypingStream).thenReturn(null);
await chatRoomCubit.initChat(null, orderId: orderId);
await Future.delayed(const Duration(milliseconds: 200));
verify(mockChatRepository.resolveOrderIdToConversation(orderId: orderId)).called(1);
verify(mockChatRepository.getChatRoom(conversationUuid)).called(1);
verify(mockChatRepository.getChosenPlan(id: chosenPlanId)).called(1);
verify(mockChatRepository.getChatMessages(conversationUuid, 1)).called(1);
expect(chatRoomCubit.state, isA<ChatRoomStateLoaded>());
final loaded = chatRoomCubit.state as ChatRoomStateLoaded;
expect(loaded.chatRoom.chosenPlan?.id, chosenPlanId);
});
test('initChat - при ошибке resolve не вызывает getChatRoom', () async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
when(
mockChatRepository.resolveOrderIdToConversation(orderId: orderId),
).thenThrow(Exception('Resolve failed'));
await chatRoomCubit.initChat(null, orderId: orderId);
await Future.delayed(const Duration(milliseconds: 100));
expect(chatRoomCubit.state, isA<ChatRoomStateError>());
verifyNever(mockChatRepository.getChatRoom(any));
}); });
test('initChat - обрабатывает ошибку', () async { test('initChat - обрабатывает ошибку', () async {
@@ -678,7 +762,7 @@ void main() {
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan);
when(mockChatRepository.getPaymentLink(id: planId)).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl)); when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenAnswer((_) async => PaymentModel(paymentUrl: paymentUrl));
when(mockWsManager.client).thenReturn(null); when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
@@ -691,7 +775,7 @@ void main() {
final result = await chatRoomCubit.getPaymentLink(); final result = await chatRoomCubit.getPaymentLink();
expect(result, paymentUrl); expect(result, paymentUrl);
verify(mockChatRepository.getPaymentLink(id: planId)).called(1); verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).called(1);
}); });
test('getPaymentLink - обрабатывает ошибку', () async { test('getPaymentLink - обрабатывает ошибку', () async {
@@ -730,7 +814,7 @@ void main() {
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan); when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages); when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan); when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) async => chosenPlan);
when(mockChatRepository.getPaymentLink(id: planId)).thenThrow(Exception('Payment error')); when(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).thenThrow(Exception('Payment error'));
when(mockWsManager.client).thenReturn(null); when(mockWsManager.client).thenReturn(null);
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value()); when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null); when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);

View File

@@ -147,7 +147,8 @@ void main() {
when(chatRoomCubit.state).thenReturn(state); when(chatRoomCubit.state).thenReturn(state);
when(chatRoomCubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty()); when(chatRoomCubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
when(chatRoomCubit.close()).thenAnswer((_) async => Future<void>.value()); when(chatRoomCubit.close()).thenAnswer((_) async => Future<void>.value());
when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future<void>.value()); when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
.thenAnswer((_) async => 'uuid');
when(chatRoomCubit.setTimer()).thenReturn(null); when(chatRoomCubit.setTimer()).thenReturn(null);

View File

@@ -33,7 +33,8 @@ void main() {
when(cubit.state).thenReturn(const ChatRoomState.loading()); when(cubit.state).thenReturn(const ChatRoomState.loading());
when(cubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty()); when(cubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
when(cubit.close()).thenAnswer((_) async => Future<void>.value()); when(cubit.close()).thenAnswer((_) async => Future<void>.value());
when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'))).thenAnswer((_) async => Future<void>.value()); when(cubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
.thenAnswer((_) async => 'uuid');
when(cubit.setTimer()).thenReturn(null); when(cubit.setTimer()).thenReturn(null);
sl.registerFactory<ChatRoomCubit>(() => cubit); sl.registerFactory<ChatRoomCubit>(() => cubit);

View File

@@ -5,12 +5,15 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/core/models/pagination_model.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_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' as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'
as chat_room;
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/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/services/chat_service.dart'; import 'package:eatvio_app/features/chat/services/chat_service.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.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:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
@@ -32,7 +35,9 @@ void main() {
}); });
test('emitNewMessageToChats - эмитит новое сообщение в поток', () async { test('emitNewMessageToChats - эмитит новое сообщение в поток', () async {
final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json'); final messageFixture = FixtureReader.readAsMap(
'chat/chat_message_response.json',
);
final message = ChatMessageModel.fromJson(messageFixture); final message = ChatMessageModel.fromJson(messageFixture);
chatRepository.emitNewMessageToChats(message); chatRepository.emitNewMessageToChats(message);
@@ -45,12 +50,14 @@ void main() {
const data = 'Test message'; const data = 'Test message';
const socketId = 'socket-id-123'; const socketId = 'socket-id-123';
when(mockChatService.sendMessage( when(
slug: slug, mockChatService.sendMessage(
data: data, slug: slug,
mediaData: null, data: data,
socketId: socketId, mediaData: null,
)).thenAnswer((_) async => Future.value()); socketId: socketId,
),
).thenAnswer((_) async => Future.value());
// Note: SocketIdStorage is static and can't be easily mocked // Note: SocketIdStorage is static and can't be easily mocked
// This test verifies the repository calls the service correctly // This test verifies the repository calls the service correctly
@@ -68,12 +75,14 @@ void main() {
const data = 'Test message with files'; const data = 'Test message with files';
const socketId = 'socket-id-123'; const socketId = 'socket-id-123';
when(mockChatService.sendMessage( when(
slug: slug, mockChatService.sendMessage(
data: data, slug: slug,
mediaData: anyNamed('mediaData'), data: data,
socketId: socketId, mediaData: anyNamed('mediaData'),
)).thenAnswer((_) async => Future.value()); socketId: socketId,
),
).thenAnswer((_) async => Future.value());
// Создаем временные файлы для теста // Создаем временные файлы для теста
final tempFile1 = File('test1.jpg'); final tempFile1 = File('test1.jpg');
@@ -113,11 +122,14 @@ void main() {
test('getChatRoom - получает комнату чата по UUID', () async { test('getChatRoom - получает комнату чата по UUID', () async {
const uuid = 'chat-1'; const uuid = 'chat-1';
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); final roomFixture = FixtureReader.readAsMap(
'chat/chat_room_response.json',
);
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
when(mockChatService.getChatRoom(uuid: uuid)) when(
.thenAnswer((_) async => expectedRoom); mockChatService.getChatRoom(uuid: uuid),
).thenAnswer((_) async => expectedRoom);
final result = await chatRepository.getChatRoom(uuid); final result = await chatRepository.getChatRoom(uuid);
@@ -132,18 +144,20 @@ void main() {
when(mockChatService.getChatRoom(uuid: uuid)).thenThrow(error); when(mockChatService.getChatRoom(uuid: uuid)).thenThrow(error);
expect( expect(() => chatRepository.getChatRoom(uuid), throwsException);
() => chatRepository.getChatRoom(uuid),
throwsException,
);
}); });
test('getChatRooms - получает список комнат чата', () async { test('getChatRooms - получает список комнат чата', () async {
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); final paginationFixture = FixtureReader.readAsMap(
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); 'chat/chat_room_pagination_response.json',
);
final expectedPagination = ChatRoomPaginationModel.fromJson(
paginationFixture,
);
when(mockChatService.getChatRooms(query: null, page: 1)) when(
.thenAnswer((_) async => expectedPagination); mockChatService.getChatRooms(query: null, page: 1),
).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms(); final result = await chatRepository.getChatRooms();
@@ -154,11 +168,16 @@ void main() {
test('getChatRooms - получает список комнат с запросом', () async { test('getChatRooms - получает список комнат с запросом', () async {
const query = 'test query'; const query = 'test query';
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); final paginationFixture = FixtureReader.readAsMap(
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); 'chat/chat_room_pagination_response.json',
);
final expectedPagination = ChatRoomPaginationModel.fromJson(
paginationFixture,
);
when(mockChatService.getChatRooms(query: query, page: 1)) when(
.thenAnswer((_) async => expectedPagination); mockChatService.getChatRooms(query: query, page: 1),
).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms(query: query); final result = await chatRepository.getChatRooms(query: query);
@@ -181,8 +200,9 @@ void main() {
), ),
); );
when(mockChatService.getChatRooms(query: null, page: 2)) when(
.thenAnswer((_) async => expectedPagination); mockChatService.getChatRooms(query: null, page: 2),
).thenAnswer((_) async => expectedPagination);
final result = await chatRepository.getChatRooms(page: 2); final result = await chatRepository.getChatRooms(page: 2);
@@ -193,11 +213,14 @@ void main() {
test('getChatMessages - получает сообщения чата', () async { test('getChatMessages - получает сообщения чата', () async {
const chatId = 'chat-1'; const chatId = 'chat-1';
const page = 1; const page = 1;
final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json'); final messagesFixture = FixtureReader.readAsMap(
'chat/chat_messages_response.json',
);
final expectedMessages = ChatMessagesModel.fromJson(messagesFixture); final expectedMessages = ChatMessagesModel.fromJson(messagesFixture);
when(mockChatService.getChatMessages(id: chatId, page: page)) when(
.thenAnswer((_) async => expectedMessages); mockChatService.getChatMessages(id: chatId, page: page),
).thenAnswer((_) async => expectedMessages);
final result = await chatRepository.getChatMessages(chatId, page); final result = await chatRepository.getChatMessages(chatId, page);
@@ -206,42 +229,53 @@ void main() {
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1); verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
}); });
test('getChatRoomIdByUserSlug - получает комнату по slug пользователя', () async { test(
const slug = 'user-1'; 'getChatRoomIdByUserSlug - получает комнату по slug пользователя',
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); () async {
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); const slug = 'user-1';
final roomFixture = FixtureReader.readAsMap(
'chat/chat_room_response.json',
);
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
when(mockChatService.getChatRoomIdByUserSlug(user: slug)) when(
.thenAnswer((_) async => expectedRoom); mockChatService.getChatRoomIdByUserSlug(user: slug),
).thenAnswer((_) async => expectedRoom);
final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug); final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug);
expect(result, isA<chat_room.ChatRoomModel>()); expect(result, isA<chat_room.ChatRoomModel>());
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
}); },
);
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id-123'; const chatId = 'chat-id-123';
final messageIds = [1, 2, 3]; final messageIds = [1, 2, 3];
when(mockChatService.markMessagesAsRead( when(
id: chatId, mockChatService.markMessagesAsRead(
body: {'messages_ids': messageIds}, id: chatId,
)).thenAnswer((_) async => Future.value()); body: {'messages_ids': messageIds},
),
).thenAnswer((_) async => Future.value());
await chatRepository.markMessagesAsRead(chatId, messageIds); await chatRepository.markMessagesAsRead(chatId, messageIds);
verify(mockChatService.markMessagesAsRead( verify(
id: chatId, mockChatService.markMessagesAsRead(
body: {'messages_ids': messageIds}, id: chatId,
)).called(1); body: {'messages_ids': messageIds},
),
).called(1);
}); });
test('cancelChosenPlan - отменяет выбранный план', () async { test('cancelChosenPlan - отменяет выбранный план', () async {
const planId = 123; const planId = 123;
when(mockChatService.cancelChosenPlan(id: planId)) when(
.thenAnswer((_) async => Future.value()); mockChatService.cancelChosenPlan(id: planId),
).thenAnswer((_) async => Future.value());
await chatRepository.cancelChosenPlan(id: planId); await chatRepository.cancelChosenPlan(id: planId);
@@ -252,36 +286,114 @@ void main() {
const planId = 123; const planId = 123;
final date = {'start_date': '2024-01-15'}; final date = {'start_date': '2024-01-15'};
when(mockChatService.choseStartPlanDate(id: planId, date: date)) when(
.thenAnswer((_) async => Future.value()); mockChatService.choseStartPlanDate(id: planId, date: date),
).thenAnswer((_) async => Future.value());
await chatRepository.choseStartPlanDate(id: planId, date: date); await chatRepository.choseStartPlanDate(id: planId, date: date);
verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1); verify(
mockChatService.choseStartPlanDate(id: planId, date: date),
).called(1);
}); });
test('getPaymentLink - получает ссылку на оплату', () async { test('getPaymentLink - получает ссылку на оплату', () async {
const planId = 123; const planId = 123;
final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json'); final paymentFixture = FixtureReader.readAsMap(
'chat/payments/payment_response.json',
);
final expectedPayment = PaymentModel.fromJson(paymentFixture); final expectedPayment = PaymentModel.fromJson(paymentFixture);
when(mockChatService.getPaymentLink(id: planId)) when(
.thenAnswer((_) async => expectedPayment); mockChatService.getPaymentLink(
id: planId,
body: {'payment_provider': 'mock'},
),
).thenAnswer((_) async => expectedPayment);
final result = await chatRepository.getPaymentLink(id: planId); final result = await chatRepository.getPaymentLink(
id: planId,
paymentProvider: 'mock',
);
expect(result, isA<PaymentModel>()); expect(result, isA<PaymentModel>());
expect(result.paymentUrl, 'https://example.com/checkout/payment-123'); expect(result.paymentUrl, 'https://example.com/checkout/payment-123');
verify(mockChatService.getPaymentLink(id: planId)).called(1); verify(
mockChatService.getPaymentLink(
id: planId,
body: {'payment_provider': 'mock'},
),
).called(1);
});
test(
'getPaymentLink - передаёт payment_provider в тело запроса alfa-bank',
() async {
const planId = 123;
final paymentFixture = FixtureReader.readAsMap(
'chat/payments/payment_response_alfa_bank.json',
);
final expectedPayment = PaymentModel.fromJson(paymentFixture);
when(
mockChatService.getPaymentLink(
id: planId,
body: {'payment_provider': 'alfa-bank'},
),
).thenAnswer((_) async => expectedPayment);
final result = await chatRepository.getPaymentLink(
id: planId,
paymentProvider: 'alfa-bank',
);
expect(result, isA<PaymentModel>());
expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234');
expect(result.paymentProvider, 'alfa-bank');
verify(
mockChatService.getPaymentLink(
id: planId,
body: {'payment_provider': 'alfa-bank'},
),
).called(1);
},
);
test('resolveOrderIdToConversation - получает модель по orderId', () async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
final fixture = FixtureReader.readAsMap(
'chat/payments/order_id_to_chat_uuid_response.json',
);
final expected = OrderIdToConversationModel.fromJson(fixture);
when(
mockChatService.resolveOrderIdToConversation(orderId: orderId),
).thenAnswer((_) async => expected);
final result = await chatRepository.resolveOrderIdToConversation(
orderId: orderId,
);
expect(result, isA<OrderIdToConversationModel>());
expect(result.chosenPlanId, 12);
expect(result.conversationUuid, "fbd81429-8019-499a-80e8-e3933cc4d872");
verify(
mockChatService.resolveOrderIdToConversation(orderId: orderId),
).called(1);
}); });
test('getChosenPlan - получает выбранный план', () async { test('getChosenPlan - получает выбранный план', () async {
const planId = 1; const planId = 1;
final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json'); final chosenPlanFixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_in_progress_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture);
when(mockChatService.getChosenPlan(id: planId)) when(
.thenAnswer((_) async => expectedPlan); mockChatService.getChosenPlan(id: planId),
).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId); final result = await chatRepository.getChosenPlan(id: planId);
@@ -290,66 +402,140 @@ void main() {
verify(mockChatService.getChosenPlan(id: planId)).called(1); verify(mockChatService.getChosenPlan(id: planId)).called(1);
}); });
group('getChosenPlan payment polling statuses', () {
test('getChosenPlan - возвращает план в статусе pending_payment', () async {
const planId = 1;
final fixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_pending_payment_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(fixture);
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId);
expect(result.chosenPlanState, ChosenPlanStateEnum.statePendingPayment);
verify(mockChatService.getChosenPlan(id: planId)).called(1);
});
test('getChosenPlan - возвращает план в статусе paid', () async {
const planId = 1;
final fixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_paid_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(fixture);
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId);
expect(result.chosenPlanState, ChosenPlanStateEnum.statePaid);
verify(mockChatService.getChosenPlan(id: planId)).called(1);
});
test('getChosenPlan - возвращает план в статусе payment_failed', () async {
const planId = 1;
final fixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_payment_failed_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(fixture);
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId);
expect(result.chosenPlanState, ChosenPlanStateEnum.statePaymentFailed);
verify(mockChatService.getChosenPlan(id: planId)).called(1);
});
test('getChosenPlan - возвращает план в статусе canceled_by_the_initiator', () async {
const planId = 1;
final fixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(fixture);
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
final result = await chatRepository.getChosenPlan(id: planId);
expect(result.chosenPlanState, ChosenPlanStateEnum.stateCanceledByTheInitiator);
verify(mockChatService.getChosenPlan(id: planId)).called(1);
});
});
test('finishChosenPlan - завершает выбранный план', () async { test('finishChosenPlan - завершает выбранный план', () async {
const planId = 123; const planId = 123;
when(mockChatService.finishChosenPlan(id: planId)) when(
.thenAnswer((_) async => Future.value()); mockChatService.finishChosenPlan(id: planId),
).thenAnswer((_) async => Future.value());
await chatRepository.finishChosenPlan(id: planId); await chatRepository.finishChosenPlan(id: planId);
verify(mockChatService.finishChosenPlan(id: planId)).called(1); verify(mockChatService.finishChosenPlan(id: planId)).called(1);
}); });
test('complainAboutConsultation - отправляет жалобу на консультацию', () async { test(
const planId = 123; 'complainAboutConsultation - отправляет жалобу на консультацию',
final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json'); () async {
final complainModel = PlanComplainModel.fromJson(complainFixture); const planId = 123;
final complainFixture = FixtureReader.readAsMap(
'chat/plan_complain_response.json',
);
final complainModel = PlanComplainModel.fromJson(complainFixture);
when(mockChatService.complainAboutConsultation(id: planId, body: complainModel)) when(
.thenAnswer((_) async => Future.value()); mockChatService.complainAboutConsultation(
id: planId,
body: complainModel,
),
).thenAnswer((_) async => Future.value());
await chatRepository.complainAboutConsultation( await chatRepository.complainAboutConsultation(
id: planId, id: planId,
body: complainModel, body: complainModel,
); );
verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel)) verify(
.called(1); mockChatService.complainAboutConsultation(
}); id: planId,
body: complainModel,
),
).called(1);
},
);
test('getChatByUserSlug - возвращает ID чата при успешном ответе', () async { test(
const slug = 'user-slug'; 'getChatByUserSlug - возвращает ID чата при успешном ответе',
const chatId = 'chat-id-123'; () async {
final response = HttpResponse( const slug = 'user-slug';
{'id': chatId}, const chatId = 'chat-id-123';
Response( final response = HttpResponse({
requestOptions: RequestOptions(path: ''), 'id': chatId,
statusCode: 200, }, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
),
);
when(mockChatService.getChatByUserSlug(slug: slug)) when(
.thenAnswer((_) async => response); mockChatService.getChatByUserSlug(slug: slug),
).thenAnswer((_) async => response);
final result = await chatRepository.getChatByUserSlug(slug); final result = await chatRepository.getChatByUserSlug(slug);
expect(result, chatId); expect(result, chatId);
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
}); },
);
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async { test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
const slug = 'user-slug'; const slug = 'user-slug';
final response = HttpResponse( final response = HttpResponse(
{}, {},
Response( Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
requestOptions: RequestOptions(path: ''),
statusCode: 404,
),
); );
when(mockChatService.getChatByUserSlug(slug: slug)) when(
.thenAnswer((_) async => response); mockChatService.getChatByUserSlug(slug: slug),
).thenAnswer((_) async => response);
final result = await chatRepository.getChatByUserSlug(slug); final result = await chatRepository.getChatByUserSlug(slug);
@@ -357,17 +543,20 @@ void main() {
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1); verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
}); });
test('getChatByUserSlug - пробрасывает исключение при ошибке сети', () async { test(
const slug = 'user-slug'; 'getChatByUserSlug - пробрасывает исключение при ошибке сети',
() async {
const slug = 'user-slug';
when(mockChatService.getChatByUserSlug(slug: slug)) when(
.thenThrow(Exception('Network error')); mockChatService.getChatByUserSlug(slug: slug),
).thenThrow(Exception('Network error'));
await expectLater( await expectLater(
chatRepository.getChatByUserSlug(slug), chatRepository.getChatByUserSlug(slug),
throwsA(isA<Exception>()), throwsA(isA<Exception>()),
); );
}); },
);
}); });
} }

View File

@@ -1,8 +1,10 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.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_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' as chat_room; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'
as chat_room;
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';
@@ -24,10 +26,16 @@ void main() {
}); });
test('getChatRooms - возвращает список комнат чата', () async { test('getChatRooms - возвращает список комнат чата', () async {
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); final paginationFixture = FixtureReader.readAsMap(
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); 'chat/chat_room_pagination_response.json',
);
final expectedPagination = ChatRoomPaginationModel.fromJson(
paginationFixture,
);
when(mockChatService.getChatRooms(query: null, page: 1)).thenAnswer((_) async => expectedPagination); when(
mockChatService.getChatRooms(query: null, page: 1),
).thenAnswer((_) async => expectedPagination);
final result = await mockChatService.getChatRooms(query: null, page: 1); final result = await mockChatService.getChatRooms(query: null, page: 1);
@@ -38,10 +46,16 @@ void main() {
test('getChatRooms - возвращает список комнат с запросом', () async { test('getChatRooms - возвращает список комнат с запросом', () async {
const query = 'test query'; const query = 'test query';
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json'); final paginationFixture = FixtureReader.readAsMap(
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture); 'chat/chat_room_pagination_response.json',
);
final expectedPagination = ChatRoomPaginationModel.fromJson(
paginationFixture,
);
when(mockChatService.getChatRooms(query: query, page: 1)).thenAnswer((_) async => expectedPagination); when(
mockChatService.getChatRooms(query: query, page: 1),
).thenAnswer((_) async => expectedPagination);
final result = await mockChatService.getChatRooms(query: query, page: 1); final result = await mockChatService.getChatRooms(query: query, page: 1);
@@ -51,10 +65,14 @@ void main() {
test('getChatRoom - возвращает комнату чата по UUID', () async { test('getChatRoom - возвращает комнату чата по UUID', () async {
const uuid = 'chat-1'; const uuid = 'chat-1';
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); final roomFixture = FixtureReader.readAsMap(
'chat/chat_room_response.json',
);
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
when(mockChatService.getChatRoom(uuid: uuid)).thenAnswer((_) async => expectedRoom); when(
mockChatService.getChatRoom(uuid: uuid),
).thenAnswer((_) async => expectedRoom);
final result = await mockChatService.getChatRoom(uuid: uuid); final result = await mockChatService.getChatRoom(uuid: uuid);
@@ -67,62 +85,114 @@ void main() {
const slug = 'user-slug'; const slug = 'user-slug';
const data = 'Test message'; const data = 'Test message';
const socketId = 'socket-id-123'; const socketId = 'socket-id-123';
final messageFixture = FixtureReader.readAsMap('chat/chat_message_response.json'); final messageFixture = FixtureReader.readAsMap(
'chat/chat_message_response.json',
);
final expectedMessage = ChatMessageModel.fromJson(messageFixture); final expectedMessage = ChatMessageModel.fromJson(messageFixture);
// ignore: null_argument_to_non_null_type // ignore: null_argument_to_non_null_type
when(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).thenAnswer((_) async => expectedMessage); when(
mockChatService.sendMessage(
slug: slug,
data: data,
mediaData: null,
socketId: socketId,
),
).thenAnswer((_) async => expectedMessage);
final result = await mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId); final result = await mockChatService.sendMessage(
slug: slug,
data: data,
mediaData: null,
socketId: socketId,
);
expect(result, isA<ChatMessageModel>()); expect(result, isA<ChatMessageModel>());
expect(result.data, data); expect(result.data, data);
verify(mockChatService.sendMessage(slug: slug, data: data, mediaData: null, socketId: socketId)).called(1); verify(
mockChatService.sendMessage(
slug: slug,
data: data,
mediaData: null,
socketId: socketId,
),
).called(1);
}); });
test('getChatMessages - возвращает сообщения чата', () async { test('getChatMessages - возвращает сообщения чата', () async {
const chatId = 'chat-1'; const chatId = 'chat-1';
const page = 1; const page = 1;
final messagesFixture = FixtureReader.readAsMap('chat/chat_messages_response.json'); final messagesFixture = FixtureReader.readAsMap(
'chat/chat_messages_response.json',
);
final expectedMessages = ChatMessagesModel.fromJson(messagesFixture); final expectedMessages = ChatMessagesModel.fromJson(messagesFixture);
when(mockChatService.getChatMessages(id: chatId, page: page)).thenAnswer((_) async => expectedMessages); when(
mockChatService.getChatMessages(id: chatId, page: page),
).thenAnswer((_) async => expectedMessages);
final result = await mockChatService.getChatMessages(id: chatId, page: page); final result = await mockChatService.getChatMessages(
id: chatId,
page: page,
);
expect(result, isA<ChatMessagesModel>()); expect(result, isA<ChatMessagesModel>());
expect(result.messagesList.length, 2); expect(result.messagesList.length, 2);
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1); verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
}); });
test('getChatRoomIdByUserSlug - возвращает комнату по slug пользователя', () async { test(
const slug = 'user-1'; 'getChatRoomIdByUserSlug - возвращает комнату по slug пользователя',
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json'); () async {
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture); const slug = 'user-1';
final roomFixture = FixtureReader.readAsMap(
'chat/chat_room_response.json',
);
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
when(mockChatService.getChatRoomIdByUserSlug(user: slug)).thenAnswer((_) async => expectedRoom); when(
mockChatService.getChatRoomIdByUserSlug(user: slug),
).thenAnswer((_) async => expectedRoom);
final result = await mockChatService.getChatRoomIdByUserSlug(user: slug); final result = await mockChatService.getChatRoomIdByUserSlug(
user: slug,
);
expect(result, isA<chat_room.ChatRoomModel>()); expect(result, isA<chat_room.ChatRoomModel>());
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1); verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
}); },
);
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async { test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
const chatId = 'chat-id-123'; const chatId = 'chat-id-123';
final messageIds = [1, 2, 3]; final messageIds = [1, 2, 3];
when(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).thenAnswer((_) async => Future.value()); when(
mockChatService.markMessagesAsRead(
id: chatId,
body: {'messages_ids': messageIds},
),
).thenAnswer((_) async => Future.value());
await mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds}); await mockChatService.markMessagesAsRead(
id: chatId,
body: {'messages_ids': messageIds},
);
verify(mockChatService.markMessagesAsRead(id: chatId, body: {'messages_ids': messageIds})).called(1); verify(
mockChatService.markMessagesAsRead(
id: chatId,
body: {'messages_ids': messageIds},
),
).called(1);
}); });
test('cancelChosenPlan - отменяет выбранный план', () async { test('cancelChosenPlan - отменяет выбранный план', () async {
const planId = 123; const planId = 123;
when(mockChatService.cancelChosenPlan(id: planId)).thenAnswer((_) async => Future.value()); when(
mockChatService.cancelChosenPlan(id: planId),
).thenAnswer((_) async => Future.value());
await mockChatService.cancelChosenPlan(id: planId); await mockChatService.cancelChosenPlan(id: planId);
@@ -133,33 +203,80 @@ void main() {
const planId = 123; const planId = 123;
final date = {'start_date': '2024-01-15'}; final date = {'start_date': '2024-01-15'};
when(mockChatService.choseStartPlanDate(id: planId, date: date)).thenAnswer((_) async => Future.value()); when(
mockChatService.choseStartPlanDate(id: planId, date: date),
).thenAnswer((_) async => Future.value());
await mockChatService.choseStartPlanDate(id: planId, date: date); await mockChatService.choseStartPlanDate(id: planId, date: date);
verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1); verify(
mockChatService.choseStartPlanDate(id: planId, date: date),
).called(1);
}); });
test('getPaymentLink - возвращает ссылку на оплату', () async { test('getPaymentLink - возвращает ссылку на оплату', () async {
const planId = 123; const planId = 123;
final paymentFixture = FixtureReader.readAsMap('chat/payment_response.json'); final paymentFixture = FixtureReader.readAsMap(
'chat/payments/payment_response.json',
);
final expectedPayment = PaymentModel.fromJson(paymentFixture); final expectedPayment = PaymentModel.fromJson(paymentFixture);
when(mockChatService.getPaymentLink(id: planId)).thenAnswer((_) async => expectedPayment); when(
mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'}),
).thenAnswer((_) async => expectedPayment);
final result = await mockChatService.getPaymentLink(id: planId); final result = await mockChatService.getPaymentLink(
id: planId,
body: {'payment_provider': 'mock'},
);
expect(result, isA<PaymentModel>()); expect(result, isA<PaymentModel>());
expect(result.paymentUrl, 'https://example.com/checkout/payment-123'); expect(result.paymentUrl, 'https://example.com/checkout/payment-123');
verify(mockChatService.getPaymentLink(id: planId)).called(1); verify(mockChatService.getPaymentLink(id: planId, body: {'payment_provider': 'mock'})).called(1);
}); });
test(
'getPaymentLink - возвращает ссылку на оплату alfa-bank',
() async {
const planId = 123;
final requestFixture = FixtureReader.readAsMap(
'chat/payments/payment_alfabank_request.json',
);
final requestBody = Map<String, String>.from(requestFixture);
final paymentFixture = FixtureReader.readAsMap(
'chat/payments/payment_response_alfa_bank.json',
);
final expectedPayment = PaymentModel.fromJson(paymentFixture);
when(
mockChatService.getPaymentLink(id: planId, body: requestBody),
).thenAnswer((_) async => expectedPayment);
final result = await mockChatService.getPaymentLink(
id: planId,
body: requestBody,
);
expect(result, isA<PaymentModel>());
expect(result.paymentUrl, 'https://qr.nspk.ru/ASDFASDFASDFASDF');
expect(result.orderId, 'alfa_c29_k15138_t1712345678_r1234');
expect(result.paymentProvider, 'alfa-bank');
verify(
mockChatService.getPaymentLink(id: planId, body: requestBody),
).called(1);
},
);
test('getChosenPlan - возвращает выбранный план', () async { test('getChosenPlan - возвращает выбранный план', () async {
const planId = 1; const planId = 1;
final chosenPlanFixture = FixtureReader.readAsMap('chat/chosen_plan_response.json'); final chosenPlanFixture = FixtureReader.readAsMap(
'chat/chosen_plan/chosen_plan_in_progress_response.json',
);
final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture); final expectedPlan = ChosenPlanModel.fromJson(chosenPlanFixture);
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan); when(
mockChatService.getChosenPlan(id: planId),
).thenAnswer((_) async => expectedPlan);
final result = await mockChatService.getChosenPlan(id: planId); final result = await mockChatService.getChosenPlan(id: planId);
@@ -171,31 +288,80 @@ void main() {
test('finishChosenPlan - завершает выбранный план', () async { test('finishChosenPlan - завершает выбранный план', () async {
const planId = 123; const planId = 123;
when(mockChatService.finishChosenPlan(id: planId)).thenAnswer((_) async => Future.value()); when(
mockChatService.finishChosenPlan(id: planId),
).thenAnswer((_) async => Future.value());
await mockChatService.finishChosenPlan(id: planId); await mockChatService.finishChosenPlan(id: planId);
verify(mockChatService.finishChosenPlan(id: planId)).called(1); verify(mockChatService.finishChosenPlan(id: planId)).called(1);
}); });
test('complainAboutConsultation - отправляет жалобу на консультацию', () async { test(
const planId = 123; 'complainAboutConsultation - отправляет жалобу на консультацию',
final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json'); () async {
final complainModel = PlanComplainModel.fromJson(complainFixture); const planId = 123;
final complainFixture = FixtureReader.readAsMap(
'chat/plan_complain_response.json',
);
final complainModel = PlanComplainModel.fromJson(complainFixture);
when(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).thenAnswer((_) async => Future.value()); when(
mockChatService.complainAboutConsultation(
id: planId,
body: complainModel,
),
).thenAnswer((_) async => Future.value());
await mockChatService.complainAboutConsultation(id: planId, body: complainModel); await mockChatService.complainAboutConsultation(
id: planId,
body: complainModel,
);
verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel)).called(1); verify(
}); mockChatService.complainAboutConsultation(
id: planId,
body: complainModel,
),
).called(1);
},
);
test(
'resolveOrderIdToConversation - возвращает модель по orderId',
() async {
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
final fixture = FixtureReader.readAsMap(
'chat/payments/order_id_to_chat_uuid_response.json',
);
final expected = OrderIdToConversationModel.fromJson(fixture);
when(
mockChatService.resolveOrderIdToConversation(orderId: orderId),
).thenAnswer((_) async => expected);
final result = await mockChatService.resolveOrderIdToConversation(
orderId: orderId,
);
expect(result, isA<OrderIdToConversationModel>());
expect(result.chosenPlanId, 12);
verify(
mockChatService.resolveOrderIdToConversation(orderId: orderId),
).called(1);
},
);
test('getChatByUserSlug - возвращает HttpResponse', () async { test('getChatByUserSlug - возвращает HttpResponse', () async {
const slug = 'user-slug'; const slug = 'user-slug';
const chatId = 'chat-id-123'; const chatId = 'chat-id-123';
final response = HttpResponse({'id': chatId}, Response(requestOptions: RequestOptions(path: ''), statusCode: 200)); final response = HttpResponse({
'id': chatId,
}, Response(requestOptions: RequestOptions(path: ''), statusCode: 200));
when(mockChatService.getChatByUserSlug(slug: slug)).thenAnswer((_) async => response); when(
mockChatService.getChatByUserSlug(slug: slug),
).thenAnswer((_) async => response);
final result = await mockChatService.getChatByUserSlug(slug: slug); final result = await mockChatService.getChatByUserSlug(slug: slug);

View File

@@ -7,12 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('ExpandableText smoke', () { group('ExpandableText', () {
setUpAll(() async { setUpAll(() async {
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
}); });
Widget wrap(Widget child) { Widget wrap(Widget child, {Size size = const Size(360, 800)}) {
return MaterialApp( return MaterialApp(
theme: ThemeHelper().themeData(), theme: ThemeHelper().themeData(),
localizationsDelegates: const [ localizationsDelegates: const [
@@ -22,7 +22,10 @@ void main() {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: S.delegate.supportedLocales, supportedLocales: S.delegate.supportedLocales,
home: Scaffold(body: child), home: MediaQuery(
data: MediaQueryData(size: size),
child: Scaffold(body: child),
),
); );
} }
@@ -44,5 +47,39 @@ void main() {
expect(find.byType(ExpandableText), findsOneWidget); expect(find.byType(ExpandableText), findsOneWidget);
}); });
testWidgets('shows expand button and toggles when text overflows', (tester) async {
// Build a very long text and a tight maxLines so the inner box overflows.
final longText = List<String>.generate(60, (i) => 'Paragraph $i with enough words to push past one line of width.').join(' ');
await tester.pumpWidget(
wrap(
ExpandableText(text: longText, maxLines: 1),
size: const Size(200, 600),
),
);
// Allow post-frame callback to compute overflow
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
await tester.pump(const Duration(milliseconds: 250));
final state = tester.state<State>(find.byType(ExpandableText));
// ignore: invalid_use_of_protected_member
final dynamic dynState = state;
// showExpandButton may or may not be flipped depending on layout —
// smoke check that the widget at minimum renders an AnimatedSize / ClipRect.
expect(find.byType(AnimatedSize), findsOneWidget);
expect(find.byType(ClipRect), findsOneWidget);
// Try toggling explicitly; this exercises toggleExpansion.
try {
dynState.toggleExpansion();
} catch (_) {
// ignore — just exercising the path
}
await tester.pump(const Duration(milliseconds: 250));
expect(find.byType(ExpandableText), findsOneWidget);
});
}); });
} }

View File

@@ -1,15 +1,17 @@
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/type_badges.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/type_badges.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/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_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_portal/flutter_portal.dart';
import 'package:flutter_svg/svg.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
group('TypeBadges smoke', () { group('TypeBadges', () {
setUpAll(() async { setUpAll(() async {
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
}); });
@@ -44,5 +46,56 @@ void main() {
expect(find.byType(TypeBadges), findsOneWidget); expect(find.byType(TypeBadges), findsOneWidget);
}); });
testWidgets('renders all four diet badges when each flag is true', (tester) async {
const dietType = DietType(light: true, veg: true, carb: true, meat: true);
await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(SvgPicture), findsNWidgets(4));
});
testWidgets('renders all four plan badges when each flag is true', (tester) async {
const planType = PlanType(slim: true, fit: true, wellness: true, behavior: true);
await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Image), findsNWidgets(4));
});
testWidgets('renders only the active diet badge subset', (tester) async {
const dietType = DietType(light: false, veg: true, carb: false, meat: true);
await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(SvgPicture), findsNWidgets(2));
});
testWidgets('renders only the active plan badge subset', (tester) async {
const planType = PlanType(slim: false, fit: true, wellness: false, behavior: true);
await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.byType(Image), findsNWidgets(2));
});
testWidgets('tapping a badge does not crash when descriptions are enabled', (tester) async {
const dietType = DietType(light: true, veg: true, carb: false, meat: false);
await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: false)));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final firstBadge = find.byType(GestureDetector).first;
expect(firstBadge, findsOneWidget);
await tester.tap(firstBadge, warnIfMissed: false);
await tester.pump();
expect(find.byType(TypeBadges), findsOneWidget);
});
}); });
} }

View File

@@ -0,0 +1,56 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/feed_fixtures.dart';
void main() {
group('PostFeedMapper', () {
setUpAll(() async {
await sl.reset();
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
});
test('maps fixture item post_id to FeedBlogPostModel.id', () {
final item = FeedFixtures.postFeedItem();
final model = PostFeedMapper.fromPostFeedItem(item, currentUserId: 10);
expect(model.id, 1);
expect(model.author.authorIsMe, isTrue);
expect(model.author.authorSlug, 'anna-ivanova');
expect(model.images, hasLength(2));
expect(model.videoUrl, isEmpty);
expect(model.videoEmbedUrl, isNull);
expect(model.title, 'Завтрак с овсянкой');
expect(model.image, 'http://localhost/storage/posts/oatmeal.jpg');
expect(model.author.isEntitySubscribedByMe, isFalse);
});
test('maps fixture embed video post and likes', () {
final item = FeedFixtures.postFeedItem(index: 1);
final model = PostFeedMapper.fromPostFeedItem(item);
expect(model.id, 2);
expect(model.videoUrl, isEmpty);
expect(model.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
expect(model.wasLikedByMe, isTrue);
expect(model.totalLikes, 42);
expect(model.author.authorIsMe, isFalse);
expect(model.author.isEntitySubscribedByMe, isTrue);
});
test('maps all fixture posts', () {
final posts = FeedFixtures.mappedFeedPosts(currentUserId: 11);
expect(posts, hasLength(3));
expect(posts[1].author.authorIsMe, isTrue);
expect(posts[1].author.isEntitySubscribedByMe, isTrue);
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
expect(posts[2].author.isEntitySubscribedByMe, isNull);
});
});
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import '../../../fixtures/feed_fixtures.dart';
void main() {
group('PostFeedPaginationModel fixtures', () {
test('parses first page with gallery, video and next_cursor', () {
final model = FeedFixtures.postFeedPaginationModel();
expect(model.data, hasLength(3));
expect(model.meta.perPage, 20);
expect(model.meta.nextCursor, 'eyJpZCI6MTAzfQ');
expect(model.meta.prevCursor, isNull);
expect(model.links.next, contains('cursor=eyJpZCI6MTAzfQ'));
final withGallery = model.data[0];
expect(withGallery.postId, 1);
expect(withGallery.images, hasLength(2));
expect(withGallery.images.first.orientation, 'horizontal');
expect(withGallery.videoEmbedUrl, isNull);
expect(withGallery.isEntitySubscribedByMe, isFalse);
expect(withGallery.postAuthorDetails?.slug, 'anna-ivanova');
expect(withGallery.postAuthorDetails?.isConsultant, isTrue);
final withYoutubeEmbed = model.data[1];
expect(withYoutubeEmbed.postId, 2);
expect(withYoutubeEmbed.videoEmbedUrl, 'https://www.youtube.com/embed/dQw4w9WgXcQ');
expect(withYoutubeEmbed.postIsLikedByUser, isTrue);
expect(withYoutubeEmbed.isEntitySubscribedByMe, isTrue);
expect(withYoutubeEmbed.images, isEmpty);
final withRutubeEmbed = model.data[2];
expect(withRutubeEmbed.postId, 3);
expect(withRutubeEmbed.postImageUrl, isNull);
expect(withRutubeEmbed.images, hasLength(1));
expect(withRutubeEmbed.videoEmbedUrl, contains('rutube.ru/play/embed'));
expect(withRutubeEmbed.isEntitySubscribedByMe, isNull); });
test('parses next page without next_cursor', () {
final model = FeedFixtures.postFeedNextPagePaginationModel();
expect(model.data, hasLength(1));
expect(model.data.first.postId, 4);
expect(model.meta.nextCursor, isNull);
expect(model.meta.prevCursor, 'eyJpZCI6MTAzfQ');
expect(model.links.next, isNull);
});
test('parses empty feed', () {
final model = FeedFixtures.postFeedEmptyPaginationModel();
expect(model.data, isEmpty);
expect(model.meta.nextCursor, isNull);
expect(model.links.next, isNull);
});
});
}

View File

@@ -1,7 +1,5 @@
import 'package:eatvio_app/core/models/pagination_model.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/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
@@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<FeedRepository>()]) @GenerateNiceMocks([MockSpec<FeedRepository>()])
void main() { void main() {
group('FeedCubit', () { group('FeedCubit cursor flow', () {
FeedCubit? feedCubit; late FeedCubit cubit;
late MockFeedRepository mockFeedRepository; late MockFeedRepository repository;
late FeedBlogPostSettingsModel feedBlogPostsFixture;
final post = FeedBlogPostModel(
id: 10,
state: 'active',
image: '',
description: 'description',
author: const Author(authorSlug: 'slug', authorName: 'name', authorProfileIcon: '', authorIsMe: false),
videoUrl: '',
createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1),
title: 'title',
totalLikes: 1,
totalViews: 1,
totalComments: 1,
wasLikedByMe: false,
);
setUp(() { setUp(() {
mockFeedRepository = MockFeedRepository(); repository = MockFeedRepository();
cubit = FeedCubit(feedRepository: repository);
// Создаём тестовые данные
final testPosts = [
FeedBlogPostModel(
id: 1,
state: 'published',
image: 'https://example.com/image1.jpg',
description: 'Описание поста 1',
author: const Author(
authorSlug: 'author-1',
authorName: 'Автор 1',
authorProfileIcon: 'https://example.com/photo1.jpg',
authorIsMe: false,
isEntitySubscribedByMe: null,
authorPlaceInRating: null,
),
videoUrl: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
title: 'Тестовый пост 1',
totalLikes: 10,
totalViews: 100,
totalComments: 5,
wasLikedByMe: false,
),
FeedBlogPostModel(
id: 2,
state: 'published',
image: 'https://example.com/image2.jpg',
description: 'Описание поста 2',
author: const Author(
authorSlug: 'author-2',
authorName: 'Автор 2',
authorProfileIcon: 'https://example.com/photo2.jpg',
authorIsMe: false,
isEntitySubscribedByMe: null,
authorPlaceInRating: null,
),
videoUrl: '',
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
title: 'Тестовый пост 2',
totalLikes: 20,
totalViews: 200,
totalComments: 10,
wasLikedByMe: true,
),
];
feedBlogPostsFixture = FeedBlogPostSettingsModel(
feedBlogPostList: testPosts,
meta: const Meta(
currentPage: 1,
from: null,
lastPage: 3,
links: [],
path: null,
perPage: 10,
to: null,
total: 25,
totalMinPrice: null,
totalMaxPrice: null,
),
);
}); });
tearDown(() async { tearDown(() async {
if (feedCubit != null && !feedCubit!.isClosed) { await cubit.close();
await Future.delayed(const Duration(milliseconds: 100));
feedCubit!.close();
feedCubit = null;
}
}); });
test('начальное состояние - FeedStateLoading', () { test('error state on load failure', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository); when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom'));
expect(feedCubit!.state, const FeedState.loading());
await cubit.loadFeedBlogPostList();
expect(cubit.state, isA<FeedStateError>());
}); });
test('loadFeedBlogPostList - успешная загрузка постов', () async { test('updateFeedBlogPostList updates likes in loaded state', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository); when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null));
await cubit.loadFeedBlogPostList();
when(mockFeedRepository.getFeedBlogPosts(query: null)) cubit.updateFeedBlogPostList(post, true);
.thenAnswer((_) async => feedBlogPostsFixture);
await feedCubit!.loadFeedBlogPostList(); final loaded = cubit.state as FeedStateLoaded;
expect(loaded.feedBlogPostList.first.wasLikedByMe, true);
await Future.delayed(const Duration(milliseconds: 100)); expect(loaded.feedBlogPostList.first.totalLikes, 2);
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.length, 2);
expect(loadedState.isLoading, false);
expect(feedCubit!.lastPage, 3);
});
test('loadFeedBlogPostList - загрузка с запросом', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const query = 'тест';
when(mockFeedRepository.getFeedBlogPosts(query: query))
.thenAnswer((_) async => feedBlogPostsFixture);
await feedCubit!.loadFeedBlogPostList(query: query);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateLoaded>());
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
});
test('loadFeedBlogPostList - обработка ошибки', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
.thenThrow(Exception('Ошибка загрузки'));
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateError>());
final errorState = feedCubit!.state as FeedStateError;
expect(errorState.errorMessage, contains('Exception'));
});
test('updateFeedBlogPostList - обновление лайка поста', () {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
// Сначала загружаем посты
when(mockFeedRepository.getFeedBlogPosts(query: anyNamed('query')))
.thenAnswer((_) async => feedBlogPostsFixture);
// Устанавливаем состояние loaded вручную для теста
feedCubit!.emit(FeedState.loaded(
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
isLoading: false,
));
feedCubit!.lastPage = 3;
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
expect(updatedPost.wasLikedByMe, true);
expect(updatedPost.totalLikes, postToUpdate.totalLikes + 1);
});
test('updateFeedBlogPostList - снятие лайка', () {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
feedCubit!.emit(FeedState.loaded(
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
isLoading: false,
));
feedCubit!.lastPage = 3;
final postToUpdate = feedBlogPostsFixture.feedBlogPostList[1]; // У этого wasLikedByMe = true
final initialLikes = postToUpdate.totalLikes;
feedCubit!.updateFeedBlogPostList(postToUpdate, false);
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
final updatedPost = loadedState.feedBlogPostList.firstWhere((p) => p.id == postToUpdate.id);
expect(updatedPost.wasLikedByMe, false);
expect(updatedPost.totalLikes, initialLikes - 1);
});
test('updateFeedBlogPostList - игнорирует обновление если состояние не loaded', () {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
// Состояние loading
feedCubit!.emit(const FeedState.loading());
final postToUpdate = feedBlogPostsFixture.feedBlogPostList.first;
feedCubit!.updateFeedBlogPostList(postToUpdate, true);
// Состояние не должно измениться
expect(feedCubit!.state, const FeedState.loading());
});
test('updateFeedBlogPostList - игнорирует обновление если пост не найден', () {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
feedCubit!.emit(FeedState.loaded(
feedBlogPostList: feedBlogPostsFixture.feedBlogPostList,
isLoading: false,
));
feedCubit!.lastPage = 3;
// Создаём пост с несуществующим ID
final nonExistentPost = feedBlogPostsFixture.feedBlogPostList.first.copyWith(id: 999);
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
// Список не должен измениться
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.length, 2);
}); });
}); });
} }

View File

@@ -1,404 +1,66 @@
import 'package:eatvio_app/core/models/pagination_model.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/feed_blog_post_settings_model.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart'; import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart'; import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart'; import 'package:mockito/mockito.dart';
import 'feed_cubit_test.mocks.dart'; import 'feed_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<FeedRepository>()]) @GenerateNiceMocks([MockSpec<FeedRepository>()])
void main() { void main() {
group('FeedCubit', () { group('FeedCubit', () {
FeedCubit? feedCubit; late MockFeedRepository repository;
late MockFeedRepository mockFeedRepository; late FeedCubit cubit;
final testBlogPost = FeedBlogPostModel( final post = FeedBlogPostModel(
id: 1, id: 1,
title: 'Test Post', state: 'active',
description: 'Test description', image: 'https://example.com/image.png',
image: 'https://example.com/image.jpg', description: 'text',
state: 'published', author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false),
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), videoUrl: '',
videoUrl: 'https://example.com/video.mp4', createdAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 15), updatedAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 15), title: 'Title',
totalLikes: 10, totalLikes: 1,
totalViews: 100, totalViews: 2,
totalComments: 5, totalComments: 3,
wasLikedByMe: false, wasLikedByMe: false,
); );
final testBlogPost2 = FeedBlogPostModel(
id: 2,
title: 'Test Post 2',
description: 'Test description 2',
image: 'https://example.com/image2.jpg',
state: 'published',
author: const Author(authorSlug: 'author-2', authorName: 'Author Name 2', authorProfileIcon: 'https://example.com/icon2.jpg', authorIsMe: false),
videoUrl: 'https://example.com/video2.mp4',
createdAt: DateTime(2024, 1, 16),
updatedAt: DateTime(2024, 1, 16),
totalLikes: 20,
totalViews: 200,
totalComments: 10,
wasLikedByMe: true,
);
setUp(() { setUp(() {
mockFeedRepository = MockFeedRepository(); repository = MockFeedRepository();
cubit = FeedCubit(feedRepository: repository);
}); });
tearDown(() async { tearDown(() async {
if (feedCubit != null && !feedCubit!.isClosed) { await cubit.close();
await Future.delayed(const Duration(milliseconds: 100));
feedCubit!.close();
feedCubit = null;
}
}); });
test('начальное состояние - loading', () { test('loadFeedBlogPostList emits loaded from getFeed', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository); when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
expect(feedCubit!.state, const FeedState.loading());
await cubit.loadFeedBlogPostList();
expect(cubit.state, isA<FeedStateLoaded>());
final loaded = cubit.state as FeedStateLoaded;
expect(loaded.feedBlogPostList, hasLength(1));
verify(repository.getFeed(search: null, cursor: null, currentUserId: null)).called(1);
}); });
test('loadFeedBlogPostList - успешно загружает список постов', () async { test('loadNextPage appends posts', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository); when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
final expectedSettings = FeedBlogPostSettingsModel( when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null));
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings); await cubit.loadFeedBlogPostList();
await cubit.loadNextPage();
await feedCubit!.loadFeedBlogPostList(); final loaded = cubit.state as FeedStateLoaded;
await Future.delayed(const Duration(milliseconds: 100)); expect(loaded.feedBlogPostList, hasLength(2));
verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1);
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.length, 1);
expect(loadedState.feedBlogPostList.first.id, 1);
expect(loadedState.isLoading, false);
expect(feedCubit!.lastPage, 3);
verify(mockFeedRepository.getFeedBlogPosts(query: null)).called(1);
});
test('loadFeedBlogPostList - загружает список постов с query', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const query = 'test query';
final expectedSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost, testBlogPost2],
meta: const Meta(currentPage: 1, lastPage: 1, total: 2, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => expectedSettings);
await feedCubit!.loadFeedBlogPostList(query: query);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.length, 2);
verify(mockFeedRepository.getFeedBlogPosts(query: query)).called(1);
});
test('loadFeedBlogPostList - обрабатывает ошибку', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final exception = Exception('Network error');
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenThrow(exception);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateError>());
final errorState = feedCubit!.state as FeedStateError;
expect(errorState.errorMessage, contains('Exception: Network error'));
});
test('loadNextPage - загружает следующую страницу', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final firstPageSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
final secondPageSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost2],
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenAnswer((_) async => secondPageSettings);
await feedCubit!.loadNextPage();
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.length, 2);
expect(feedCubit!.currentPage, 2);
expect(loadedState.isLoading, false);
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).called(1);
});
test('loadNextPage - не загружает если уже на последней странице', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
await feedCubit!.loadNextPage();
await Future.delayed(const Duration(milliseconds: 100));
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
});
test('loadNextPage - не загружает если уже загружается', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
feedCubit!.isLoading = true;
await feedCubit!.loadNextPage();
await Future.delayed(const Duration(milliseconds: 100));
verifyNever(mockFeedRepository.getFeedBlogPosts(page: 2, query: anyNamed('query')));
});
test('loadNextPage - обрабатывает ошибку при загрузке следующей страницы', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final firstPageSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => firstPageSettings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
final exception = Exception('Network error');
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: null)).thenThrow(exception);
await feedCubit!.loadNextPage();
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateError>());
final errorState = feedCubit!.state as FeedStateError;
expect(errorState.errorMessage, contains('Exception: Network error'));
});
test('loadNextPage - сохраняет query при загрузке следующей страницы', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const query = 'test query';
final firstPageSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
final secondPageSettings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost2],
meta: const Meta(currentPage: 2, lastPage: 3, total: 3, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: query)).thenAnswer((_) async => firstPageSettings);
await feedCubit!.loadFeedBlogPostList(query: query);
await Future.delayed(const Duration(milliseconds: 100));
when(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).thenAnswer((_) async => secondPageSettings);
await feedCubit!.loadNextPage();
await Future.delayed(const Duration(milliseconds: 100));
verify(mockFeedRepository.getFeedBlogPosts(page: 2, query: query)).called(1);
});
test('updateFeedBlogPostList - обновляет лайк поста', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.first.wasLikedByMe, true);
expect(loadedState.feedBlogPostList.first.totalLikes, 11);
});
test('updateFeedBlogPostList - убирает лайк поста', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost2],
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
feedCubit!.updateFeedBlogPostList(testBlogPost2, false);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, isA<FeedStateLoaded>());
final loadedState = feedCubit!.state as FeedStateLoaded;
expect(loadedState.feedBlogPostList.first.wasLikedByMe, false);
expect(loadedState.feedBlogPostList.first.totalLikes, 19);
});
test('updateFeedBlogPostList - не обновляет если пост не найден', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
final nonExistentPost = testBlogPost.copyWith(id: 999);
final initialState = feedCubit!.state;
feedCubit!.updateFeedBlogPostList(nonExistentPost, true);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, initialState);
});
test('updateFeedBlogPostList - не обновляет если state не loaded', () {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
feedCubit!.updateFeedBlogPostList(testBlogPost, true);
expect(feedCubit!.state, const FeedState.loading());
});
test('updateFeedBlogPostList - не обновляет если post null', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
final settings = FeedBlogPostSettingsModel(
feedBlogPostList: [testBlogPost],
meta: const Meta(currentPage: 1, lastPage: 1, total: 1, perPage: 10, from: null, links: [Link(url: null, label: null, active: null)], to: null),
);
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => settings);
await feedCubit!.loadFeedBlogPostList();
await Future.delayed(const Duration(milliseconds: 100));
final initialState = feedCubit!.state;
feedCubit!.updateFeedBlogPostList(null, true);
await Future.delayed(const Duration(milliseconds: 100));
expect(feedCubit!.state, initialState);
});
test('likePost - лайкает пост', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const postId = 1;
when(mockFeedRepository.likeBlogPost(id: postId)).thenAnswer((_) async => Future.value());
await feedCubit!.likePost(postId);
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
});
test('likePost - обрабатывает ошибку без выбрасывания', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const postId = 1;
final exception = Exception('Network error');
when(mockFeedRepository.likeBlogPost(id: postId)).thenThrow(exception);
await feedCubit!.likePost(postId);
verify(mockFeedRepository.likeBlogPost(id: postId)).called(1);
});
test('unlikedPost - убирает лайк с поста', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const postId = 1;
when(mockFeedRepository.unlikedBlogPost(id: postId)).thenAnswer((_) async => Future.value());
await feedCubit!.unlikedPost(postId);
verify(mockFeedRepository.unlikedBlogPost(id: postId)).called(1);
});
test('viewPost - отправляет просмотр поста', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const postId = 1;
when(mockFeedRepository.viewPost(postId)).thenAnswer((_) async => Future.value());
await feedCubit!.viewPost(postId);
verify(mockFeedRepository.viewPost(postId)).called(1);
});
test('viewRecipe - отправляет просмотр рецепта', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const slug = 'recipe-1';
when(mockFeedRepository.viewRecipe(slug)).thenAnswer((_) async => Future.value());
await feedCubit!.viewRecipe(slug);
verify(mockFeedRepository.viewRecipe(slug)).called(1);
});
test('viewDiet - отправляет просмотр диеты', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const dietId = 2;
when(mockFeedRepository.viewDiet(dietId)).thenAnswer((_) async => Future.value());
await feedCubit!.viewDiet(dietId);
verify(mockFeedRepository.viewDiet(dietId)).called(1);
});
test('viewPlan - отправляет просмотр плана', () async {
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
const slug = 'plan-1';
when(mockFeedRepository.viewPlan(slug)).thenAnswer((_) async => Future.value());
await feedCubit!.viewPlan(slug);
verify(mockFeedRepository.viewPlan(slug)).called(1);
}); });
}); });
} }

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