Compare commits
49 Commits
push-notif
...
sbp_paymen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
| 5d86b7c71b | |||
|
|
50aefde842 | ||
| 0991ac555e | |||
| 5fbd578463 | |||
|
|
426bd49aa1 | ||
| c6fc20d217 | |||
|
|
17e18dc68b | ||
| b5a889cbb0 | |||
| d483295aab | |||
| 985154c863 | |||
| f1e07b3d86 | |||
| 062079e7f1 | |||
| 24652884ee | |||
| ea9cdb93d4 | |||
| 7c4010b275 | |||
| 4458aaf74f | |||
| f20f871c88 | |||
| f61fb8c6b3 | |||
| 3d14dee4e9 | |||
| 38647b4ac6 | |||
| c836b600c6 | |||
| dfe11a6df2 | |||
| 35cad4e05e |
@@ -8,6 +8,9 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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))),
|
||||
|
||||
@@ -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": "Промокод не найден или истёк"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.23+103
|
||||
version: 1.3.24+106
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -84,6 +84,8 @@ dependencies:
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
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 +111,7 @@ flutter:
|
||||
- assets/images/
|
||||
- assets/audio/
|
||||
- assets/icons/
|
||||
- assets/icons/video_call/
|
||||
- assets/splash/
|
||||
- assets/lottie_animations/
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,20 +200,20 @@ void main() {
|
||||
.thenAnswer((_) async => chosenPlan);
|
||||
await cubit.getChosenPlan(id: 1);
|
||||
|
||||
when(mockChatRepository.getPaymentLink(id: 1))
|
||||
when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank'))
|
||||
.thenAnswer((_) async => paymentModel);
|
||||
|
||||
final result = await cubit.getPaymentLink();
|
||||
|
||||
expect(result, equals('https://payment.url'));
|
||||
verify(mockChatRepository.getPaymentLink(id: 1)).called(1);
|
||||
verify(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank')).called(1);
|
||||
});
|
||||
|
||||
test('getPaymentLink - возвращает null, если план не выбран', () async {
|
||||
final result = await cubit.getPaymentLink();
|
||||
|
||||
expect(result, isNull);
|
||||
verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id')));
|
||||
verifyNever(mockChatRepository.getPaymentLink(id: anyNamed('id'), paymentProvider: anyNamed('paymentProvider')));
|
||||
});
|
||||
|
||||
test('getPaymentLink - обработка ошибки', () async {
|
||||
@@ -254,7 +254,7 @@ void main() {
|
||||
.thenAnswer((_) async => chosenPlan);
|
||||
await cubit.getChosenPlan(id: 1);
|
||||
|
||||
when(mockChatRepository.getPaymentLink(id: 1))
|
||||
when(mockChatRepository.getPaymentLink(id: 1, paymentProvider: 'alfa-bank'))
|
||||
.thenThrow(Exception('Ошибка'));
|
||||
|
||||
final result = await cubit.getPaymentLink();
|
||||
|
||||
131
test/features/payment/consultation_payment_flow_test.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Widget createTestApp() {
|
||||
return const MaterialApp(
|
||||
localizationsDelegates: [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: [Locale('ru')],
|
||||
home: Scaffold(body: SizedBox()),
|
||||
);
|
||||
}
|
||||
|
||||
group('showConsultationPaymentFlow', () {
|
||||
late bool gateCalled;
|
||||
late bool? gateResult;
|
||||
late bool formCalled;
|
||||
late PromoCodeResult? formResult;
|
||||
late bool sbpCalled;
|
||||
late int sbpPlanId;
|
||||
|
||||
Future<bool?> mockGate(BuildContext context) async {
|
||||
gateCalled = true;
|
||||
return gateResult;
|
||||
}
|
||||
|
||||
Future<PromoCodeResult?> mockForm(
|
||||
BuildContext context, {
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
}) async {
|
||||
formCalled = true;
|
||||
return formResult;
|
||||
}
|
||||
|
||||
Future<void> mockSbp(BuildContext context, {required int planId}) async {
|
||||
sbpCalled = true;
|
||||
sbpPlanId = planId;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
gateCalled = false;
|
||||
gateResult = null;
|
||||
formCalled = false;
|
||||
formResult = null;
|
||||
sbpCalled = false;
|
||||
sbpPlanId = 0;
|
||||
});
|
||||
|
||||
testWidgets('Нет → вызывает SbpBanks (не форму)', (tester) async {
|
||||
gateResult = false;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isFalse);
|
||||
expect(sbpCalled, isTrue);
|
||||
expect(sbpPlanId, 42);
|
||||
});
|
||||
|
||||
testWidgets('Да → форма → Пропустить → вызывает SbpBanks', (tester) async {
|
||||
gateResult = true;
|
||||
formResult = const PromoCodeResult();
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isTrue);
|
||||
expect(sbpCalled, isTrue);
|
||||
expect(sbpPlanId, 42);
|
||||
});
|
||||
|
||||
testWidgets('Да → форма отменена (null) → Sbp не вызывается', (tester) async {
|
||||
gateResult = true;
|
||||
formResult = null;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isTrue);
|
||||
expect(sbpCalled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('гейт отменён (null) → ничего не вызывается', (tester) async {
|
||||
gateResult = null;
|
||||
await tester.pumpWidget(createTestApp());
|
||||
|
||||
await showConsultationPaymentFlow(
|
||||
tester.element(find.byType(SizedBox)),
|
||||
planId: 42,
|
||||
showGate: mockGate,
|
||||
showForm: mockForm,
|
||||
showSbp: mockSbp,
|
||||
);
|
||||
|
||||
expect(gateCalled, isTrue);
|
||||
expect(formCalled, isFalse);
|
||||
expect(sbpCalled, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.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/features/payment/repositories/payment_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
import '../../../../fixtures/fixture_reader.dart';
|
||||
import 'consultation_payment_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<PaymentRepository>(),
|
||||
MockSpec<SbpRepository>(),
|
||||
])
|
||||
void main() {
|
||||
group('ConsultationPaymentCubit', () {
|
||||
late ConsultationPaymentCubit cubit;
|
||||
late MockPaymentRepository mockPaymentRepository;
|
||||
late MockSbpRepository mockSbpRepository;
|
||||
|
||||
final installedBank = C2bmemberModel.fromJson(const {
|
||||
'bankName': 'Сбербанк',
|
||||
'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
|
||||
'schema': 'bank100000000111',
|
||||
'package_name': 'ru.sberbankmobile',
|
||||
'icon': 'packages/sbp/assets/icons/sber.png',
|
||||
});
|
||||
|
||||
late PaymentModel alfaPayment;
|
||||
|
||||
setUp(() {
|
||||
mockPaymentRepository = MockPaymentRepository();
|
||||
mockSbpRepository = MockSbpRepository();
|
||||
cubit = ConsultationPaymentCubit(
|
||||
paymentRepository: mockPaymentRepository,
|
||||
sbpRepository: mockSbpRepository,
|
||||
);
|
||||
alfaPayment = PaymentModel.fromJson(
|
||||
FixtureReader.readAsMap('chat/payments/payment_response_alfa_bank.json'),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
test('начальный state - isLoading=false, banks=[], checkoutUrl=null', () {
|
||||
expect(cubit.state, isA<ConsultationPaymentState>());
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.banks, isEmpty);
|
||||
expect(cubit.state.checkoutUrl, isNull);
|
||||
expect(cubit.state.orderId, isNull);
|
||||
expect(cubit.state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'startPayment - успешно получает checkout_url и загружает банки',
|
||||
() async {
|
||||
const planId = 123;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(
|
||||
mockSbpRepository.getInstalledBanks(),
|
||||
).thenAnswer((_) async => [installedBank]);
|
||||
|
||||
await cubit.startPayment(planId: planId);
|
||||
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl);
|
||||
expect(cubit.state.orderId, alfaPayment.orderId);
|
||||
expect(cubit.state.banks, [installedBank]);
|
||||
expect(cubit.state.errorMessage, isNull);
|
||||
verify(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).called(1);
|
||||
verify(mockSbpRepository.getInstalledBanks()).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('startPayment - при ошибке API emit errorMessage', () async {
|
||||
const planId = 123;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenThrow(Exception('Payment start failed'));
|
||||
|
||||
await cubit.startPayment(planId: planId);
|
||||
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.errorMessage, isNotNull);
|
||||
expect(cubit.state.banks, isEmpty);
|
||||
expect(cubit.state.checkoutUrl, isNull);
|
||||
verifyNever(mockSbpRepository.getInstalledBanks());
|
||||
});
|
||||
|
||||
test(
|
||||
'startPayment - при ошибке SBP после успешного payment emit error',
|
||||
() async {
|
||||
const planId = 123;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(
|
||||
mockSbpRepository.getInstalledBanks(),
|
||||
).thenThrow(Exception('SBP plugin error'));
|
||||
|
||||
await cubit.startPayment(planId: planId);
|
||||
|
||||
expect(cubit.state.isLoading, isFalse);
|
||||
expect(cubit.state.errorMessage, isNotNull);
|
||||
expect(cubit.state.checkoutUrl, alfaPayment.paymentUrl);
|
||||
expect(cubit.state.orderId, alfaPayment.orderId);
|
||||
expect(cubit.state.banks, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test('openBank - успешно открывает банк после startPayment', () async {
|
||||
const planId = 123;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(
|
||||
mockSbpRepository.getInstalledBanks(),
|
||||
).thenAnswer((_) async => [installedBank]);
|
||||
when(
|
||||
mockSbpRepository.openBank(
|
||||
url: alfaPayment.paymentUrl,
|
||||
bank: installedBank,
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await cubit.startPayment(planId: planId);
|
||||
final result = await cubit.openBank(bank: installedBank);
|
||||
|
||||
expect(result, isTrue);
|
||||
verify(
|
||||
mockSbpRepository.openBank(
|
||||
url: alfaPayment.paymentUrl,
|
||||
bank: installedBank,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'openBank - без checkoutUrl возвращает false и не вызывает SbpRepository',
|
||||
() async {
|
||||
final result = await cubit.openBank(bank: installedBank);
|
||||
|
||||
expect(result, isFalse);
|
||||
verifyNever(
|
||||
mockSbpRepository.openBank(
|
||||
url: anyNamed('url'),
|
||||
bank: anyNamed('bank'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('openBank - возвращает false когда банк не открылся', () async {
|
||||
const planId = 123;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(
|
||||
mockSbpRepository.getInstalledBanks(),
|
||||
).thenAnswer((_) async => [installedBank]);
|
||||
when(
|
||||
mockSbpRepository.openBank(
|
||||
url: alfaPayment.paymentUrl,
|
||||
bank: installedBank,
|
||||
),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
await cubit.startPayment(planId: planId);
|
||||
final result = await cubit.openBank(bank: installedBank);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('PromoCodeGateBottomSheet', () {
|
||||
Widget createTestWidget({required ValueChanged<bool?> onResult}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(
|
||||
body: PromoCodeGateBottomSheet(onResult: onResult),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('показывает заголовок, BottomSheetHandlerBar и кнопки Да/Нет', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget(onResult: (_) {}));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(BottomSheetHandlerBar), findsOneWidget);
|
||||
expect(find.text('У вас есть промокод?'), findsOneWidget);
|
||||
expect(find.text('Да'), findsOneWidget);
|
||||
expect(find.text('Нет'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap Да вызывает onResult(true)', (tester) async {
|
||||
bool? result;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(onResult: (v) => result = v),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Да'));
|
||||
await tester.pump();
|
||||
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('tap Нет вызывает onResult(false)', (tester) async {
|
||||
bool? result;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(onResult: (v) => result = v),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('Нет'));
|
||||
await tester.pump();
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ItvioPromoCodeModal', () {
|
||||
Widget createTestWidget({
|
||||
Future<PromoValidation> Function(String)? onValidate,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => ItvioPromoCodeModal.show(
|
||||
context,
|
||||
onValidate: onValidate,
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('показывает заголовок Есть промокод? и кнопки Применить/Пропустить', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Есть промокод?'), findsOneWidget);
|
||||
expect(find.text('Применить'), findsOneWidget);
|
||||
expect(find.text('Пропустить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Применить с пустым полем показывает ошибку и не закрывает', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Применить'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Введите промокод'), findsAtLeast(1));
|
||||
expect(find.text('Применить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Применить с вводом вызывает onValidate и показывает ошибку', (tester) async {
|
||||
PromoValidation? captured;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
onValidate: (code) async {
|
||||
captured ??= const PromoValidation.failure('Промокод не найден или истёк');
|
||||
return captured!;
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'TEST123');
|
||||
await tester.tap(find.text('Применить'));
|
||||
await tester.pump();
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(find.text('Промокод не найден или истёк'), findsOneWidget);
|
||||
expect(find.text('Применить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Пропустить закрывает шторку', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Пропустить'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Есть промокод?'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('отсутствует UI applied-состояния (Промокод применён)', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Промокод применён!'), findsNothing);
|
||||
expect(find.text('Продолжить'), findsNothing);
|
||||
expect(find.text('Убрать промокод'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.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/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
import '../../../../fixtures/fixture_reader.dart';
|
||||
import 'sbp_banks_bottom_sheet_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<PaymentRepository>(),
|
||||
MockSpec<SbpRepository>(),
|
||||
])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('SbpBanksBottomSheet', () {
|
||||
late MockPaymentRepository mockPaymentRepository;
|
||||
late MockSbpRepository mockSbpRepository;
|
||||
late ConsultationPaymentCubit cubit;
|
||||
late PaymentModel alfaPayment;
|
||||
|
||||
final installedBank = C2bmemberModel.fromJson(const {
|
||||
'bankName': 'Сбербанк',
|
||||
'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
|
||||
'schema': 'bank100000000111',
|
||||
'package_name': 'ru.sberbankmobile',
|
||||
'icon': '',
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockPaymentRepository = MockPaymentRepository();
|
||||
mockSbpRepository = MockSbpRepository();
|
||||
cubit = ConsultationPaymentCubit(
|
||||
paymentRepository: mockPaymentRepository,
|
||||
sbpRepository: mockSbpRepository,
|
||||
);
|
||||
alfaPayment = PaymentModel.fromJson(
|
||||
FixtureReader.readAsMap('chat/payments/payment_response_alfa_bank.json'),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cubit.close();
|
||||
});
|
||||
|
||||
Widget createTestWidget({required int planId}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(
|
||||
body: SbpBanksBottomSheet(
|
||||
planId: planId,
|
||||
cubit: cubit,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('показывает Loader пока идёт startPayment', (tester) async {
|
||||
const planId = 12;
|
||||
final completer = Completer<PaymentModel>();
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) => completer.future);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
expect(find.text('Сбербанк'), findsNothing);
|
||||
|
||||
completer.complete(alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('вызывает startPaymentProcess с переданным planId', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(mockPaymentRepository.startPaymentProcess(planId: planId)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('после успеха показывает список банков и скрывает Loader', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
expect(find.text('Выберите банк'), findsOneWidget);
|
||||
expect(find.text('Сбербанк'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tap по банку вызывает openBank с checkout_url', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => [installedBank]);
|
||||
when(
|
||||
mockSbpRepository.openBank(
|
||||
url: alfaPayment.paymentUrl,
|
||||
bank: installedBank,
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Сбербанк'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
verify(
|
||||
mockSbpRepository.openBank(
|
||||
url: alfaPayment.paymentUrl,
|
||||
bank: installedBank,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
testWidgets('при ошибке API показывает сообщение об ошибке', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenThrow(Exception('Payment start failed'));
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
expect(find.text('Сбербанк'), findsNothing);
|
||||
expect(
|
||||
find.text('Не удалось начать оплату. Попробуйте позже'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('при пустом списке банков показывает empty-сообщение', (tester) async {
|
||||
const planId = 12;
|
||||
|
||||
when(
|
||||
mockPaymentRepository.startPaymentProcess(planId: planId),
|
||||
).thenAnswer((_) async => alfaPayment);
|
||||
when(mockSbpRepository.getInstalledBanks()).thenAnswer((_) async => []);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: planId));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
expect(find.text('Нет установленных банков'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'payment_repository_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<ChatService>()])
|
||||
void main() {
|
||||
group('PaymentRepository', () {
|
||||
late PaymentRepository paymentRepository;
|
||||
late MockChatService mockChatService;
|
||||
|
||||
setUp(() {
|
||||
mockChatService = MockChatService();
|
||||
paymentRepository = PaymentRepository(chatService: mockChatService);
|
||||
});
|
||||
|
||||
test(
|
||||
'startPaymentProcess - возвращает PaymentModel из ответа alfa-bank',
|
||||
() async {
|
||||
const planId = 123;
|
||||
final paymentFixture = FixtureReader.readAsMap(
|
||||
'chat/payments/payment_response_alfa_bank.json',
|
||||
);
|
||||
final expectedPayment = PaymentModel.fromJson(paymentFixture);
|
||||
final requestBody = Map<String, String>.from(
|
||||
FixtureReader.readAsMap('chat/payments/payment_alfabank_request.json'),
|
||||
);
|
||||
|
||||
when(
|
||||
mockChatService.getPaymentLink(id: planId, body: requestBody),
|
||||
).thenAnswer((_) async => expectedPayment);
|
||||
|
||||
final result = await paymentRepository.startPaymentProcess(planId: planId);
|
||||
|
||||
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(
|
||||
'startPaymentProcess - передаёт payment_provider alfa-bank в тело запроса',
|
||||
() async {
|
||||
const planId = 42;
|
||||
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);
|
||||
|
||||
await paymentRepository.startPaymentProcess(planId: planId);
|
||||
|
||||
verify(
|
||||
mockChatService.getPaymentLink(
|
||||
id: planId,
|
||||
body: {'payment_provider': 'alfa-bank'},
|
||||
),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('startPaymentProcess - пробрасывает ошибку сервиса', () async {
|
||||
const planId = 123;
|
||||
final error = Exception('Payment start failed');
|
||||
|
||||
when(
|
||||
mockChatService.getPaymentLink(
|
||||
id: planId,
|
||||
body: {'payment_provider': 'alfa-bank'},
|
||||
),
|
||||
).thenThrow(error);
|
||||
|
||||
expect(
|
||||
() => paymentRepository.startPaymentProcess(planId: planId),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
118
test/features/payment/repositories/sbp_repository_test.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
import 'sbp_repository_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<SbpService>()])
|
||||
void main() {
|
||||
group('SbpRepository', () {
|
||||
late SbpRepository sbpRepository;
|
||||
late MockSbpService mockSbpService;
|
||||
|
||||
final installedBank = C2bmemberModel.fromJson(const {
|
||||
'bankName': 'Сбербанк',
|
||||
'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
|
||||
'schema': 'bank100000000111',
|
||||
'package_name': 'ru.sberbankmobile',
|
||||
'icon': 'packages/sbp/assets/icons/sber.png',
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockSbpService = MockSbpService();
|
||||
sbpRepository = SbpRepository(sbpService: mockSbpService);
|
||||
});
|
||||
|
||||
group('getInstalledBanks', () {
|
||||
test(
|
||||
'делегирует сервису и возвращает список установленных банков',
|
||||
() async {
|
||||
when(
|
||||
mockSbpService.getInstalledBanks(),
|
||||
).thenAnswer((_) async => [installedBank]);
|
||||
|
||||
final result = await sbpRepository.getInstalledBanks();
|
||||
|
||||
expect(result, [installedBank]);
|
||||
verify(mockSbpService.getInstalledBanks()).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'возвращает пустой список, когда сервис вернул пустой список',
|
||||
() async {
|
||||
when(mockSbpService.getInstalledBanks()).thenAnswer((_) async => []);
|
||||
|
||||
final result = await sbpRepository.getInstalledBanks();
|
||||
|
||||
expect(result, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'бросает exception при ошибке нативного плагина (graceful)',
|
||||
() async {
|
||||
when(
|
||||
mockSbpService.getInstalledBanks(),
|
||||
).thenThrow(MissingPluginException('no sbp handler'));
|
||||
|
||||
expect(() => sbpRepository.getInstalledBanks(), throwsException);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('openBank', () {
|
||||
const checkoutUrl = 'https://qr.nspk.ru/ASDFASDFASDFASDF';
|
||||
|
||||
test(
|
||||
'делегирует сервису и возвращает true при успешном запуске',
|
||||
() async {
|
||||
when(
|
||||
mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final result = await sbpRepository.openBank(
|
||||
url: checkoutUrl,
|
||||
bank: installedBank,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
verify(
|
||||
mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
|
||||
).called(1);
|
||||
},
|
||||
);
|
||||
|
||||
test('возвращает false, когда сервис вернул false', () async {
|
||||
when(
|
||||
mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
|
||||
).thenAnswer((_) async => false);
|
||||
|
||||
final result = await sbpRepository.openBank(
|
||||
url: checkoutUrl,
|
||||
bank: installedBank,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'возвращает false при ошибке нативного плагина (graceful)',
|
||||
() async {
|
||||
when(
|
||||
mockSbpService.openBank(url: checkoutUrl, bank: installedBank),
|
||||
).thenThrow(MissingPluginException('no sbp handler'));
|
||||
|
||||
expect(
|
||||
() => sbpRepository.openBank(url: checkoutUrl, bank: installedBank),
|
||||
throwsException,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
119
test/features/payment/services/sbp_service_test.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sbp/models/c2bmembers_model.dart';
|
||||
|
||||
/// Тесты реализации [SbpServiceImpl] — обёртки над статичным классом [Sbp].
|
||||
///
|
||||
/// Поскольку unit-тесты исполняются на хост-машине (не iOS), срабатывает
|
||||
/// Android-ветка плагина: `getInstalledBanks` возвращает список банков через
|
||||
/// `package_name`, а `openBank` подставляет схему банка в начало checkout_url.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const channel = MethodChannel('sbp');
|
||||
|
||||
/// Банк-фикстура: Сбербанк (присутствует в справочнике c2bmembersData пакета).
|
||||
final sberbank = C2bmemberModel.fromJson(const {
|
||||
'bankName': 'Сбербанк',
|
||||
'logoURL': 'https://qr.nspk.ru/proxyapp/logo/bank100000000111.png',
|
||||
'schema': 'bank100000000111',
|
||||
'package_name': 'ru.sberbankmobile',
|
||||
'icon': 'packages/sbp/assets/icons/sber.png',
|
||||
});
|
||||
|
||||
late SbpService sbpService;
|
||||
|
||||
setUp(() {
|
||||
sbpService = const SbpServiceImpl();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
group('SbpServiceImpl.getInstalledBanks', () {
|
||||
test('возвращает список банков, установленных на устройстве (Android)', () async {
|
||||
// Плагин (Android) возвращает список установленных банков в формате
|
||||
// {name, bitmap, package_name}; обёртка сопоставляет со справочником.
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall call) async {
|
||||
return [
|
||||
{'name': 'Сбербанк', 'bitmap': null, 'package_name': 'ru.sberbankmobile'},
|
||||
];
|
||||
});
|
||||
|
||||
final result = await sbpService.getInstalledBanks();
|
||||
|
||||
expect(result, isNotEmpty);
|
||||
expect(
|
||||
result.any(
|
||||
(bank) =>
|
||||
bank.bankName == 'Сбербанк' && bank.packageName == 'ru.sberbankmobile',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('возвращает пустой список, если нет установленных банков', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall call) async {
|
||||
return <Map<String, dynamic>>[];
|
||||
});
|
||||
|
||||
final result = await sbpService.getInstalledBanks();
|
||||
|
||||
expect(result, isEmpty);
|
||||
});
|
||||
|
||||
test('пробрасывает ошибку плагина при отсутствии нативного обработчика', () async {
|
||||
// Обработчик не зарегистрирован → invokeMethod выбрасывает
|
||||
// MissingPluginException. Сервис должен пробросить её наверх.
|
||||
expect(
|
||||
sbpService.getInstalledBanks(),
|
||||
throwsA(isA<MissingPluginException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SbpServiceImpl.openBank', () {
|
||||
test('вызывает нативный openBank с подстановкой схемы банка в url и возвращает true', () async {
|
||||
const checkoutUrl = 'https://qr.nspk.ru/ASDFASDFASDFASDF';
|
||||
String? receivedUrl;
|
||||
String? receivedPackageName;
|
||||
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall call) async {
|
||||
if (call.method == 'openBank') {
|
||||
final args = call.arguments as Map<dynamic, dynamic>;
|
||||
receivedUrl = args['url'] as String?;
|
||||
receivedPackageName = args['package_name'] as String?;
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
final result = await sbpService.openBank(url: checkoutUrl, bank: sberbank);
|
||||
|
||||
expect(result, isTrue);
|
||||
// Android-ветка заменяет схему "https" на schema банка в начале url.
|
||||
expect(receivedUrl, 'bank100000000111://qr.nspk.ru/ASDFASDFASDFASDF');
|
||||
expect(receivedPackageName, 'ru.sberbankmobile');
|
||||
});
|
||||
|
||||
test('возвращает false при неудачном запуске банковского приложения', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (MethodCall call) async {
|
||||
return false;
|
||||
});
|
||||
|
||||
final result = await sbpService.openBank(
|
||||
url: 'https://qr.nspk.ru/ASDFASDFASDFASDF',
|
||||
bank: sberbank,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('ChosenPlanStateEnum', () {
|
||||
test('has all expected values', () {
|
||||
expect(ChosenPlanStateEnum.values.length, 15);
|
||||
expect(ChosenPlanStateEnum.values.length, 16);
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateInited));
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateStartDateHasBeenSet));
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateInCoordination));
|
||||
@@ -20,6 +20,7 @@ void main() {
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateDone));
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.personalDietSet));
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.stateChargeWasCollected));
|
||||
expect(ChosenPlanStateEnum.values, contains(ChosenPlanStateEnum.statePaymentFailed));
|
||||
});
|
||||
|
||||
test('getShowableChatBadgeStates returns correct states', () {
|
||||
@@ -35,6 +36,7 @@ void main() {
|
||||
expect(states, contains(ChosenPlanStateEnum.stateDone));
|
||||
expect(states, contains(ChosenPlanStateEnum.stateInProgress));
|
||||
expect(states, contains(ChosenPlanStateEnum.personalDietSet));
|
||||
expect(states, isNot(contains(ChosenPlanStateEnum.statePaymentFailed)));
|
||||
});
|
||||
|
||||
test('isStateAfterPaidState returns false for states before paid', () {
|
||||
@@ -43,6 +45,7 @@ void main() {
|
||||
expect(ChosenPlanStateEnum.stateInCoordination.isStateAfterPaidState(), isFalse);
|
||||
expect(ChosenPlanStateEnum.stateStartDateHasBeenSet.isStateAfterPaidState(), isFalse);
|
||||
expect(ChosenPlanStateEnum.statePendingPayment.isStateAfterPaidState(), isFalse);
|
||||
expect(ChosenPlanStateEnum.statePaymentFailed.isStateAfterPaidState(), isFalse);
|
||||
});
|
||||
|
||||
test('isStateAfterPaidState returns true for states after paid', () {
|
||||
@@ -70,6 +73,7 @@ void main() {
|
||||
expect(activeStates, contains(ChosenPlanStateEnum.stateComplained));
|
||||
expect(activeStates, contains(ChosenPlanStateEnum.stateDone));
|
||||
expect(activeStates, contains(ChosenPlanStateEnum.personalDietSet));
|
||||
expect(activeStates, isNot(contains(ChosenPlanStateEnum.statePaymentFailed)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 '../../../fixtures/fixture_reader.dart';
|
||||
|
||||
void main() {
|
||||
group('ChosenPlanModel', () {
|
||||
test('создает ChosenPlanModel с правильными значениями', () {
|
||||
@@ -140,6 +142,60 @@ void main() {
|
||||
expect(json['consultant_id'], 1);
|
||||
expect(json.containsKey('plan'), isTrue);
|
||||
});
|
||||
|
||||
group('fromJson payment polling statuses', () {
|
||||
test('fromJson парсит pending_payment из фикстуры', () {
|
||||
final json = FixtureReader.readAsMap(
|
||||
'chat/chosen_plan/chosen_plan_pending_payment_response.json',
|
||||
);
|
||||
|
||||
final model = ChosenPlanModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.optionType, '1');
|
||||
expect(model.chosenPlanState, ChosenPlanStateEnum.statePendingPayment);
|
||||
expect(model.chatId, 'chat-1');
|
||||
});
|
||||
|
||||
test('fromJson парсит paid из фикстуры', () {
|
||||
final json = FixtureReader.readAsMap(
|
||||
'chat/chosen_plan/chosen_plan_paid_response.json',
|
||||
);
|
||||
|
||||
final model = ChosenPlanModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.optionType, '1');
|
||||
expect(model.chosenPlanState, ChosenPlanStateEnum.statePaid);
|
||||
expect(model.chatId, 'chat-1');
|
||||
});
|
||||
|
||||
test('fromJson парсит payment_failed из фикстуры', () {
|
||||
final json = FixtureReader.readAsMap(
|
||||
'chat/chosen_plan/chosen_plan_payment_failed_response.json',
|
||||
);
|
||||
|
||||
final model = ChosenPlanModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.optionType, '1');
|
||||
expect(model.chosenPlanState, ChosenPlanStateEnum.statePaymentFailed);
|
||||
expect(model.chatId, 'chat-1');
|
||||
});
|
||||
|
||||
test('fromJson парсит canceled_by_the_initiator из фикстуры', () {
|
||||
final json = FixtureReader.readAsMap(
|
||||
'chat/chosen_plan/chosen_plan_canceled_by_the_initiator_response.json',
|
||||
);
|
||||
|
||||
final model = ChosenPlanModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.optionType, '1');
|
||||
expect(model.chosenPlanState, ChosenPlanStateEnum.stateCanceledByTheInitiator);
|
||||
expect(model.chatId, 'chat-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('parseChosenPlan', () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -116,10 +117,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('creates MyRecipeModel with steps and videoUrl', () {
|
||||
final steps = [
|
||||
const Step(image: 'step1.jpg', description: 'Step 1'),
|
||||
const Step(image: 'step2.jpg', description: 'Step 2'),
|
||||
];
|
||||
final steps = [const Step(image: 'step1.jpg', description: 'Step 1'), const Step(image: 'step2.jpg', description: 'Step 2')];
|
||||
|
||||
final recipe = MyRecipeModel(
|
||||
id: 1,
|
||||
@@ -225,4 +223,194 @@ void main() {
|
||||
expect((json['data'] as List).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('MyRecipeModelX.toRecipeModel', () {
|
||||
test('maps fields and uses fixed state, empty ingredients/tags', () {
|
||||
const recipe = MyRecipeModel(
|
||||
id: 7,
|
||||
image: 'image.jpg',
|
||||
state: 'draft',
|
||||
cookingTime: 30,
|
||||
activeCookingTime: 15,
|
||||
description: 'Some description',
|
||||
steps: [
|
||||
Step(image: 's1.jpg', description: 'Step 1'),
|
||||
Step(image: 's2.jpg', description: 'Step 2'),
|
||||
],
|
||||
videoUrl: 'video.mp4',
|
||||
title: 'Recipe',
|
||||
slug: 'recipe',
|
||||
calories: 250.7,
|
||||
likesCount: 1,
|
||||
viewsCount: 2,
|
||||
);
|
||||
|
||||
final result = recipe.toRecipeModel();
|
||||
|
||||
expect(result.title, 'Recipe');
|
||||
expect(result.description, 'Some description');
|
||||
expect(result.image, 'image.jpg');
|
||||
expect(result.totalCookingTime, 30);
|
||||
expect(result.activeCookTime, 15);
|
||||
expect(result.energyPerServing, 250);
|
||||
expect(result.videoUrl, 'video.mp4');
|
||||
expect(result.state, 'active');
|
||||
expect(result.steps, isNotNull);
|
||||
expect(result.steps!.length, 2);
|
||||
expect(result.steps![0]!.image, 's1.jpg');
|
||||
expect(result.steps![0]!.description, 'Step 1');
|
||||
expect(result.ingredients, isEmpty);
|
||||
expect(result.tags, isEmpty);
|
||||
});
|
||||
|
||||
test('handles null steps gracefully', () {
|
||||
const recipe = MyRecipeModel(
|
||||
id: 1,
|
||||
image: 'image.jpg',
|
||||
state: 'draft',
|
||||
cookingTime: 10,
|
||||
activeCookingTime: 5,
|
||||
description: 'desc',
|
||||
steps: null,
|
||||
videoUrl: null,
|
||||
title: 'r',
|
||||
slug: 'r',
|
||||
calories: 100.0,
|
||||
likesCount: 0,
|
||||
viewsCount: 0,
|
||||
);
|
||||
|
||||
final result = recipe.toRecipeModel();
|
||||
|
||||
expect(result.steps, isNull);
|
||||
expect(result.videoUrl, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('MyRecipeModelX.toRecipe', () {
|
||||
test('maps required Recipe fields and applies defaults', () {
|
||||
const recipe = MyRecipeModel(
|
||||
id: 11,
|
||||
image: 'image.jpg',
|
||||
state: 'published',
|
||||
cookingTime: 45,
|
||||
activeCookingTime: 20,
|
||||
description: 'desc',
|
||||
steps: [Step(image: 's.jpg', description: 'step')],
|
||||
videoUrl: 'video.mp4',
|
||||
title: 'Title',
|
||||
slug: 'title',
|
||||
calories: 333.0,
|
||||
likesCount: 7,
|
||||
viewsCount: 8,
|
||||
);
|
||||
|
||||
final result = recipe.toRecipe();
|
||||
|
||||
expect(result.id, 11);
|
||||
expect(result.image, 'image.jpg');
|
||||
expect(result.state, 'published');
|
||||
expect(result.cookingTime, 45);
|
||||
expect(result.activeCookingTime, 20);
|
||||
expect(result.description, 'desc');
|
||||
expect(result.steps, isNotNull);
|
||||
expect(result.steps!.length, 1);
|
||||
expect(result.videoUrl, 'video.mp4');
|
||||
expect(result.title, 'Title');
|
||||
expect(result.slug, 'title');
|
||||
expect(result.calories, 333.0);
|
||||
expect(result.likesCount, 7);
|
||||
expect(result.viewsCount, 8);
|
||||
expect(result.type, 'recipe');
|
||||
expect(result.commentsCount, 0);
|
||||
expect(result.wasLikedByMe, isFalse);
|
||||
expect(result.isFavorite, isFalse);
|
||||
expect(result.tags, isEmpty);
|
||||
expect(result.ingredients, isEmpty);
|
||||
expect(result.author.authorIsMe, isTrue);
|
||||
expect(result.author.authorSlug, '');
|
||||
});
|
||||
});
|
||||
|
||||
group('RecipeToRecipeModelX.toRecipeModel', () {
|
||||
test('maps Recipe to RecipeModel including ingredients and tags', () {
|
||||
const recipe = Recipe(
|
||||
id: 1,
|
||||
image: 'image.jpg',
|
||||
state: 'draft',
|
||||
cookingTime: 30,
|
||||
activeCookingTime: 15,
|
||||
ingredients: [
|
||||
Ingredient(name: 'Salt', unit: 'g', portion: '5', productId: 1),
|
||||
Ingredient(name: 'Sugar', unit: null, portion: '10', productId: 2),
|
||||
],
|
||||
description: 'desc',
|
||||
steps: [Step(image: 's.jpg', description: 'step')],
|
||||
videoUrl: 'video.mp4',
|
||||
title: 'Title',
|
||||
type: 'recipe',
|
||||
slug: 'slug',
|
||||
commentsCount: 1,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
calories: 200.6,
|
||||
likesCount: 3,
|
||||
viewsCount: 4,
|
||||
tags: [
|
||||
Tag(name: 'fast'),
|
||||
Tag(name: 'vegetarian'),
|
||||
],
|
||||
author: Author(authorSlug: 'a', authorName: 'A', authorProfileIcon: '', authorIsMe: true),
|
||||
);
|
||||
|
||||
final result = recipe.toRecipeModel();
|
||||
|
||||
expect(result.title, 'Title');
|
||||
expect(result.description, 'desc');
|
||||
expect(result.image, 'image.jpg');
|
||||
expect(result.totalCookingTime, 30);
|
||||
expect(result.activeCookTime, 15);
|
||||
expect(result.energyPerServing, 200);
|
||||
expect(result.videoUrl, 'video.mp4');
|
||||
expect(result.state, 'active');
|
||||
expect(result.steps, isNotNull);
|
||||
expect(result.steps!.length, 1);
|
||||
expect(result.ingredients!.length, 2);
|
||||
expect(result.ingredients![0]!.name, 'Salt');
|
||||
expect(result.ingredients![0]!.unit, 'g');
|
||||
expect(result.ingredients![1]!.unit, '');
|
||||
expect(result.tags, ['fast', 'vegetarian']);
|
||||
});
|
||||
|
||||
test('returns empty ingredients when source is null', () {
|
||||
const recipe = Recipe(
|
||||
id: 1,
|
||||
image: 'image.jpg',
|
||||
state: 'draft',
|
||||
cookingTime: 1,
|
||||
activeCookingTime: 1,
|
||||
ingredients: null,
|
||||
description: 'desc',
|
||||
steps: null,
|
||||
videoUrl: null,
|
||||
title: 't',
|
||||
type: 'recipe',
|
||||
slug: 's',
|
||||
commentsCount: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
calories: 1.0,
|
||||
likesCount: 0,
|
||||
viewsCount: 0,
|
||||
tags: [],
|
||||
author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
|
||||
);
|
||||
|
||||
final result = recipe.toRecipeModel();
|
||||
|
||||
expect(result.ingredients, isEmpty);
|
||||
expect(result.tags, isEmpty);
|
||||
expect(result.steps, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
169
test/features/registration/registration_error_message_test.dart
Normal file
@@ -0,0 +1,169 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/registration/registration_error_message.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('registrationErrorMessage', () {
|
||||
test('returns the error itself when it is a String', () {
|
||||
expect(registrationErrorMessage('Some failure'), 'Some failure');
|
||||
});
|
||||
|
||||
test('returns toString for arbitrary objects', () {
|
||||
final error = Exception('Boom');
|
||||
expect(registrationErrorMessage(error), error.toString());
|
||||
});
|
||||
|
||||
test('returns DioException.message when type is not 422 and message is set', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
message: 'Network down',
|
||||
type: DioExceptionType.connectionError,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), 'Network down');
|
||||
});
|
||||
|
||||
test('falls back to DioException.toString when message is null', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
type: DioExceptionType.unknown,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), dio.toString());
|
||||
});
|
||||
|
||||
test('parses 422 message and field errors', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/register'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/register'),
|
||||
statusCode: 422,
|
||||
data: <String, dynamic>{
|
||||
'message': 'Validation failed',
|
||||
'errors': <String, dynamic>{
|
||||
'email': <String>['Email is required', 'Email must be valid'],
|
||||
'password': <String>['Password too short'],
|
||||
},
|
||||
},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
final result = registrationErrorMessage(dio);
|
||||
expect(result, contains('Validation failed'));
|
||||
expect(result, contains('Email is required'));
|
||||
expect(result, contains('Email must be valid'));
|
||||
expect(result, contains('Password too short'));
|
||||
});
|
||||
|
||||
test('handles 422 with only message', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: <String, dynamic>{'message': 'Email already taken'},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), 'Email already taken');
|
||||
});
|
||||
|
||||
test('handles 422 with only errors map', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: <String, dynamic>{
|
||||
'errors': <String, dynamic>{
|
||||
'phone': <String>['Phone is required'],
|
||||
},
|
||||
},
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), contains('Phone is required'));
|
||||
});
|
||||
|
||||
test('handles 422 with empty errors collection — returns generic message', () {
|
||||
final data = <String, dynamic>{
|
||||
'errors': <String, dynamic>{
|
||||
'phone': <String>[],
|
||||
'email': <String>[],
|
||||
},
|
||||
};
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: data,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(
|
||||
registrationErrorMessage(dio),
|
||||
'Ошибка регистрации: $data',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles 422 when errors is not a Map', () {
|
||||
final data = <String, dynamic>{
|
||||
'errors': <String>['unexpected payload'],
|
||||
};
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: data,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(
|
||||
registrationErrorMessage(dio),
|
||||
'Ошибка регистрации: $data',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to DioException default for 422 with non-map data', () {
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: 'plain string',
|
||||
),
|
||||
message: 'Server returned 422',
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), 'Server returned 422');
|
||||
});
|
||||
|
||||
test('handles 422 when value in errors map is not a List', () {
|
||||
final data = <String, dynamic>{
|
||||
'message': 'Bad data',
|
||||
'errors': <String, dynamic>{
|
||||
'phone': 'not a list, will be skipped',
|
||||
},
|
||||
};
|
||||
final dio = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
statusCode: 422,
|
||||
data: data,
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
|
||||
expect(registrationErrorMessage(dio), 'Bad data');
|
||||
});
|
||||
});
|
||||
}
|
||||
115
test/features/video_calls/models/video_call_models_test.dart
Normal file
@@ -0,0 +1,115 @@
|
||||
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
|
||||
void main() {
|
||||
group('VideoCallTokenResponse', () {
|
||||
test('создаёт модель с правильными значениями', () {
|
||||
const model = VideoCallTokenResponse(
|
||||
token: 'ey-participant-token',
|
||||
roomName: 'conv-uuid-123',
|
||||
wsUrl: 'wss://livekit.eatvio.test/conv-uuid-123',
|
||||
);
|
||||
|
||||
expect(model.token, 'ey-participant-token');
|
||||
expect(model.roomName, 'conv-uuid-123');
|
||||
expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123');
|
||||
});
|
||||
|
||||
test('fromJson корректно десериализует ответ бэкенда', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
|
||||
final model = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
expect(model.token, 'ey-participant-token');
|
||||
expect(model.roomName, 'conv-uuid-123');
|
||||
expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123');
|
||||
});
|
||||
|
||||
test('toJson сериализует модель в исходный JSON', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final model = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
final result = model.toJson();
|
||||
|
||||
expect(result['token'], 'ey-participant-token');
|
||||
expect(result['roomName'], 'conv-uuid-123');
|
||||
expect(result['wsUrl'], 'wss://livekit.eatvio.test/conv-uuid-123');
|
||||
});
|
||||
|
||||
test('round-trip JSON сохраняет значения', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
|
||||
final model = VideoCallTokenResponse.fromJson(json);
|
||||
final restored = VideoCallTokenResponse.fromJson(model.toJson());
|
||||
|
||||
expect(restored, model);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallAdminTokenResponse', () {
|
||||
test('создаёт модель с правильными значениями', () {
|
||||
const model = VideoCallAdminTokenResponse(
|
||||
adminToken: 'ey-admin-token',
|
||||
roomName: 'conv-uuid-123',
|
||||
apiUrl: 'https://livekit.eatvio.test',
|
||||
);
|
||||
|
||||
expect(model.adminToken, 'ey-admin-token');
|
||||
expect(model.roomName, 'conv-uuid-123');
|
||||
expect(model.apiUrl, 'https://livekit.eatvio.test');
|
||||
});
|
||||
|
||||
test('fromJson корректно десериализует ответ бэкенда', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json');
|
||||
|
||||
final model = VideoCallAdminTokenResponse.fromJson(json);
|
||||
|
||||
expect(model.adminToken, 'ey-admin-token');
|
||||
expect(model.roomName, 'conv-uuid-123');
|
||||
expect(model.apiUrl, 'https://livekit.eatvio.test');
|
||||
});
|
||||
|
||||
test('round-trip JSON сохраняет значения', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json');
|
||||
|
||||
final model = VideoCallAdminTokenResponse.fromJson(json);
|
||||
final restored = VideoCallAdminTokenResponse.fromJson(model.toJson());
|
||||
|
||||
expect(restored, model);
|
||||
});
|
||||
});
|
||||
|
||||
group('VideoCallEndedResponse', () {
|
||||
test('fromJson десериализует sent=true', () {
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||
|
||||
final model = VideoCallEndedResponse.fromJson(json);
|
||||
|
||||
expect(model.sent, isTrue);
|
||||
});
|
||||
|
||||
test('fromJson десериализует sent=false', () {
|
||||
final model = VideoCallEndedResponse.fromJson(const {'sent': false});
|
||||
|
||||
expect(model.sent, isFalse);
|
||||
});
|
||||
|
||||
test('toJson сериализует модель', () {
|
||||
const model = VideoCallEndedResponse(sent: true);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['sent'], isTrue);
|
||||
});
|
||||
|
||||
test('round-trip JSON сохраняет значения', () {
|
||||
const model = VideoCallEndedResponse(sent: true);
|
||||
|
||||
final restored = VideoCallEndedResponse.fromJson(model.toJson());
|
||||
|
||||
expect(restored, model);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
|
||||
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'video_call_repository_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallService>()])
|
||||
void main() {
|
||||
group('VideoCallRepository', () {
|
||||
late VideoCallRepository repository;
|
||||
late MockVideoCallService mockService;
|
||||
|
||||
setUp(() {
|
||||
mockService = MockVideoCallService();
|
||||
repository = VideoCallRepository(videoCallService: mockService);
|
||||
});
|
||||
|
||||
test('getParticipantToken - делегирует вызов сервису и возвращает токен', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.getParticipantToken(conversationUuid);
|
||||
|
||||
expect(result, isA<VideoCallTokenResponse>());
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('No token'));
|
||||
|
||||
expect(
|
||||
() => repository.getParticipantToken(conversationUuid),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
});
|
||||
|
||||
test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json');
|
||||
final expected = VideoCallAdminTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getAdminToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.getAdminToken(conversationUuid);
|
||||
|
||||
expect(result, isA<VideoCallAdminTokenResponse>());
|
||||
expect(result.adminToken, expected.adminToken);
|
||||
expect(result.apiUrl, expected.apiUrl);
|
||||
verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - вызывает сервис с пустым JSON-телом', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||
final expected = VideoCallEndedResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenAnswer((_) async => expected);
|
||||
|
||||
final result = await repository.notifyCallEnded(conversationUuid);
|
||||
|
||||
expect(result, isA<VideoCallEndedResponse>());
|
||||
expect(result.sent, isTrue);
|
||||
|
||||
final captured = verify(
|
||||
mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: captureAnyNamed('body'),
|
||||
),
|
||||
).captured;
|
||||
expect(captured, hasLength(1));
|
||||
expect(captured.single, isA<Map<String, dynamic>>());
|
||||
expect(captured.single as Map<String, dynamic>, isEmpty);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - пробрасывает ошибку сервиса', () async {
|
||||
const conversationUuid = 'conv-uuid-broken';
|
||||
|
||||
when(mockService.notifyCallEnded(
|
||||
conversationUuid: conversationUuid,
|
||||
body: anyNamed('body'),
|
||||
)).thenThrow(Exception('Backend error'));
|
||||
|
||||
expect(
|
||||
() => repository.notifyCallEnded(conversationUuid),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import 'package:eatvio_app/features/video_calls/models/video_call_models.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import '../../../fixtures/fixture_reader.dart';
|
||||
import 'video_call_service_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallService>()])
|
||||
void main() {
|
||||
group('VideoCallService', () {
|
||||
late MockVideoCallService mockService;
|
||||
|
||||
setUp(() {
|
||||
mockService = MockVideoCallService();
|
||||
});
|
||||
|
||||
test('getParticipantToken - возвращает токен и WebSocket URL', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json');
|
||||
final expected = VideoCallTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockService.getParticipantToken(conversationUuid: conversationUuid);
|
||||
|
||||
expect(result, isA<VideoCallTokenResponse>());
|
||||
expect(result.token, expected.token);
|
||||
expect(result.wsUrl, expected.wsUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1);
|
||||
});
|
||||
|
||||
test('getParticipantToken - пробрасывает ошибку', () async {
|
||||
const conversationUuid = 'conv-uuid-error';
|
||||
|
||||
when(mockService.getParticipantToken(conversationUuid: conversationUuid))
|
||||
.thenThrow(Exception('Network error'));
|
||||
|
||||
expect(
|
||||
() => mockService.getParticipantToken(conversationUuid: conversationUuid),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('getAdminToken - возвращает админский токен и API URL', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json');
|
||||
final expected = VideoCallAdminTokenResponse.fromJson(json);
|
||||
|
||||
when(mockService.getAdminToken(conversationUuid: conversationUuid))
|
||||
.thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockService.getAdminToken(conversationUuid: conversationUuid);
|
||||
|
||||
expect(result, isA<VideoCallAdminTokenResponse>());
|
||||
expect(result.adminToken, expected.adminToken);
|
||||
expect(result.apiUrl, expected.apiUrl);
|
||||
expect(result.roomName, expected.roomName);
|
||||
verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - отправляет пустое тело и получает sent=true', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
const body = <String, dynamic>{};
|
||||
final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json');
|
||||
final expected = VideoCallEndedResponse.fromJson(json);
|
||||
|
||||
when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body))
|
||||
.thenAnswer((_) async => expected);
|
||||
|
||||
final result = await mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body);
|
||||
|
||||
expect(result, isA<VideoCallEndedResponse>());
|
||||
expect(result.sent, isTrue);
|
||||
verify(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)).called(1);
|
||||
});
|
||||
|
||||
test('notifyCallEnded - пробрасывает ошибку', () async {
|
||||
const conversationUuid = 'conv-uuid-error';
|
||||
const body = <String, dynamic>{};
|
||||
|
||||
when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body))
|
||||
.thenThrow(Exception('Backend error'));
|
||||
|
||||
expect(
|
||||
() => mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('VideoCallControlColumn renders labels and handles taps', (tester) async {
|
||||
var chatTapCount = 0;
|
||||
var micTapCount = 0;
|
||||
var videoTapCount = 0;
|
||||
var hangUpTapCount = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: VideoCallControlColumn(
|
||||
micEnabled: true,
|
||||
videoEnabled: true,
|
||||
onChat: () => chatTapCount++,
|
||||
onToggleMic: () => micTapCount++,
|
||||
onToggleVideo: () => videoTapCount++,
|
||||
onHangUp: () => hangUpTapCount++,
|
||||
labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'),
|
||||
controlLabelStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Chat'), findsOneWidget);
|
||||
expect(find.text('Sound'), findsOneWidget);
|
||||
expect(find.text('Video'), findsOneWidget);
|
||||
expect(find.text('Leave'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Chat'));
|
||||
await tester.tap(find.text('Sound'));
|
||||
await tester.tap(find.text('Video'));
|
||||
await tester.tap(find.text('Leave'));
|
||||
|
||||
expect(chatTapCount, 1);
|
||||
expect(micTapCount, 1);
|
||||
expect(videoTapCount, 1);
|
||||
expect(hangUpTapCount, 1);
|
||||
});
|
||||
|
||||
testWidgets('VideoCallControlColumn renders muted/off variants', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: VideoCallControlColumn(
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
onChat: () {},
|
||||
onToggleMic: () {},
|
||||
onToggleVideo: () {},
|
||||
onHangUp: () {},
|
||||
labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'),
|
||||
controlLabelStyle: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(VideoCallControlTile), findsNWidgets(4));
|
||||
expect(find.byType(VideoCallPipStyleIconButton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('VideoCallPipStyleIconButton triggers callback', (tester) async {
|
||||
var pressed = 0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: VideoCallPipStyleIconButton(
|
||||
onPressed: () => pressed++,
|
||||
child: const SizedBox(width: 20, height: 20),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byType(VideoCallPipStyleIconButton));
|
||||
expect(pressed, 1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_livekit_track_utils_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<lk.Room>(),
|
||||
MockSpec<lk.LocalParticipant>(),
|
||||
MockSpec<lk.RemoteParticipant>(),
|
||||
MockSpec<lk.TrackPublication>(),
|
||||
MockSpec<lk.VideoTrack>(),
|
||||
])
|
||||
void main() {
|
||||
group('VideoCallLiveKitTrackUtils', () {
|
||||
test('asVideoTrack возвращает null для null-трека', () {
|
||||
expect(VideoCallLiveKitTrackUtils.asVideoTrack(null), isNull);
|
||||
});
|
||||
|
||||
test('visibleVideoFromPublication возвращает null для null-публикации', () {
|
||||
expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(null), isNull);
|
||||
});
|
||||
|
||||
test('visibleVideoFromPublication возвращает null для muted публикации', () {
|
||||
final pub = MockTrackPublication();
|
||||
when(pub.muted).thenReturn(true);
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull);
|
||||
});
|
||||
|
||||
test('visibleVideoFromPublication возвращает трек для не-muted publication', () {
|
||||
final pub = MockTrackPublication();
|
||||
final track = MockVideoTrack();
|
||||
when(pub.muted).thenReturn(false);
|
||||
when(pub.track).thenReturn(track);
|
||||
when(track.muted).thenReturn(false);
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track));
|
||||
});
|
||||
|
||||
test('remoteCameraVideoTrack возвращает null когда remote participants пусты', () {
|
||||
final room = MockRoom();
|
||||
when(room.remoteParticipants).thenReturn(UnmodifiableMapView(<String, lk.RemoteParticipant>{}));
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room), isNull);
|
||||
});
|
||||
|
||||
test('visibleVideoFromPublication возвращает null если track отсутствует', () {
|
||||
final pub = MockTrackPublication<lk.VideoTrack>();
|
||||
when(pub.muted).thenReturn(false);
|
||||
when(pub.track).thenReturn(null);
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull);
|
||||
});
|
||||
|
||||
test('visibleVideoFromPublication возвращает null если video track в mute', () {
|
||||
final pub = MockTrackPublication<lk.VideoTrack>();
|
||||
final track = MockVideoTrack();
|
||||
when(pub.muted).thenReturn(false);
|
||||
when(pub.track).thenReturn(track);
|
||||
when(track.muted).thenReturn(true);
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull);
|
||||
});
|
||||
|
||||
test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () {
|
||||
final room = MockRoom();
|
||||
when(room.localParticipant).thenReturn(null);
|
||||
|
||||
expect(VideoCallLiveKitTrackUtils.localCameraVideoTrack(room), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||