Compare commits
52 Commits
offline-di
...
personaliz
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 | ||
| bd2de07b8f | |||
|
|
eee912f05b | ||
|
|
e316bf1e0e | ||
|
|
de19af14bf | ||
|
|
b064c7016e | ||
|
|
e94681266a | ||
|
|
0ca2d1a1cc | ||
|
|
be924265a1 | ||
| a0edfe942b | |||
|
|
51397003b5 | ||
|
|
e10610d93c | ||
|
|
d21cc46558 | ||
|
|
2ab26bbda5 | ||
|
|
41e556d172 | ||
|
|
6d920857f6 | ||
|
|
7183fc5d7d | ||
|
|
3852a06ab4 | ||
| 7db9af9894 | |||
| fb99a42115 | |||
|
|
54c27c28cd | ||
|
|
fe243965e3 | ||
|
|
56313a4fcc | ||
|
|
cb36ef8e07 | ||
|
|
b2072ab626 | ||
| 5b72684c95 | |||
|
|
91ad51d340 | ||
| 01282a35cb | |||
| 0991ac555e | |||
| b5a889cbb0 | |||
| 985154c863 | |||
| f1e07b3d86 | |||
| 062079e7f1 | |||
| 24652884ee | |||
| ea9cdb93d4 | |||
| 7c4010b275 | |||
| 4458aaf74f | |||
| f20f871c88 | |||
| f61fb8c6b3 | |||
| 3d14dee4e9 | |||
| 38647b4ac6 | |||
| c836b600c6 | |||
| dfe11a6df2 | |||
| 35cad4e05e |
@@ -152,9 +152,10 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
## 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
|
||||
None documented at initialization.
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
2
Makefile
@@ -10,4 +10,4 @@ build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<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.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
@@ -86,5 +88,168 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</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>
|
||||
</manifest>
|
||||
|
||||
BIN
assets/icons/video_call/eatvio_c.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
3
assets/icons/video_call/poor_connection.svg
Normal 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 |
3
assets/icons/video_call/vc_cam_off.svg
Normal 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 |
BIN
assets/icons/video_call/vc_camera_switch.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/icons/video_call/vc_control_chat.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
assets/icons/video_call/vc_control_leave.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/icons/video_call/vc_control_mic.png
Normal file
|
After Width: | Height: | Size: 694 B |
BIN
assets/icons/video_call/vc_control_mic_off.png
Normal file
|
After Width: | Height: | Size: 855 B |
BIN
assets/icons/video_call/vc_control_sound_active.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/video_call/vc_control_video.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/icons/video_call/vc_expand.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
3
assets/icons/video_call/vc_expand.svg
Normal 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 |
9
assets/icons/video_call/vc_mic_off.svg
Normal 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 |
5
assets/icons/video_call/vc_mic_on.svg
Normal 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 |
3
assets/icons/video_call/vc_shrink.svg
Normal 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 |
3
assets/icons/video_call/warning.svg
Normal 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 |
@@ -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>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
@@ -72,6 +74,57 @@
|
||||
<string>yandexauth</string>
|
||||
<string>yandexauth2</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>
|
||||
<key>YAClientId</key>
|
||||
<string>5399b03636104c6d920b87c1ad329254</string>
|
||||
|
||||
@@ -123,7 +123,21 @@ class AppRouter {
|
||||
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 segments = uri.pathSegments;
|
||||
|
||||
@@ -189,6 +203,10 @@ class AppRouter {
|
||||
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
||||
if (target != null) return target;
|
||||
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');
|
||||
@@ -196,7 +214,7 @@ class AppRouter {
|
||||
}
|
||||
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
return _resolveDeepLinkUri(state.uri);
|
||||
return resolveDeepLinkTarget(state.uri);
|
||||
}
|
||||
|
||||
static void navigateByDeepLinkUrl(String url) {
|
||||
@@ -230,7 +248,7 @@ class AppRouter {
|
||||
);
|
||||
}
|
||||
|
||||
final target = _resolveDeepLinkUri(uri);
|
||||
final target = resolveDeepLinkTarget(uri);
|
||||
FlutterNativeSplash.remove();
|
||||
if (target != null) {
|
||||
appRouter.go(target);
|
||||
|
||||
@@ -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/notifications/presentation/notifications_cubit/notifications_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/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
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<UserCardsCubit>(() => UserCardsCubit(cardsRepository: 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<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
|
||||
@@ -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_plans/repositories/my_diets_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/plans/repositories/plan_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_v2/repositories/registration_repository_v2.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';
|
||||
|
||||
/// Регистрация всех Repository в системе внедрения зависимостей
|
||||
@@ -49,6 +52,7 @@ class RepositoryRegistrations {
|
||||
static void _registerSingletonRepositories() {
|
||||
// Core repositories
|
||||
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
|
||||
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||
@@ -72,6 +76,8 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: 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<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||
|
||||
|
||||
@@ -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_plans/services/my_plans_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/plans/services/plan_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_v2/services/registration_service_v2.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';
|
||||
|
||||
/// Регистрация всех Service в системе внедрения зависимостей
|
||||
@@ -50,6 +52,7 @@ class ServiceRegistrations {
|
||||
static void _registerSingletonServices() {
|
||||
// Core services
|
||||
sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
|
||||
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
|
||||
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
||||
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
||||
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
||||
@@ -80,6 +83,7 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class ErrorReportPolicy {
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
case DioExceptionType.transformTimeout:
|
||||
return true;
|
||||
case DioExceptionType.unknown:
|
||||
case DioExceptionType.badResponse:
|
||||
|
||||
@@ -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/payment_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/stepper_become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
@@ -210,13 +211,23 @@ class OtherRoutes {
|
||||
name: 'chatScreen',
|
||||
builder: (context, state) {
|
||||
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
||||
if (uuidFromQuery != null) {
|
||||
return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false);
|
||||
final orderIdFromQuery = state.uri.queryParameters['orderId'];
|
||||
if (uuidFromQuery != null || orderIdFromQuery != null) {
|
||||
return ChatScreen(
|
||||
uuid: uuidFromQuery,
|
||||
orderId: orderIdFromQuery,
|
||||
sendInitialMessage: false,
|
||||
);
|
||||
}
|
||||
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?;
|
||||
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
|
||||
return ChatScreen(
|
||||
uuid: uuid,
|
||||
orderId: orderId,
|
||||
sendInitialMessage: sendInitialMessage,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -224,6 +235,27 @@ class OtherRoutes {
|
||||
name: 'emptyChatScreen',
|
||||
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
|
||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
||||
|
||||
@@ -53,6 +53,7 @@ class RouteConstants {
|
||||
static const String photoViewScreen = '/photo-view-screen';
|
||||
static const String chatScreen = '/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 blockedUsersScreen = '/blocked-users-screen';
|
||||
static const String notificationsSettingsScreen = '/notifications-settings-screen';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
|
||||
/// Helper class for managing themes and colors.
|
||||
class ThemeHelper {
|
||||
// The current app theme
|
||||
@@ -200,6 +199,12 @@ class PrimaryColors {
|
||||
Color get grey7B => const Color(0xFF7B7B7B);
|
||||
|
||||
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();
|
||||
|
||||
15
lib/features/chat/models/order_id_to_conversation_model.dart
Normal 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);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class PaymentModel with _$PaymentModel {
|
||||
|
||||
factory PaymentModel({
|
||||
@JsonKey(name: 'checkout_url') required String paymentUrl,
|
||||
@JsonKey(name: 'order_id') String? orderId,
|
||||
@JsonKey(name: 'payment_provider') String? paymentProvider,
|
||||
}) = _PaymentModel;
|
||||
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) => _$PaymentModelFromJson(json);
|
||||
}
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
emit(const ChatRoomState.loading());
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
if (chatRoom.chosenPlanId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
|
||||
|
||||
String? resolvedUuid = uuid;
|
||||
int? resolvedChosenPlanId;
|
||||
|
||||
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;
|
||||
await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id);
|
||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||
emit(
|
||||
ChatRoomState.loaded(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
@@ -115,6 +136,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
return resolvedUuid;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -122,6 +144,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
messagesSubscription?.cancel();
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +320,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
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;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -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/plans/enums/chosen_plan_state_enum.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:file_picker/file_picker.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -35,10 +36,11 @@ import 'package:intl/intl.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
final String uuid;
|
||||
final String? uuid;
|
||||
final String? orderId;
|
||||
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
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -54,6 +56,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
|
||||
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
||||
String? _effectiveUuid;
|
||||
|
||||
String? get _conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||
|
||||
void _onHide() {
|
||||
chatRoomCubit.cancelTimer();
|
||||
}
|
||||
@@ -63,26 +70,41 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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
|
||||
void didUpdateWidget(ChatScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.uuid != widget.uuid) {
|
||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
||||
if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) {
|
||||
_effectiveUuid = widget.uuid;
|
||||
_initChatRoom();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_effectiveUuid = widget.uuid;
|
||||
scrollController.addListener(_onScroll);
|
||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
||||
_initChatRoom();
|
||||
chatRoomCubit.setTimer();
|
||||
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final uuid = _conversationUuid;
|
||||
if (uuid == null) return;
|
||||
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 {
|
||||
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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/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/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/pages/payment_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotificationBannerState extends State<NotificationBanner> {
|
||||
bool isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
|
||||
child: _StarIcon(),
|
||||
),
|
||||
if (isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 40, top: 24),
|
||||
child: Loader(),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
onTap: () {
|
||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
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},
|
||||
);
|
||||
if (planId != null) {
|
||||
showConsultationPaymentFlow(context, planId: planId);
|
||||
}
|
||||
},
|
||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
|
||||
@@ -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_room_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/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||
@@ -98,8 +99,19 @@ class ChatRepository {
|
||||
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
required int id,
|
||||
required String paymentProvider,
|
||||
}) 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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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_room_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/plan_complain_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')
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
@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}')
|
||||
|
||||
@@ -106,9 +106,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
backgroundColor: appTheme.gray100,
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
|
||||
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_comments") required int totalComments,
|
||||
@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;
|
||||
|
||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||
|
||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
93
lib/features/feed/models/post_feed_pagination_model.dart
Normal 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();
|
||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class FeedCubit extends Cubit<FeedState> {
|
||||
final FeedRepository feedRepository;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
String? _nextCursor;
|
||||
bool _hasMore = true;
|
||||
bool isLoading = false;
|
||||
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 {
|
||||
_query = query;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
||||
lastPage = feedBlogPosts.meta.lastPage;
|
||||
_nextCursor = null;
|
||||
_hasMore = true;
|
||||
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
||||
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadNextPage() async {
|
||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
||||
isLoading = true;
|
||||
Future<void> loadNextPage({int? currentUserId}) async {
|
||||
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
||||
}
|
||||
isLoading = true;
|
||||
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||
|
||||
try {
|
||||
if (currentPage < lastPage && !isClosed) {
|
||||
currentPage++;
|
||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
||||
isLoading = false;
|
||||
if (!isClosed) {
|
||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
if (!isClosed) {
|
||||
emit(FeedState.error(errorMessage: e.toString()));
|
||||
}
|
||||
try {
|
||||
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||
_nextCursor = feed.nextCursor;
|
||||
_hasMore = _nextCursor != null;
|
||||
if (!isClosed && state is FeedStateLoaded) {
|
||||
emit(
|
||||
FeedState.loaded(
|
||||
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
query: query,
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans(query: query);
|
||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||
switch (_selectedChipIndex) {
|
||||
case 0:
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||
);
|
||||
break;
|
||||
case 1 when !hidePlans:
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
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:eatvio_app/core/app_config.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/markdown_html_widget.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_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:flutter/material.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';
|
||||
|
||||
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
|
||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||
@@ -30,243 +35,448 @@ class BlogPostWidget extends StatefulWidget {
|
||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
late bool isLiked;
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
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);
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final galleryUrls = post.images.map((e) => PostFeedMapper.resolveMediaUrl(e.url)).where((e) => e.isNotEmpty).toList();
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.feedBlogPostList[widget.index].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 (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
||||
);
|
||||
},
|
||||
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),
|
||||
],
|
||||
),
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
_BlogPostMediaContent(
|
||||
galleryUrls: galleryUrls,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
onOpenGallery: _openGallery,
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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/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/profile_cubit/profile_cubit.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:flutter/material.dart';
|
||||
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class BlogPosts extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||
child: BlocBuilder<FeedCubit, FeedState>(
|
||||
builder: (BuildContext context, FeedState state) {
|
||||
if (state is FeedStateLoaded) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
||||
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||
return false;
|
||||
},
|
||||
child: ListView.separated(
|
||||
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
|
||||
if (index == state.feedBlogPostList.length) {
|
||||
return const Loader();
|
||||
}
|
||||
final post = state.feedBlogPostList[index];
|
||||
return VisibilityDetector(
|
||||
key: ValueKey(post.id),
|
||||
onVisibilityChanged: (info) {
|
||||
final id = state.feedBlogPostList[index].id;
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
||||
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||
},
|
||||
key: ValueKey(index),
|
||||
child: BlogPostWidget(index: index),
|
||||
child: BlogPostWidget(post: post),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
} 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 {
|
||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})();
|
||||
''';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||
@@ -1,9 +1,9 @@
|
||||
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/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/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/view_model.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
@@ -13,9 +13,12 @@ class FeedRepository {
|
||||
|
||||
FeedRepository({required this.feedService});
|
||||
|
||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
||||
return (await feedService.getBlogPostList(page: page, query: query));
|
||||
|
||||
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||
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 {
|
||||
|
||||
@@ -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_consultant_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/view_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
@@ -18,6 +19,12 @@ abstract class FeedService {
|
||||
@GET('/api/blogPosts/index')
|
||||
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}')
|
||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit<OrderInfoState> {
|
||||
try {
|
||||
final plan = state.chosenPlan;
|
||||
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;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
49
lib/features/payment/presentation/payment_flow.dart
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
281
lib/features/payment/presentation/widgets/promo_code_modal.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/features/payment/repositories/payment_repository.dart
Normal 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'},
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/features/payment/repositories/sbp_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
43
lib/features/payment/services/sbp_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
|
||||
@JsonValue('personal_diet_setted')
|
||||
personalDietSet,
|
||||
@JsonValue('charge_was_collected')
|
||||
stateChargeWasCollected;
|
||||
stateChargeWasCollected,
|
||||
@JsonValue('payment_failed')
|
||||
statePaymentFailed;
|
||||
|
||||
static List<ChosenPlanStateEnum> getShowableChatBadgeStates() {
|
||||
return [
|
||||
@@ -53,6 +55,7 @@ enum ChosenPlanStateEnum {
|
||||
case ChosenPlanStateEnum.stateInCoordination:
|
||||
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
|
||||
case ChosenPlanStateEnum.statePendingPayment:
|
||||
case ChosenPlanStateEnum.statePaymentFailed:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
@@ -68,7 +71,7 @@ enum ChosenPlanStateEnum {
|
||||
ChosenPlanStateEnum.stateLastDayFinished,
|
||||
ChosenPlanStateEnum.stateComplained,
|
||||
ChosenPlanStateEnum.stateDone,
|
||||
ChosenPlanStateEnum.personalDietSet
|
||||
ChosenPlanStateEnum.personalDietSet,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
37
lib/features/video_calls/models/video_call_models.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/features/video_calls/services/video_call_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -123,6 +123,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"another_form_of_minute": MessageLookupByLibrary.simpleMessage("минут"),
|
||||
"another_form_of_stage": MessageLookupByLibrary.simpleMessage("этапа"),
|
||||
"answer": MessageLookupByLibrary.simpleMessage("Ответить"),
|
||||
"apply": MessageLookupByLibrary.simpleMessage("Apply"),
|
||||
"archived": MessageLookupByLibrary.simpleMessage("В архиве"),
|
||||
"atLeast3Symbols": MessageLookupByLibrary.simpleMessage(
|
||||
"Минимум 3 символа",
|
||||
@@ -173,6 +174,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"chest": MessageLookupByLibrary.simpleMessage("Грудь"),
|
||||
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
|
||||
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите банк",
|
||||
),
|
||||
"choose_date": MessageLookupByLibrary.simpleMessage("Выбрать дату"),
|
||||
"choose_diet_duration": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите срок диеты",
|
||||
@@ -378,6 +382,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"enter_information": MessageLookupByLibrary.simpleMessage(
|
||||
"Введите информацию...",
|
||||
),
|
||||
"enter_promo_code": MessageLookupByLibrary.simpleMessage(
|
||||
"Enter promo code",
|
||||
),
|
||||
"error_desc": MessageLookupByLibrary.simpleMessage(
|
||||
"Произошла ошибка. Мы уже работаем над её устранением. Попробуйте позже",
|
||||
),
|
||||
@@ -463,6 +470,12 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"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_error": MessageLookupByLibrary.simpleMessage("Введите рост"),
|
||||
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
|
||||
@@ -588,6 +601,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"no_default_cards": MessageLookupByLibrary.simpleMessage(
|
||||
"У вас нет привязанных карт. Что бы начать работу, привяжите карту",
|
||||
),
|
||||
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
|
||||
"Нет установленных банков",
|
||||
),
|
||||
"not_liked_groceries": MessageLookupByLibrary.simpleMessage(
|
||||
"Не любимые продукты",
|
||||
),
|
||||
@@ -699,6 +715,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Разница в ценах, это комиссия Итвио",
|
||||
),
|
||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Promo code not found or expired",
|
||||
),
|
||||
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
|
||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||
@@ -737,6 +757,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"reply": MessageLookupByLibrary.simpleMessage("Ответить"),
|
||||
"resend": MessageLookupByLibrary.simpleMessage("Отправить повторно"),
|
||||
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
|
||||
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось начать оплату. Попробуйте позже",
|
||||
),
|
||||
"send": MessageLookupByLibrary.simpleMessage("Отправить"),
|
||||
"send_complain": MessageLookupByLibrary.simpleMessage("Отправить жалобу"),
|
||||
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
|
||||
@@ -873,6 +896,41 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"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(
|
||||
"Насилие и вражда",
|
||||
),
|
||||
@@ -909,6 +967,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
|
||||
"Неверные данные пользователя",
|
||||
),
|
||||
"yes": MessageLookupByLibrary.simpleMessage("Yes"),
|
||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||
),
|
||||
|
||||
@@ -45,6 +45,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"another_form_of_day": MessageLookupByLibrary.simpleMessage("дня"),
|
||||
"answer": MessageLookupByLibrary.simpleMessage("Ответить"),
|
||||
"apply": MessageLookupByLibrary.simpleMessage("Применить"),
|
||||
"archived": MessageLookupByLibrary.simpleMessage("В архиве"),
|
||||
"authentificate": MessageLookupByLibrary.simpleMessage("Войти"),
|
||||
"authorization": MessageLookupByLibrary.simpleMessage("Авторизация"),
|
||||
@@ -62,6 +63,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"chat": MessageLookupByLibrary.simpleMessage("Чат"),
|
||||
"chest": MessageLookupByLibrary.simpleMessage("Грудь"),
|
||||
"chest_circumference": MessageLookupByLibrary.simpleMessage("Обхват груди"),
|
||||
"choose_bank_for_payment": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите банк",
|
||||
),
|
||||
"choose_diet_duration": MessageLookupByLibrary.simpleMessage(
|
||||
"Выберите срок диеты",
|
||||
),
|
||||
@@ -151,6 +155,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"enter_information": MessageLookupByLibrary.simpleMessage(
|
||||
"Введите информацию...",
|
||||
),
|
||||
"enter_promo_code": MessageLookupByLibrary.simpleMessage(
|
||||
"Введите промокод",
|
||||
),
|
||||
"exit": MessageLookupByLibrary.simpleMessage("Выйти"),
|
||||
"expand": MessageLookupByLibrary.simpleMessage("Показать полностью"),
|
||||
"fats": MessageLookupByLibrary.simpleMessage("Жиры"),
|
||||
@@ -181,6 +188,12 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"gallery": MessageLookupByLibrary.simpleMessage("Галерея"),
|
||||
"gr": MessageLookupByLibrary.simpleMessage("гр"),
|
||||
"grams": MessageLookupByLibrary.simpleMessage("гр"),
|
||||
"have_promo_code": MessageLookupByLibrary.simpleMessage(
|
||||
"У вас есть промокод?",
|
||||
),
|
||||
"have_promo_code_form": MessageLookupByLibrary.simpleMessage(
|
||||
"Есть промокод?",
|
||||
),
|
||||
"height_measurements": MessageLookupByLibrary.simpleMessage("Замер роста"),
|
||||
"hide": MessageLookupByLibrary.simpleMessage("Свернуть"),
|
||||
"hip_circumference": MessageLookupByLibrary.simpleMessage("Обхват бедер"),
|
||||
@@ -243,6 +256,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"no": MessageLookupByLibrary.simpleMessage("Нет"),
|
||||
"no_account": MessageLookupByLibrary.simpleMessage("У меня нет аккаунта"),
|
||||
"no_installed_banks": MessageLookupByLibrary.simpleMessage(
|
||||
"Нет установленных банков",
|
||||
),
|
||||
"notifications": MessageLookupByLibrary.simpleMessage("Уведомления"),
|
||||
"one_option": MessageLookupByLibrary.simpleMessage("1 опция"),
|
||||
"option": MessageLookupByLibrary.simpleMessage("Опция"),
|
||||
@@ -306,6 +322,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Разница в ценах, это комиссия Итвио",
|
||||
),
|
||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||
"Промокод не найден или истёк",
|
||||
),
|
||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
|
||||
@@ -314,6 +334,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Снять с публикации",
|
||||
),
|
||||
"save": MessageLookupByLibrary.simpleMessage("Сохранить"),
|
||||
"sbp_payment_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Не удалось начать оплату. Попробуйте позже",
|
||||
),
|
||||
"services": MessageLookupByLibrary.simpleMessage("Оказание услуг"),
|
||||
"settings": MessageLookupByLibrary.simpleMessage("Настройки"),
|
||||
"share": MessageLookupByLibrary.simpleMessage("Поделиться"),
|
||||
@@ -353,6 +376,41 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"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_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"),
|
||||
"weight": MessageLookupByLibrary.simpleMessage("Вес"),
|
||||
@@ -369,6 +427,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
|
||||
"Неверные данные пользователя",
|
||||
),
|
||||
"yes": MessageLookupByLibrary.simpleMessage("Да"),
|
||||
"you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"),
|
||||
"your_blood_pressure": MessageLookupByLibrary.simpleMessage(
|
||||
"Ваше артериальное давление?",
|
||||
|
||||
@@ -2649,6 +2649,36 @@ class S {
|
||||
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 {
|
||||
return Intl.message(
|
||||
@@ -4018,6 +4048,191 @@ class S {
|
||||
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> {
|
||||
|
||||
@@ -326,6 +326,9 @@
|
||||
"date_has_ben_set": "Дата старта начала консультации установлена",
|
||||
"tap_on_link_to_pay": "Перейдите по ссылке для оплаты консультации",
|
||||
"pay": "Оплатить",
|
||||
"choose_bank_for_payment": "Выберите банк",
|
||||
"no_installed_banks": "Нет установленных банков",
|
||||
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
|
||||
"more_about_order": "Подробнее о заказе",
|
||||
"cancel_order": "Отменить заказ",
|
||||
"refuse_order":"Отказаться от заказа",
|
||||
@@ -472,6 +475,29 @@
|
||||
"hide_photo_title": "Спрятать фото?",
|
||||
"hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль",
|
||||
"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"
|
||||
}
|
||||
|
||||
|
||||
@@ -217,5 +217,31 @@
|
||||
"user_data": "Данные пользователя",
|
||||
"infoAboutOrder": "Информация о заказе",
|
||||
"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": "Промокод не найден или истёк"
|
||||
}
|
||||
|
||||
28
pubspec.yaml
@@ -1,7 +1,7 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.23+105
|
||||
publish_to: 'none'
|
||||
version: 1.3.24+106
|
||||
publish_to: "none"
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.4
|
||||
@@ -82,8 +82,11 @@ dependencies:
|
||||
rxdart: ^0.27.7
|
||||
gpassword: ^1.0.0
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
flutter_inappwebview: ^6.1.5
|
||||
intl_utils: ^2.8.13
|
||||
sentry_dio: ^9.10.0
|
||||
livekit_client: ^2.6.1
|
||||
sbp: 0.1.0
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.10.4
|
||||
@@ -109,6 +112,7 @@ flutter:
|
||||
- assets/images/
|
||||
- assets/audio/
|
||||
- assets/icons/
|
||||
- assets/icons/video_call/
|
||||
- assets/splash/
|
||||
- assets/lottie_animations/
|
||||
|
||||
@@ -162,14 +166,14 @@ flutter_intl:
|
||||
flutter_native_splash:
|
||||
android: true
|
||||
ios: true
|
||||
color: '#FFFFFF'
|
||||
image_android: 'assets/splash/splash.png'
|
||||
image_dark_android: 'assets/splash/splash.png'
|
||||
image_ios: 'assets/splash/splash.png'
|
||||
image_dark_ios: 'assets/splash/splash.png'
|
||||
branding_android: 'assets/splash/branding.png'
|
||||
branding_dark_android: 'assets/splash/branding.png'
|
||||
color: "#FFFFFF"
|
||||
image_android: "assets/splash/splash.png"
|
||||
image_dark_android: "assets/splash/splash.png"
|
||||
image_ios: "assets/splash/splash.png"
|
||||
image_dark_ios: "assets/splash/splash.png"
|
||||
branding_android: "assets/splash/branding.png"
|
||||
branding_dark_android: "assets/splash/branding.png"
|
||||
android_12:
|
||||
image: 'assets/splash/splash.png'
|
||||
image_dark: 'assets/splash/splash.png'
|
||||
branding: 'assets/splash/branding.png'
|
||||
image: "assets/splash/splash.png"
|
||||
image_dark: "assets/splash/splash.png"
|
||||
branding: "assets/splash/branding.png"
|
||||
|
||||
@@ -614,6 +614,234 @@ void main() {
|
||||
// Проверяем, что preview отображается (RichText должен быть)
|
||||
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)'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -6,6 +7,50 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
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 инициализирован', () {
|
||||
expect(AppRouter.navigatorKey, isNotNull);
|
||||
expect(AppRouter.navigatorKey, isA<GlobalKey<NavigatorState>>());
|
||||
|
||||
@@ -110,5 +110,92 @@ void main() {
|
||||
final event = SentryEvent(throwable: const SocketException('x'));
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
36
test/core/error_reporting/no_op_error_reporter_test.dart
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
55
test/core/error_reporting/report_from_test.dart
Normal 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());
|
||||
});
|
||||
}
|
||||
39
test/core/error_reporting/sentry_error_reporter_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.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_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() {
|
||||
group('CustomPopup smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
});
|
||||
const fakeSvg = '''
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10">
|
||||
<rect width="10" height="10" fill="red"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
final fakeBundle = _FakeAssetBundle({
|
||||
'assets/icons/copy2.svg': fakeSvg,
|
||||
'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) {
|
||||
return MaterialApp(
|
||||
setUpAll(() async {
|
||||
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(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
@@ -31,87 +71,117 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: S.delegate.supportedLocales,
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
home: Scaffold(body: Center(child: child)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('renders custom popup for author', (tester) async {
|
||||
await tester.pumpWidget(wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user')));
|
||||
await tester.pump();
|
||||
expect(find.byType(CustomPopup), findsOneWidget);
|
||||
expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget);
|
||||
});
|
||||
testWidgets('CustomPopup for author shows edit and delete actions for posts', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'posts/1',
|
||||
id: 1,
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('renders custom popup for non-author', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
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);
|
||||
});
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
testWidgets('renders custom popup for non-author with recipe', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe)),
|
||||
);
|
||||
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);
|
||||
});
|
||||
expect(find.text('Скопировать ссылку'), findsOneWidget);
|
||||
expect(find.text('Поделиться'), findsOneWidget);
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
expect(find.text('Удалить'), 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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,47 @@
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
|
||||
void main() {
|
||||
group('PaymentModel', () {
|
||||
test('создает PaymentModel с правильными значениями', () {
|
||||
final model = PaymentModel(
|
||||
paymentUrl: 'https://example.com/payment',
|
||||
);
|
||||
final model = PaymentModel(paymentUrl: 'https://example.com/payment');
|
||||
|
||||
expect(model.paymentUrl, 'https://example.com/payment');
|
||||
});
|
||||
|
||||
test('fromJson создает PaymentModel из JSON', () {
|
||||
final json = {
|
||||
'checkout_url': 'https://example.com/payment',
|
||||
};
|
||||
final json = {'checkout_url': 'https://example.com/payment'};
|
||||
|
||||
final model = PaymentModel.fromJson(json);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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/my_finance/repositories/cards_repository.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/feed/models/diets_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
|
||||
verify(mockChatRepository.getChatRoom(uuid)).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 {
|
||||
@@ -678,7 +762,7 @@ void main() {
|
||||
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
|
||||
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||
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.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||
@@ -691,7 +775,7 @@ void main() {
|
||||
final result = await chatRoomCubit.getPaymentLink();
|
||||
|
||||
expect(result, paymentUrl);
|
||||
verify(mockChatRepository.getPaymentLink(id: planId)).called(1);
|
||||
verify(mockChatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank')).called(1);
|
||||
});
|
||||
|
||||
test('getPaymentLink - обрабатывает ошибку', () async {
|
||||
@@ -730,7 +814,7 @@ void main() {
|
||||
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
|
||||
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||
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.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||
|
||||
@@ -147,7 +147,8 @@ void main() {
|
||||
when(chatRoomCubit.state).thenReturn(state);
|
||||
when(chatRoomCubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
|
||||
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);
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ void main() {
|
||||
when(cubit.state).thenReturn(const ChatRoomState.loading());
|
||||
when(cubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
|
||||
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);
|
||||
sl.registerFactory<ChatRoomCubit>(() => cubit);
|
||||
|
||||
|
||||
@@ -5,12 +5,15 @@ import 'package:dio/dio.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_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/order_id_to_conversation_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/repositories/chat_repository.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:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
@@ -32,7 +35,9 @@ void main() {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
@@ -45,12 +50,14 @@ void main() {
|
||||
const data = 'Test message';
|
||||
const socketId = 'socket-id-123';
|
||||
|
||||
when(mockChatService.sendMessage(
|
||||
slug: slug,
|
||||
data: data,
|
||||
mediaData: null,
|
||||
socketId: socketId,
|
||||
)).thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockChatService.sendMessage(
|
||||
slug: slug,
|
||||
data: data,
|
||||
mediaData: null,
|
||||
socketId: socketId,
|
||||
),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
// Note: SocketIdStorage is static and can't be easily mocked
|
||||
// This test verifies the repository calls the service correctly
|
||||
@@ -68,12 +75,14 @@ void main() {
|
||||
const data = 'Test message with files';
|
||||
const socketId = 'socket-id-123';
|
||||
|
||||
when(mockChatService.sendMessage(
|
||||
slug: slug,
|
||||
data: data,
|
||||
mediaData: anyNamed('mediaData'),
|
||||
socketId: socketId,
|
||||
)).thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockChatService.sendMessage(
|
||||
slug: slug,
|
||||
data: data,
|
||||
mediaData: anyNamed('mediaData'),
|
||||
socketId: socketId,
|
||||
),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
// Создаем временные файлы для теста
|
||||
final tempFile1 = File('test1.jpg');
|
||||
@@ -113,11 +122,14 @@ void main() {
|
||||
|
||||
test('getChatRoom - получает комнату чата по UUID', () async {
|
||||
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);
|
||||
|
||||
when(mockChatService.getChatRoom(uuid: uuid))
|
||||
.thenAnswer((_) async => expectedRoom);
|
||||
when(
|
||||
mockChatService.getChatRoom(uuid: uuid),
|
||||
).thenAnswer((_) async => expectedRoom);
|
||||
|
||||
final result = await chatRepository.getChatRoom(uuid);
|
||||
|
||||
@@ -132,18 +144,20 @@ void main() {
|
||||
|
||||
when(mockChatService.getChatRoom(uuid: uuid)).thenThrow(error);
|
||||
|
||||
expect(
|
||||
() => chatRepository.getChatRoom(uuid),
|
||||
throwsException,
|
||||
);
|
||||
expect(() => chatRepository.getChatRoom(uuid), throwsException);
|
||||
});
|
||||
|
||||
test('getChatRooms - получает список комнат чата', () async {
|
||||
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
|
||||
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
|
||||
final paginationFixture = FixtureReader.readAsMap(
|
||||
'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 chatRepository.getChatRooms();
|
||||
|
||||
@@ -154,11 +168,16 @@ void main() {
|
||||
|
||||
test('getChatRooms - получает список комнат с запросом', () async {
|
||||
const query = 'test query';
|
||||
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
|
||||
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
|
||||
final paginationFixture = FixtureReader.readAsMap(
|
||||
'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 chatRepository.getChatRooms(query: query);
|
||||
|
||||
@@ -181,8 +200,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(mockChatService.getChatRooms(query: null, page: 2))
|
||||
.thenAnswer((_) async => expectedPagination);
|
||||
when(
|
||||
mockChatService.getChatRooms(query: null, page: 2),
|
||||
).thenAnswer((_) async => expectedPagination);
|
||||
|
||||
final result = await chatRepository.getChatRooms(page: 2);
|
||||
|
||||
@@ -193,11 +213,14 @@ void main() {
|
||||
test('getChatMessages - получает сообщения чата', () async {
|
||||
const chatId = 'chat-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);
|
||||
|
||||
when(mockChatService.getChatMessages(id: chatId, page: page))
|
||||
.thenAnswer((_) async => expectedMessages);
|
||||
when(
|
||||
mockChatService.getChatMessages(id: chatId, page: page),
|
||||
).thenAnswer((_) async => expectedMessages);
|
||||
|
||||
final result = await chatRepository.getChatMessages(chatId, page);
|
||||
|
||||
@@ -206,42 +229,53 @@ void main() {
|
||||
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
|
||||
});
|
||||
|
||||
test('getChatRoomIdByUserSlug - получает комнату по slug пользователя', () async {
|
||||
const slug = 'user-1';
|
||||
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
|
||||
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
|
||||
test(
|
||||
'getChatRoomIdByUserSlug - получает комнату по slug пользователя',
|
||||
() async {
|
||||
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 chatRepository.getChatRoomIdByUserSlug(slug: slug);
|
||||
final result = await chatRepository.getChatRoomIdByUserSlug(slug: slug);
|
||||
|
||||
expect(result, isA<chat_room.ChatRoomModel>());
|
||||
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
|
||||
});
|
||||
expect(result, isA<chat_room.ChatRoomModel>());
|
||||
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
|
||||
const chatId = 'chat-id-123';
|
||||
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 chatRepository.markMessagesAsRead(chatId, 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 {
|
||||
const planId = 123;
|
||||
|
||||
when(mockChatService.cancelChosenPlan(id: planId))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockChatService.cancelChosenPlan(id: planId),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await chatRepository.cancelChosenPlan(id: planId);
|
||||
|
||||
@@ -252,36 +286,114 @@ void main() {
|
||||
const planId = 123;
|
||||
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 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 {
|
||||
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);
|
||||
|
||||
when(mockChatService.getPaymentLink(id: planId))
|
||||
.thenAnswer((_) async => expectedPayment);
|
||||
when(
|
||||
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.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 {
|
||||
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);
|
||||
|
||||
when(mockChatService.getChosenPlan(id: planId))
|
||||
.thenAnswer((_) async => expectedPlan);
|
||||
when(
|
||||
mockChatService.getChosenPlan(id: planId),
|
||||
).thenAnswer((_) async => expectedPlan);
|
||||
|
||||
final result = await chatRepository.getChosenPlan(id: planId);
|
||||
|
||||
@@ -290,66 +402,140 @@ void main() {
|
||||
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 {
|
||||
const planId = 123;
|
||||
|
||||
when(mockChatService.finishChosenPlan(id: planId))
|
||||
.thenAnswer((_) async => Future.value());
|
||||
when(
|
||||
mockChatService.finishChosenPlan(id: planId),
|
||||
).thenAnswer((_) async => Future.value());
|
||||
|
||||
await chatRepository.finishChosenPlan(id: planId);
|
||||
|
||||
verify(mockChatService.finishChosenPlan(id: planId)).called(1);
|
||||
});
|
||||
|
||||
test('complainAboutConsultation - отправляет жалобу на консультацию', () async {
|
||||
const planId = 123;
|
||||
final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json');
|
||||
final complainModel = PlanComplainModel.fromJson(complainFixture);
|
||||
test(
|
||||
'complainAboutConsultation - отправляет жалобу на консультацию',
|
||||
() async {
|
||||
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 chatRepository.complainAboutConsultation(
|
||||
id: planId,
|
||||
body: complainModel,
|
||||
);
|
||||
await chatRepository.complainAboutConsultation(
|
||||
id: planId,
|
||||
body: complainModel,
|
||||
);
|
||||
|
||||
verify(mockChatService.complainAboutConsultation(id: planId, body: complainModel))
|
||||
.called(1);
|
||||
});
|
||||
verify(
|
||||
mockChatService.complainAboutConsultation(
|
||||
id: planId,
|
||||
body: complainModel,
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('getChatByUserSlug - возвращает ID чата при успешном ответе', () async {
|
||||
const slug = 'user-slug';
|
||||
const chatId = 'chat-id-123';
|
||||
final response = HttpResponse(
|
||||
{'id': chatId},
|
||||
Response(
|
||||
requestOptions: RequestOptions(path: ''),
|
||||
statusCode: 200,
|
||||
),
|
||||
);
|
||||
test(
|
||||
'getChatByUserSlug - возвращает ID чата при успешном ответе',
|
||||
() async {
|
||||
const slug = 'user-slug';
|
||||
const chatId = 'chat-id-123';
|
||||
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 chatRepository.getChatByUserSlug(slug);
|
||||
final result = await chatRepository.getChatByUserSlug(slug);
|
||||
|
||||
expect(result, chatId);
|
||||
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
|
||||
});
|
||||
expect(result, chatId);
|
||||
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
|
||||
const slug = 'user-slug';
|
||||
final response = HttpResponse(
|
||||
{},
|
||||
Response(
|
||||
requestOptions: RequestOptions(path: ''),
|
||||
statusCode: 404,
|
||||
),
|
||||
Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
|
||||
);
|
||||
|
||||
when(mockChatService.getChatByUserSlug(slug: slug))
|
||||
.thenAnswer((_) async => response);
|
||||
when(
|
||||
mockChatService.getChatByUserSlug(slug: slug),
|
||||
).thenAnswer((_) async => response);
|
||||
|
||||
final result = await chatRepository.getChatByUserSlug(slug);
|
||||
|
||||
@@ -357,17 +543,20 @@ void main() {
|
||||
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
|
||||
});
|
||||
|
||||
test('getChatByUserSlug - пробрасывает исключение при ошибке сети', () async {
|
||||
const slug = 'user-slug';
|
||||
test(
|
||||
'getChatByUserSlug - пробрасывает исключение при ошибке сети',
|
||||
() async {
|
||||
const slug = 'user-slug';
|
||||
|
||||
when(mockChatService.getChatByUserSlug(slug: slug))
|
||||
.thenThrow(Exception('Network error'));
|
||||
when(
|
||||
mockChatService.getChatByUserSlug(slug: slug),
|
||||
).thenThrow(Exception('Network error'));
|
||||
|
||||
await expectLater(
|
||||
chatRepository.getChatByUserSlug(slug),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
await expectLater(
|
||||
chatRepository.getChatByUserSlug(slug),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:dio/dio.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_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/order_id_to_conversation_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/services/chat_service.dart';
|
||||
@@ -24,10 +26,16 @@ void main() {
|
||||
});
|
||||
|
||||
test('getChatRooms - возвращает список комнат чата', () async {
|
||||
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
|
||||
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
|
||||
final paginationFixture = FixtureReader.readAsMap(
|
||||
'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);
|
||||
|
||||
@@ -38,10 +46,16 @@ void main() {
|
||||
|
||||
test('getChatRooms - возвращает список комнат с запросом', () async {
|
||||
const query = 'test query';
|
||||
final paginationFixture = FixtureReader.readAsMap('chat/chat_room_pagination_response.json');
|
||||
final expectedPagination = ChatRoomPaginationModel.fromJson(paginationFixture);
|
||||
final paginationFixture = FixtureReader.readAsMap(
|
||||
'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);
|
||||
|
||||
@@ -51,10 +65,14 @@ void main() {
|
||||
|
||||
test('getChatRoom - возвращает комнату чата по UUID', () async {
|
||||
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);
|
||||
|
||||
when(mockChatService.getChatRoom(uuid: uuid)).thenAnswer((_) async => expectedRoom);
|
||||
when(
|
||||
mockChatService.getChatRoom(uuid: uuid),
|
||||
).thenAnswer((_) async => expectedRoom);
|
||||
|
||||
final result = await mockChatService.getChatRoom(uuid: uuid);
|
||||
|
||||
@@ -67,62 +85,114 @@ void main() {
|
||||
const slug = 'user-slug';
|
||||
const data = 'Test message';
|
||||
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);
|
||||
|
||||
// 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.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 {
|
||||
const chatId = 'chat-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);
|
||||
|
||||
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.messagesList.length, 2);
|
||||
verify(mockChatService.getChatMessages(id: chatId, page: page)).called(1);
|
||||
});
|
||||
|
||||
test('getChatRoomIdByUserSlug - возвращает комнату по slug пользователя', () async {
|
||||
const slug = 'user-1';
|
||||
final roomFixture = FixtureReader.readAsMap('chat/chat_room_response.json');
|
||||
final expectedRoom = chat_room.ChatRoomModel.fromJson(roomFixture);
|
||||
test(
|
||||
'getChatRoomIdByUserSlug - возвращает комнату по slug пользователя',
|
||||
() async {
|
||||
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>());
|
||||
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
|
||||
});
|
||||
expect(result, isA<chat_room.ChatRoomModel>());
|
||||
verify(mockChatService.getChatRoomIdByUserSlug(user: slug)).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
|
||||
const chatId = 'chat-id-123';
|
||||
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 {
|
||||
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);
|
||||
|
||||
@@ -133,33 +203,80 @@ void main() {
|
||||
const planId = 123;
|
||||
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);
|
||||
|
||||
verify(mockChatService.choseStartPlanDate(id: planId, date: date)).called(1);
|
||||
verify(
|
||||
mockChatService.choseStartPlanDate(id: planId, date: date),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('getPaymentLink - возвращает ссылку на оплату', () async {
|
||||
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);
|
||||
|
||||
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.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 {
|
||||
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);
|
||||
|
||||
when(mockChatService.getChosenPlan(id: planId)).thenAnswer((_) async => expectedPlan);
|
||||
when(
|
||||
mockChatService.getChosenPlan(id: planId),
|
||||
).thenAnswer((_) async => expectedPlan);
|
||||
|
||||
final result = await mockChatService.getChosenPlan(id: planId);
|
||||
|
||||
@@ -171,31 +288,80 @@ void main() {
|
||||
test('finishChosenPlan - завершает выбранный план', () async {
|
||||
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);
|
||||
|
||||
verify(mockChatService.finishChosenPlan(id: planId)).called(1);
|
||||
});
|
||||
|
||||
test('complainAboutConsultation - отправляет жалобу на консультацию', () async {
|
||||
const planId = 123;
|
||||
final complainFixture = FixtureReader.readAsMap('chat/plan_complain_response.json');
|
||||
final complainModel = PlanComplainModel.fromJson(complainFixture);
|
||||
test(
|
||||
'complainAboutConsultation - отправляет жалобу на консультацию',
|
||||
() async {
|
||||
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 {
|
||||
const slug = 'user-slug';
|
||||
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);
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ExpandableText smoke', () {
|
||||
group('ExpandableText', () {
|
||||
setUpAll(() async {
|
||||
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(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
@@ -22,7 +22,10 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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/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:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('TypeBadges smoke', () {
|
||||
group('TypeBadges', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
});
|
||||
@@ -44,5 +46,56 @@ void main() {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
56
test/features/feed/models/post_feed_mapper_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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/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_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
@@ -13,211 +11,52 @@ import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late FeedBlogPostSettingsModel feedBlogPostsFixture;
|
||||
group('FeedCubit cursor flow', () {
|
||||
late FeedCubit cubit;
|
||||
late MockFeedRepository repository;
|
||||
|
||||
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(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
|
||||
// Создаём тестовые данные
|
||||
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,
|
||||
),
|
||||
);
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - FeedStateLoading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('error state on load failure', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenThrow(Exception('boom'));
|
||||
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
expect(cubit.state, isA<FeedStateError>());
|
||||
});
|
||||
|
||||
test('loadFeedBlogPostList - успешная загрузка постов', () async {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
test('updateFeedBlogPostList updates likes in loaded state', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: null));
|
||||
await cubit.loadFeedBlogPostList();
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null))
|
||||
.thenAnswer((_) async => feedBlogPostsFixture);
|
||||
cubit.updateFeedBlogPostList(post, true);
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
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);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList.first.wasLikedByMe, true);
|
||||
expect(loaded.feedBlogPostList.first.totalLikes, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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/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_state.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'feed_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<FeedRepository>()])
|
||||
void main() {
|
||||
group('FeedCubit', () {
|
||||
FeedCubit? feedCubit;
|
||||
late MockFeedRepository mockFeedRepository;
|
||||
late MockFeedRepository repository;
|
||||
late FeedCubit cubit;
|
||||
|
||||
final testBlogPost = FeedBlogPostModel(
|
||||
final post = FeedBlogPostModel(
|
||||
id: 1,
|
||||
title: 'Test Post',
|
||||
description: 'Test description',
|
||||
image: 'https://example.com/image.jpg',
|
||||
state: 'published',
|
||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||
videoUrl: 'https://example.com/video.mp4',
|
||||
createdAt: DateTime(2024, 1, 15),
|
||||
updatedAt: DateTime(2024, 1, 15),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
state: 'active',
|
||||
image: 'https://example.com/image.png',
|
||||
description: 'text',
|
||||
author: const Author(authorSlug: 'a', authorName: 'Author', authorProfileIcon: 'https://example.com/avatar.png', authorIsMe: false),
|
||||
videoUrl: '',
|
||||
createdAt: DateTime(2024, 1, 1),
|
||||
updatedAt: DateTime(2024, 1, 1),
|
||||
title: 'Title',
|
||||
totalLikes: 1,
|
||||
totalViews: 2,
|
||||
totalComments: 3,
|
||||
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(() {
|
||||
mockFeedRepository = MockFeedRepository();
|
||||
repository = MockFeedRepository();
|
||||
cubit = FeedCubit(feedRepository: repository);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (feedCubit != null && !feedCubit!.isClosed) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
feedCubit!.close();
|
||||
feedCubit = null;
|
||||
}
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('начальное состояние - loading', () {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
expect(feedCubit!.state, const FeedState.loading());
|
||||
test('loadFeedBlogPostList emits loaded from getFeed', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
|
||||
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 {
|
||||
feedCubit = FeedCubit(feedRepository: mockFeedRepository);
|
||||
final expectedSettings = 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),
|
||||
);
|
||||
test('loadNextPage appends posts', () async {
|
||||
when(repository.getFeed(search: null, cursor: null, currentUserId: null)).thenAnswer((_) async => (posts: [post], nextCursor: 'next'));
|
||||
when(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).thenAnswer((_) async => (posts: [post.copyWith(id: 2)], nextCursor: null));
|
||||
|
||||
when(mockFeedRepository.getFeedBlogPosts(query: null)).thenAnswer((_) async => expectedSettings);
|
||||
await cubit.loadFeedBlogPostList();
|
||||
await cubit.loadNextPage();
|
||||
|
||||
await feedCubit!.loadFeedBlogPostList();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
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);
|
||||
final loaded = cubit.state as FeedStateLoaded;
|
||||
expect(loaded.feedBlogPostList, hasLength(2));
|
||||
verify(repository.getFeed(search: null, cursor: 'next', currentUserId: null)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||