Compare commits

..

96 Commits

Author SHA1 Message Date
163f56ff61 Merge pull request 'feat(vidoe_calls)' (#983) from misha/eatvio_app:issue-975 into main
Reviewed-on: eatvio/eatvio_app#983
2026-07-22 07:25:32 +00:00
misha
5dc1f48fd1 feat(ci/cd): added api key for tf 2026-07-15 17:31:32 +03:00
misha
5ec877ef72 version changed 2026-07-15 15:19:56 +03:00
misha
dbaf639ac6 fix: analyzer fix 2026-07-15 15:18:53 +03:00
misha
2a07ed596b Merge remote-tracking branch 'upstream/main' into issue-975 2026-07-15 15:17:44 +03:00
bd2de07b8f Merge pull request 'sbp-alfa-bank-payment' (#998) from misha/eatvio_app:sbp_payments into main
Reviewed-on: eatvio/eatvio_app#998
2026-07-15 12:06:00 +00:00
misha
eee912f05b fix: pub cache cleaning 2026-07-14 16:57:32 +03:00
misha
e316bf1e0e version changed 2026-07-14 16:12:43 +03:00
misha
de19af14bf fix: fixed analyzer and test 2026-07-14 16:11:56 +03:00
Anatoliy
b064c7016e feat(payment, chat): promo code mocked BottomSheet added 2026-07-14 15:50:44 +03:00
misha
e94681266a feat(sbp_payments): added new layoute for sbp banks and rederict to chosen bank, and added tests for layoute 2026-07-14 13:43:46 +03:00
misha
0ca2d1a1cc feat(sbp_payments): added banks deep link handling to chat screen 2026-07-14 13:09:18 +03:00
misha
be924265a1 feat(sbp_payments): added cubit layer for sbp payments with tests 2026-07-09 18:35:09 +03:00
a0edfe942b feat(payments): phase 3 test added 2026-07-09 16:21:53 +03:00
misha
51397003b5 feat(sbp_payments): added tests for new chosen_plan_state fixtures and new tests for chat repo and service 2026-07-08 15:07:36 +03:00
misha
e10610d93c feat(sbp_payments): added new chosen_plan fixtures with chosen_plan states and fixed chat tests 2026-07-08 14:28:08 +03:00
Anatoliy Poloz
d21cc46558 feat(payments): phase #1 tests and part of implementation 2026-07-08 13:31:55 +03:00
Anatoliy Poloz
2ab26bbda5 feat(chat): reorganize test fixtures 2026-07-08 12:31:06 +03:00
misha
41e556d172 feat(sbp_payments): fixtures and dio excp type added 2026-07-08 12:20:11 +03:00
Anatoliy Poloz
6d920857f6 fix(gradle) 2026-07-08 11:02:58 +03:00
Anatoliy
7183fc5d7d feat(sbp-payin): fixtures and banks assets added 2026-07-08 10:50:12 +03:00
Anatoliy
3852a06ab4 feat(payment): new sbp payment fixture added 2026-07-07 12:22:05 +03:00
misha
219908684f feat(video_calls): fixed naviagtion from push notification to call and added wake lock 2026-07-06 11:18:05 +03:00
misha
9ce7b035ff feat(video_calls): fixed call ended push handling 2026-06-30 18:48:45 +03:00
misha
c8ad600d97 fix: analyzer 2026-06-30 17:17:58 +03:00
misha
f493e07f9f feat(video_calls): call ended push, recall button for message 2026-06-30 16:11:18 +03:00
misha
c0c7e2e658 version changed 2026-06-29 12:15:34 +03:00
misha
f913ae60fa feat(vide_calls): added tokens clearing 2026-06-29 12:12:53 +03:00
misha
2100e5ca02 fix: analyzer fix 2026-06-26 11:30:49 +03:00
misha
10b36117a6 version changed 2026-06-26 08:57:31 +03:00
misha
bde4eea7d1 feat(video_calls): fixed recinnecting logic, added consultant bage 2026-06-26 08:56:57 +03:00
misha
59f42c9b1e fix: test fixed 2026-06-24 09:35:04 +03:00
misha
c5bf5acfe7 feat(video_calls): fixed online indicator and in app notifications for video call 2026-06-24 08:26:16 +03:00
7db9af9894 merge upstream 2026-06-23 08:56:24 +00:00
misha
8ff826fa3e fix: analyzer fix 2026-06-23 09:08:04 +03:00
misha
1e29cda65c Version changed 2026-06-23 08:43:40 +03:00
misha
ed72ed9799 feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes 2026-06-23 08:42:55 +03:00
misha
8920fc280f feat(ci/cd): version changed, anroind action runs only on main branch 2026-06-18 15:02:53 +03:00
misha
7493870a3d feat(video_calls): removed system ui, fixed wrong app initializing 2026-06-18 15:01:21 +03:00
misha
e3c22f8ac8 Revert "feat(video_calls): added system ui for call and navigation to the app"
This reverts commit ea5fced456.
2026-06-18 14:41:56 +03:00
misha
ea5fced456 feat(video_calls): added system ui for call and navigation to the app 2026-06-17 16:47:51 +03:00
misha
e3dd8162fa fix: ci/cd error fixed 2026-06-17 11:33:17 +03:00
misha
90e5c7e58d fix: ci/cd error fixed 2026-06-17 11:17:37 +03:00
misha
65d529b0af feat(video_calls): fixed navigation from voip push 2026-06-17 11:06:22 +03:00
misha
95c1d40c79 feat(video_calls): fixed nullable overlay state handling 2026-06-16 22:57:17 +03:00
misha
895443e747 feat(video_calls): added more logs for sentry 2026-06-16 21:11:45 +03:00
misha
367753d409 Version change 2026-06-16 19:30:01 +03:00
misha
e82866f936 feat(video_calls): voip push handling fix 2026-06-16 19:28:16 +03:00
misha
b13ff74220 fix: added NSLocationAlwaysAndWhenInUseUsageDescription key description 2026-06-16 18:33:21 +03:00
misha
e0d697e8d0 feat(video_calls): fixed ios push UI 2026-06-16 18:30:16 +03:00
misha
705538ac85 fix: added NSLocationWhenInUseUsageDescription key description 2026-06-16 17:56:59 +03:00
misha
03f037518e feat(video_calls): fixed ios and HMS pushes for calls 2026-06-16 15:01:46 +03:00
misha
a07565fca9 feat(ci/cd): removed pods updating 2026-06-15 16:47:17 +03:00
misha
b9cb4eeb84 feat(video_calls): app delegate fix 2026-06-15 16:27:24 +03:00
misha
4d531036fa Version changed 2026-06-15 14:12:37 +03:00
misha
1cafb4b05a feat(video_calls): changed chat button layoute 2026-06-15 14:07:56 +03:00
misha
5daa187045 feat(ci/cd): fixed app delegate for ios 2026-06-11 12:14:42 +03:00
misha
f4f884484d feat(ci/cd): separeted code sign from build_app 2026-06-11 11:55:43 +03:00
misha
4dd32e1a42 feat(ci/cd): fixed team id 2026-06-10 17:21:40 +03:00
misha
3e89bb5ca9 feat(ci/cd): added team id to build_app for ios 2026-06-10 17:14:15 +03:00
misha
885c6f66f9 feat(ci/cd): added gitea token for fastlane url 2026-06-10 16:39:06 +03:00
misha
9dc568ceab feat(ci/cd): removed flutter updating 2026-06-10 14:35:20 +03:00
misha
6fb151ef32 feat(ci/cd): added pods updating 2026-06-10 14:19:21 +03:00
misha
5fa024a7a8 feat(ci/cd): changed repositories for ci/cd 2026-06-10 14:09:54 +03:00
misha
de795c5274 feat(video_calls): added system android pip for calls 2026-06-10 09:32:43 +03:00
misha
27fe4afd81 feat(video_calls): added new incomng push impl, fixed mic and video sharing while connecting to call 2026-06-09 09:55:20 +03:00
misha
8e2b7147d5 Merge branch 'issue-975' of ssh://10.55.5.123:222/misha/eatvio_app into issue-975 2026-06-04 14:39:30 +03:00
misha
f51a68324b feat(video_calls): ios voip pushes handling 2026-06-04 14:38:27 +03:00
8a0d51add9 merge upstream 2026-06-02 11:43:59 +00:00
misha
634d83fd61 feat(tests): fixed tests 2026-05-28 12:11:29 +03:00
misha
9e9f2a9f45 feat(analyzer): fixed analyzer issue 2026-05-28 10:56:07 +03:00
misha
a495f98f30 feat(ci/cd): api key for matching 2026-05-27 16:32:33 +03:00
misha
9f8f9a7aaa feat(ci/cd): location fixes 2026-05-27 13:13:42 +03:00
misha
af1980930b feat(ci/cd): pods 403 error fix 2026-05-27 13:11:09 +03:00
misha
1c262da81a feat(ci/cd): make fstlane to update the certificates and profiles 2026-05-27 13:03:34 +03:00
misha
8a617509de version changed 2026-05-25 08:10:10 +03:00
misha
73cec1a01e feat(vide_calls): gms calls handling 2026-05-25 08:07:20 +03:00
fb99a42115 Merge pull request 'feat(video_calls)' (#980) from misha/eatvio_app:issue-974 into main
Reviewed-on: eatvio/eatvio_app#980
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
Reviewed-by: mso7nueh <mso7nueh@mail.ru>
2026-05-25 02:22:48 +00:00
misha
1acb158f36 feat(vidoe_calls): global cideo call state, connection, loading and error screens for video calls 2026-05-12 07:47:41 +03:00
01282a35cb merge upstream 2026-04-19 17:51:49 +00:00
0991ac555e merge upstream 2026-04-17 14:13:58 +00:00
b5a889cbb0 merge upstream 2026-04-16 17:22:23 +00:00
985154c863 merge upstream 2026-04-01 16:15:21 +00:00
f1e07b3d86 merge upstream 2026-03-12 14:13:03 +00:00
062079e7f1 merge upstream 2026-03-10 04:16:36 +00:00
24652884ee merge upstream 2026-02-24 11:02:11 +00:00
ea9cdb93d4 merge upstream 2026-02-11 17:26:07 +00:00
7c4010b275 merge upstream 2026-02-06 07:09:12 +00:00
4458aaf74f merge upstream 2026-02-04 07:52:24 +00:00
f20f871c88 merge upstream 2026-01-28 15:45:54 +00:00
f61fb8c6b3 merge upstream 2026-01-23 07:40:43 +00:00
3d14dee4e9 merge upstream 2026-01-19 13:07:28 +00:00
38647b4ac6 merge upstream 2026-01-15 22:01:54 +00:00
c836b600c6 merge upstream 2026-01-13 09:25:36 +00:00
dfe11a6df2 merge upstream 2025-12-12 09:55:45 +00:00
35cad4e05e Merge pull request 'sync upsteam' (#3) from eatvio/eatvio_app:main into main
Reviewed-on: apoloz/eatvio_app#3
2025-11-20 17:24:29 +00:00
163 changed files with 12939 additions and 1068 deletions

View File

@@ -15,11 +15,12 @@ jobs:
uses: https://github.com/actions/checkout@v2
- name: Checkout env repository
uses: https://github.com/actions/checkout@v2
uses: https://github.com/actions/checkout@v3
with:
repository: mso7nueh/eatvio_app_env
repository: eatvio/eatvio_app_env
path: env_repo
token: ${{ vars.MSO7NUEH_TOKEN }}
# Здесь может быть токен любого юзера с доступом в gitea
token: ${{ vars.READ_USER_GITEA_TOKEN }}
ref: main
- name: Copy .env file
@@ -57,23 +58,20 @@ jobs:
- name: Flutter build
run: |
flutter upgrade
flutter --version
flutter clean
flutter pub get
flutter pub upgrade
flutter pub run build_runner build --delete-conflicting-outputs
# Configure Flutter for release mode
pod repo update
flutter build ios --config-only --release
cd ios
pod update
pod install
fastlane beta
env:
LANG: en_US.UTF-8
LC_ALL: en_US.UTF-8
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
FASTLANE_USER: eatvio2023@icloud.com
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
@@ -13,6 +17,16 @@
android:usesCleartextTraffic="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<service
android:name=".EatvioFcmMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<service
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
tools:node="remove" />
<service
android:name=".HmsPushService"
android:exported="false">
@@ -33,7 +47,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:supportsPictureInPicture="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -77,6 +91,68 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
Replace it with a bridge that routes to the same accept flow as Answer. -->
<activity
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitIncomingBridgeActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
android:exported="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:turnScreenOn="true"
android:launchMode="singleTop"
android:configChanges="orientation"
android:permission="${applicationId}.PERMISSION_CALL">
<intent-filter>
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity-alias>
<activity
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
tools:node="remove" />
<activity
android:name=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<activity-alias
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
android:exported="false"
android:theme="@style/TranslucentTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:excludeFromRecents="true"
android:noHistory="true"
android:launchMode="singleTop" />
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="microphone"
android:exported="false"
android:stopWithTask="true" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -88,5 +164,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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
package com.eatvioapp.app
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData
class EatvioFcmMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
FlutterFirebaseTokenLiveData.getInstance().postToken(token)
Log.i(TAG, "onNewToken")
}
override fun onMessageReceived(message: RemoteMessage) {
Log.i(TAG, "onMessageReceived: ${message.data}")
if (VideoCallPushBridge.isEndedVideoCall(message.data)) {
VideoCallPushBridge.dismissAllIncomingCalls(this)
return
}
if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) {
return
}
if (MainActivity.isInForeground) {
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
return
}
VideoCallPushBridge.showIncomingCall(this, message.data)
}
companion object {
private const val TAG = "EatvioFCM"
}
}

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

After

Width:  |  Height:  |  Size: 853 B

View File

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

View File

@@ -1,30 +1,517 @@
import AVFAudio
import CallKit
import Flutter
import PushKit
import UIKit
import UserNotifications
import YandexLoginSDK
import flutter_callkit_incoming
import flutter_foreground_task
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
private var voipRegistry: PKPushRegistry?
private var voipDiagnosticsChannel: FlutterMethodChannel?
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let appUrlScheme = "eatvio"
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
}
let mainQueue = DispatchQueue.main
voipRegistry = PKPushRegistry(queue: mainQueue)
voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self
}
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
if let controller = window?.rootViewController as? FlutterViewController {
let channel = FlutterMethodChannel(
name: voipDiagnosticsChannelName,
binaryMessenger: controller.binaryMessenger
)
voipDiagnosticsChannel = channel
channel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
return
}
switch call.method {
case "drainVoipDiagnostics":
result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall":
result(self.drainPendingAcceptedVoipCall())
default:
result(FlutterMethodNotImplemented)
}
}
}
return launched
}
@available(iOS 8.0, *)
func application(
_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([Any]?) -> Void
override func application(
_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
) -> Bool {
YXLSdk.shared.processUserActivity(userActivity)
return true
if YXLSdk.shared.processUserActivity(userActivity) {
return true
}
return super.application(
application,
continue: userActivity,
restorationHandler: restorationHandler
)
}
override public func application(
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
_ app: UIApplication,
open url: URL,
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
if url.scheme == appUrlScheme {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let queryItems = components?.queryItems ?? []
let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let callKitId = queryItems.first { $0.name == "callKitId" }?.value?
.trimmingCharacters(in: .whitespacesAndNewlines)
recordVoipDiagnostic(
"accepted_call_url_opened",
data: [
"callKitId": callKitId ?? "",
"hasCallKitId": callKitId != nil,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
voipDiagnosticsChannel?.invokeMethod(
"acceptedVoipCall",
arguments: [
"id": callKitId,
"handle": conversationUuid,
"extra": [
"conversationUuid": conversationUuid
],
]
)
}
return true
}
return YXLSdk.shared.handleOpen(
url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String)
url,
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
)
}
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
guard
let actionUrl = actionUrl,
let components = URLComponents(string: actionUrl)
else {
return nil
}
let queryItems = components.queryItems ?? []
for key: String in ["uuid", "conversationUuid", "conversation_uuid"] {
if let value = queryItems.first(where: { $0.name == key })?.value,
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return value.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
let segments = components.path.split(separator: "/").map(String.init)
for index in segments.indices where segments[index] == "video-call" {
let nextIndex = segments.index(after: index)
if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty {
return segments[nextIndex]
}
}
return nil
}
private func conversationUuid(fromCall call: Call) -> String? {
let extra = call.data.extra as? [String: Any] ?? [:]
for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] {
if let value = extra[key] as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
}
let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines)
if !handle.isEmpty {
return handle
}
return nil
}
private func storePendingAcceptedCall(_ call: Call) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
return payload
}
private func bringAppToForeground() {
DispatchQueue.main.async {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.makeKeyAndVisible()
}
}
self.window?.makeKeyAndVisible()
}
}
private func openAppForAcceptedCall(_ call: Call) {
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
recordVoipDiagnostic(
"accepted_call_missing_conversation_uuid",
data: ["callKitId": call.data.uuid]
)
return
}
var components = URLComponents()
components.scheme = appUrlScheme
components.host = "video-call"
components.queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
guard let url = components.url else {
recordVoipDiagnostic(
"accepted_call_url_build_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
]
)
return
}
recordVoipDiagnostic(
"accepted_call_open_app_requested",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
"appState": UIApplication.shared.applicationState.rawValue,
]
)
bringAppToForeground()
UIApplication.shared.open(url, options: [:]) { [weak self] success in
self?.recordVoipDiagnostic(
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
}
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
guard let value = value else {
return ""
}
if let string = value as? String {
return string
}
if let number = value as? NSNumber {
return number
}
if let bool = value as? Bool {
return bool
}
if let dictionary = value as? [AnyHashable: Any] {
return dictionary.reduce(into: [String: Any]()) { result, entry in
result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value)
}
}
if let array = value as? [Any] {
return array.map { sanitizedDiagnosticValue($0) }
}
return String(describing: value)
}
private func recordVoipDiagnostic(_ name: String, data: [String: Any]) {
let defaults = UserDefaults.standard
var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
var event: [String: Any] = [
"name": name,
"timestamp": ISO8601DateFormatter().string(from: Date()),
"appState": UIApplication.shared.applicationState.rawValue,
]
event["data"] = sanitizedDiagnosticValue(data)
events.append(event)
if events.count > 20 {
events = Array(events.suffix(20))
}
defaults.set(events, forKey: voipDiagnosticsStorageKey)
voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil)
}
private func drainVoipDiagnostics() -> [[String: Any]] {
let defaults = UserDefaults.standard
let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? []
defaults.removeObject(forKey: voipDiagnosticsStorageKey)
return events
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
for type: PKPushType
) {
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
}
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let dict = payload.dictionaryPayload
let pushMarkers = [
dict["type"] as? String,
dict["notification_type"] as? String,
dict["event"] as? String,
dict["action"] as? String,
dict["push_type"] as? String,
dict["call_type"] as? String,
]
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
if pushMarkers.contains(where: { $0.contains("video_call") })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
recordVoipDiagnostic(
"voip_end_or_cancel_received",
data: [
"markers": pushMarkers,
"payload": dict,
]
)
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
}
let nameCaller = (dict["nameCaller"] as? String)
?? (dict["counterpartName"] as? String)
?? (dict["counterpart_name"] as? String)
?? (dict["caller_name"] as? String)
?? (dict["callerName"] as? String)
?? (dict["caller"] as? String)
?? ""
let conversationUuid = (dict["conversation_uuid"] as? String)
?? (dict["conversationUuid"] as? String)
?? (dict["conversation_id"] as? String)
?? (dict["chat_uuid"] as? String)
?? (dict["uuid"] as? String)
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
?? (dict["handle"] as? String)
?? ""
let callKitIdSource = [
(key: "call_id", value: dict["call_id"] as? String),
(key: "id", value: dict["id"] as? String),
(key: "message_id", value: dict["message_id"] as? String),
]
.first { item in
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return false
}
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
}
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
let isVideo = (dict["isVideo"] as? Bool)
?? (dict["is_video"] as? Bool)
?? true
var extra: [String: Any] = [:]
if !conversationUuid.isEmpty {
extra["conversationUuid"] = conversationUuid
}
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
?? dict["avatarUrl"] as? String
?? dict["avatar_url"] as? String
?? dict["counterpartAvatarUrl"] as? String
?? dict["counterpart_avatar_url"] as? String
{
extra["counterpartAvatarUrl"] = avatar
}
if let messageId = dict["message_id"] as? String {
extra["messageId"] = messageId
}
if let roomName = dict["room_name"] as? String {
extra["roomName"] = roomName
}
if let localName = dict["localParticipantName"] as? String
?? dict["local_participant_name"] as? String
{
extra["localParticipantName"] = localName
}
if let counterpartName = dict["counterpartName"] as? String {
extra["counterpartName"] = counterpartName
} else if let counterpartName = dict["counterpart_name"] as? String {
extra["counterpartName"] = counterpartName
} else if !nameCaller.isEmpty {
extra["counterpartName"] = nameCaller
}
let data = flutter_callkit_incoming.Data(
id: id,
nameCaller: nameCaller,
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
type: isVideo ? 1 : 0
)
data.extra = extra as NSDictionary
NSLog(
"[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)"
)
recordVoipDiagnostic(
"voip_incoming_received",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"nameCaller": nameCaller,
"isVideo": isVideo,
"markers": pushMarkers,
"payload": dict,
]
)
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
recordVoipDiagnostic(
"callkit_plugin_missing",
data: [
"callKitId": id,
"conversationUuid": conversationUuid,
]
)
completion()
return
}
callkitPlugin.showCallkitIncoming(
data,
fromPushKit: true
) {
let activeCalls = callkitPlugin.activeCalls()
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
NSLog(
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
)
self.recordVoipDiagnostic(
"callkit_report_completed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": activeIds,
"activeCallsCount": activeCalls.count,
]
)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
let delayedActiveCalls = callkitPlugin.activeCalls()
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
self.recordVoipDiagnostic(
"callkit_active_calls_delayed",
data: [
"callKitId": id,
"callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid,
"activeCallIds": delayedActiveIds,
"activeCallsCount": delayedActiveCalls.count,
]
)
}
completion()
}
}
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
storePendingAcceptedCall(call)
recordVoipDiagnostic(
"callkit_accept_native",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid(fromCall: call) ?? "",
"data": call.data.toJSON(),
]
)
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
action.fulfill()
}
func onDecline(_ call: Call, _ action: CXEndCallAction) {
action.fulfill()
}
func onEnd(_ call: Call, _ action: CXEndCallAction) {
action.fulfill()
}
func onTimeOut(_ call: Call) {}
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
func providerDidReset() {}
}

View File

@@ -37,14 +37,18 @@
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
<key>NSMicrophoneUsageDescription</key>
<string>Нужен для звука в видеозвонках с консультантом</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>voip</string>
<string>fetch</string>
<string>remote-notification</string>
</array>
@@ -74,6 +78,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>
@@ -87,6 +142,14 @@
<string>yx5399b03636104c6d920b87c1ad329254</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>Eatvio</string>
<key>CFBundleURLSchemes</key>
<array>
<string>eatvio</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
import 'package:eatvio_app/core/locator_service.dart' as di;
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
@@ -47,6 +48,7 @@ class AppInitializer {
throw TimeoutException('Firebase initialization timeout');
},
);
VideoCallFcmHandler.registerBackgroundHandler();
if (sl<AppConfig>().buildType == AppBuildType.prod) {
try {
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));

View File

@@ -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);

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
import 'package:eatvio_app/features/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;
@@ -51,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:get_it/get_it.dart';
@@ -71,6 +74,18 @@ class CubitRegistrations {
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
sl.registerLazySingleton<VideoCallManager>(
() => VideoCallManager(
videoCallRepository: sl(),
profileRepository: sl(),
),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
}
/// Регистрация Cubit'ов как фабрик
@@ -135,6 +150,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()));

View File

@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/my_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';
@@ -74,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()));

View File

@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
import 'package:eatvio_app/features/my_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,7 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
import 'package:eatvio_app/features/registration/services/registration_service.dart';
import 'package:eatvio_app/features/registration_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_callkit_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:get_it/get_it.dart';
/// Регистрация всех Service в системе внедрения зависимостей
@@ -75,6 +78,12 @@ class ServiceRegistrations {
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
);
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
() => VideoCallSystemPipCoordinator(),
);
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
@@ -82,6 +91,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()));

View File

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

View File

@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
class FirebaseApi {
final ErrorReporter _errorReporter;
@@ -87,6 +88,9 @@ class FirebaseApi {
throw TimeoutException('registerPushToken timeout');
},
);
if (Platform.isIOS) {
await _registerIosVoipPushToken();
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
@@ -95,6 +99,88 @@ class FirebaseApi {
}
}
Future<void> unregisterPushTokens() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await unregisterPushToken();
} catch (_) {}
if (Platform.isIOS) {
try {
await _unregisterIosVoipPushToken();
} catch (_) {}
}
}
Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
final token = (await getToken()) ?? '';
await sl<Dio>().post(
'/api/profile/notification/token/unregister',
data: {
'platform': platform,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _registerIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
await sl<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<void> _unregisterIosVoipPushToken() async {
try {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? '';
await sl<Dio>().post(
'/api/profile/notification/token/voip/unregister',
data: {
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('unregisterPushToken voip timeout');
},
);
} catch (e, st) {
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
}
Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false;
try {

View File

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

View File

@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
@@ -211,13 +212,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(
@@ -234,7 +245,13 @@ class OtherRoutes {
return VideoCallScreen(
conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?,
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
counterpartUserId: extra['counterpartUserId'] as int?,
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
localParticipantName: extra['localParticipantName'] as String?,
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
onBackToChat: extra['onBackToChat'] as void Function()?,
onHangUp: extra['onHangUp'] as void Function()?,
);
@@ -329,3 +346,11 @@ class OtherRoutes {
];
}
}
VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) {
if (raw is VideoCallEntryMode) return raw;
if (raw == VideoCallEntryMode.incomingAccepted.name) {
return VideoCallEntryMode.incomingAccepted;
}
return VideoCallEntryMode.outgoing;
}

View File

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

View File

@@ -26,6 +26,13 @@ class WsManager {
PrivateChannel? _privateNotificationChannel;
PrivateChannel? _privateMessagesChannel;
PrivateChannel? _privateDirectChatChannel;
PresenceChannel? _presenceDirectChatChannel;
StreamSubscription? _presenceMemberAddedSubscription;
StreamSubscription? _presenceMemberRemovedSubscription;
StreamSubscription? _presenceSubscriptionSucceededSubscription;
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
bool _presenceChannelEventsBound = false;
StreamSubscription? _lifecycleSubscription;
StreamSubscription? _eventSubscription;
@@ -175,6 +182,107 @@ class WsManager {
);
}
Future<void> initPresenceDirectChatChannel(String chatId) async {
final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
);
}
void addPresenceDirectChatListener({
required int counterpartUserId,
required void Function(bool isOnline) onCounterpartOnlineChanged,
}) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceDirectChatListeners.add(
_PresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
),
);
_bindPresenceChannelEventsIfNeeded();
channel.subscribe();
_emitCounterpartPresence(counterpartUserId);
}
void removePresenceDirectChatListener(
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
}
}
void _bindPresenceChannelEventsIfNeeded() {
if (_presenceChannelEventsBound) return;
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription =
channel.whenSubscriptionSucceeded().listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
});
}
void _emitCounterpartPresence(int counterpartUserId) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);
}
}
}
int? _parsePresenceMemberUserId(String? data) {
if (data == null || data.isEmpty) return null;
try {
final decoded = jsonDecode(data);
if (decoded is! Map) return null;
final rawId = decoded['user_id'] ?? decoded['id'];
if (rawId == null) return null;
return int.tryParse(rawId.toString());
} catch (_) {
return null;
}
}
void _subscribe() {
_privateMessagesChannel?.subscribe();
_privateNotificationChannel?.subscribe();
@@ -193,6 +301,19 @@ class WsManager {
_privateDirectChatChannel?.unsubscribe();
}
void unsubscribePresenceDirectChatChannel() {
_presenceSubscriptionSucceededSubscription?.cancel();
_presenceMemberAddedSubscription?.cancel();
_presenceMemberRemovedSubscription?.cancel();
_presenceSubscriptionSucceededSubscription = null;
_presenceMemberAddedSubscription = null;
_presenceMemberRemovedSubscription = null;
_presenceDirectChatListeners.clear();
_presenceChannelEventsBound = false;
_presenceDirectChatChannel?.unsubscribe();
_presenceDirectChatChannel = null;
}
Future<void> clear() async {
await client?.disconnect();
await _lifecycleSubscription?.cancel();
@@ -204,6 +325,7 @@ class WsManager {
client = null;
userId = null;
_privateDirectChatChannel?.unsubscribe();
unsubscribePresenceDirectChatChannel();
_privateMessagesChannel?.unsubscribe();
_privateNotificationChannel?.unsubscribe();
_privateDirectChatChannel = null;
@@ -211,3 +333,13 @@ class WsManager {
_privateNotificationChannel = null;
}
}
class _PresenceDirectChatListener {
const _PresenceDirectChatListener({
required this.counterpartUserId,
required this.onCounterpartOnlineChanged,
});
final int counterpartUserId;
final void Function(bool isOnline) onCounterpartOnlineChanged;
}

View File

@@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
@JsonKey(name: 'room_id') required String? roomId,
@JsonKey(name: 'type') String? type,
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
}) = _ChatMessageModel;
@@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
}
enum MessageState { loading, sent, error }
enum ChatMessageType {
@JsonValue('text')
text,
@JsonValue('system')
system,
@JsonValue('attachment')
attachment,
@JsonValue('image')
image,
@JsonValue('plan_order')
planOrder,
@JsonValue('video_call_started')
videoCallStarted,
@JsonValue('video_call_ended')
videoCallEnded,
@JsonValue('unknown')
unknown;
}

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
@freezed
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);
}

View File

@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
late int lastPage;
bool isLoading = false;
Timer? _timer;
String? _activeChatUuid;
void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid();
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
@@ -91,17 +94,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 +139,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 +147,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
messagesSubscription?.cancel();
wsManager.unsubscribeDirectChatChannel();
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
return null;
}
}
@@ -170,8 +196,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
final client = wsManager.client;
if (client == null) return;
_activeChatUuid = uuid;
await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId);
messagesSubscription = wsManager.directChatStream?.listen((event) {
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
@@ -197,6 +225,118 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
});
}
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: currentState.chatRoom.users,
currentUserId: wsManager.userId,
);
await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) {
unawaited(_handleCounterpartOnlineChanged(
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
));
};
wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
Future<void> _handleCounterpartOnlineChanged({
required _ChatCounterpartField counterpartField,
required bool isOnline,
}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = counterpartField == _ChatCounterpartField.receiver
? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
return;
}
if (!isOnline) {
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
return;
}
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: true,
lastSeenText: '',
);
}
Future<void> _refreshCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
}) async {
final chatUuid = _activeChatUuid;
if (chatUuid == null) return;
try {
final chatRoom = await chatRepository.getChatRoom(chatUuid);
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: wsManager.userId,
);
final field = counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender;
if (field != counterpartField) return;
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: counterpart.isOnline,
lastSeenText: counterpart.lastSeenText,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _emitCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
required bool isOnline,
required String lastSeenText,
}) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final users = currentState.chatRoom.users;
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
? users.copyWith(
receiver: users.receiver.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
)
: users.copyWith(
sender: users.sender.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
);
emit(
currentState.copyWith(
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
),
);
}
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
try {
final messageUuid = uuid.v4();
@@ -297,7 +437,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);
@@ -337,6 +477,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override
Future<void> close() async {
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel();
_timer?.cancel();
messagesSubscription?.cancel();
@@ -435,3 +579,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}
}
}
enum _ChatCounterpartField { sender, receiver }

View File

@@ -13,13 +13,19 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
@@ -36,10 +42,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();
@@ -55,6 +62,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();
}
@@ -64,26 +76,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);
}
}
@@ -117,7 +144,7 @@ class _ChatScreenState extends State<ChatScreen> {
},
),
),
body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)),
body: DataErrorWidget(onReload: _initChatRoom),
);
} else {
return Scaffold(
@@ -141,6 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
final chosenPlan = state.chatRoom.chosenPlan;
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [
@@ -217,19 +245,31 @@ class _ChatScreenState extends State<ChatScreen> {
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)),
AnimatedCrossFade(
firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary),
firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut,
secondChild: const SizedBox.shrink(),
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 150),
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.chatRoom.users.receiver.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
),
AnimatedCrossFade(
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut,
secondChild: ChatUserOnlineStatusLine(
isOnline: chatCounterpart.isOnline,
lastSeenText: chatCounterpart.lastSeenText,
style: CustomTextStyles.bodySmallMulishPrimary,
showOnlineDot: false,
),
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 150),
),
],
),
),
],
),
@@ -237,18 +277,7 @@ 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': widget.uuid,
'counterpartName': state.chatRoom.users.receiver.name,
'localParticipantName': state.chatRoom.users.sender.name,
},
);
},
),
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
),
],
),
@@ -283,7 +312,7 @@ class _ChatScreenState extends State<ChatScreen> {
index + 1 == state.chatMessages.length) {
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
final message = state.chatMessages[index];
final isPlanOrderMessage = message.type == 'plan_order';
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
PacketOptions? messageOption;
if (isPlanOrderMessage && chosenPlan != null) {
@@ -324,18 +353,13 @@ class _ChatScreenState extends State<ChatScreen> {
});
}
},
child: ChatBubbleWidget(
isRead: message.isRead,
sendTime: message.updatedAt,
text: message.data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: message.mediaData ?? [],
messageState: message.messageState,
messageUuid: message.uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
messageType: message.type,
isConsultant: amIConsultant,
urlsMediaInfo: message.urlsMediaInfo,
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
),
],
@@ -343,7 +367,7 @@ class _ChatScreenState extends State<ChatScreen> {
}
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
final message = state.chatMessages[index];
final isPlanOrderMessage = message.type == 'plan_order';
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
PacketOptions? messageOption;
if (isPlanOrderMessage && chosenPlan != null) {
@@ -380,18 +404,13 @@ class _ChatScreenState extends State<ChatScreen> {
});
}
},
child: ChatBubbleWidget(
isRead: message.isRead,
sendTime: message.updatedAt,
text: message.data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: message.mediaData ?? [],
messageState: message.messageState,
messageUuid: message.uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
messageType: message.type,
isConsultant: amIConsultant,
urlsMediaInfo: message.urlsMediaInfo,
child: _buildMessageBubble(
context: context,
state: state,
chatCounterpart: chatCounterpart,
isCurrentUserAuthor: isCurrentUserAuthor,
message: message,
amIConsultant: amIConsultant,
),
),
],
@@ -758,6 +777,52 @@ class _ChatScreenState extends State<ChatScreen> {
),
);
}
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': conversationUuid,
'counterpartName': chatCounterpart.name,
'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
'counterpartUserId': chatCounterpart.id,
'counterpartIsOnline': chatCounterpart.isOnline,
'counterpartLastSeenText': chatCounterpart.lastSeenText,
'localParticipantName': state.chatRoom.users.sender.name,
},
);
}
Widget _buildMessageBubble({
required BuildContext context,
required ChatRoomStateLoaded state,
required ChatUserModel chatCounterpart,
required bool isCurrentUserAuthor,
required ChatMessageModel message,
required bool? amIConsultant,
}) {
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
return VideoCallChatMessageWidget(
text: message.data.trim(),
sendTime: message.updatedAt,
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
);
}
return ChatBubbleWidget(
isRead: message.isRead,
sendTime: message.updatedAt,
text: message.data.trim(),
isCurrentUser: isCurrentUserAuthor,
mediaData: message.mediaData ?? [],
messageState: message.messageState,
messageUuid: message.uuid ?? '',
userSlug: state.chatRoom.users.receiver.slug,
messageType: message.type,
isConsultant: amIConsultant,
urlsMediaInfo: message.urlsMediaInfo,
);
}
}
class _ChatHeaderVideoCallButton extends StatelessWidget {
@@ -779,9 +844,7 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
child: SizedBox(
width: 40,
height: 40,
child: Center(
child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
),
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
),
),
);

View File

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

View File

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

View File

@@ -2,15 +2,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/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)),

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_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);
}

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
import 'package:eatvio_app/features/chat/models/chat_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}')

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
@@ -15,6 +16,7 @@ import 'package:eatvio_app/features/consultant_rating/presentation/consultant_ra
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -165,6 +167,13 @@ class MenuScreen extends StatelessWidget {
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
if (context.mounted) {

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
@JsonValue('personal_diet_setted')
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,
];
}
}

View File

@@ -15,17 +15,20 @@ class ExpandableBouble extends StatelessWidget {
child: MainBackgroundBouble(
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: initiallyExpanded,
expandedCrossAxisAlignment: CrossAxisAlignment.start,
expandedAlignment: Alignment.bottomLeft,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
title,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
child: Material(
color: Colors.transparent,
child: ExpansionTile(
initiallyExpanded: initiallyExpanded,
expandedCrossAxisAlignment: CrossAxisAlignment.start,
expandedAlignment: Alignment.bottomLeft,
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(
title,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
children: children,
),
children: children,
),
),
),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
class VideoCallManagerSnapshot {
const VideoCallManagerSnapshot({
required this.conversationUuid,
this.counterpartName,
this.localParticipantName,
required this.micEnabled,
required this.videoEnabled,
this.connectError,
required this.connectPhase,
required this.linkIssue,
this.counterpartPresentInLobby = false,
});
final String conversationUuid;
final String? counterpartName;
final String? localParticipantName;
final bool micEnabled;
final bool videoEnabled;
final String? connectError;
final VideoCallConnectPhase connectPhase;
final VideoCallLinkIssue linkIssue;
final bool counterpartPresentInLobby;
static const initial = VideoCallManagerSnapshot(
conversationUuid: '',
micEnabled: false,
videoEnabled: false,
connectPhase: VideoCallConnectPhase.idle,
linkIssue: VideoCallLinkIssue.none,
);
VideoCallManagerSnapshot copyWith({
String? conversationUuid,
String? counterpartName,
String? localParticipantName,
bool? micEnabled,
bool? videoEnabled,
String? connectError,
VideoCallConnectPhase? connectPhase,
VideoCallLinkIssue? linkIssue,
bool? counterpartPresentInLobby,
bool clearConnectError = false,
}) {
return VideoCallManagerSnapshot(
conversationUuid: conversationUuid ?? this.conversationUuid,
counterpartName: counterpartName ?? this.counterpartName,
localParticipantName: localParticipantName ?? this.localParticipantName,
micEnabled: micEnabled ?? this.micEnabled,
videoEnabled: videoEnabled ?? this.videoEnabled,
connectError: clearConnectError ? null : (connectError ?? this.connectError),
connectPhase: connectPhase ?? this.connectPhase,
linkIssue: linkIssue ?? this.linkIssue,
counterpartPresentInLobby:
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
);
}
}

View File

@@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
}
@freezed
abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
const factory VideoCallAcceptResponse({
required bool sent,
@JsonKey(name: 'devices_notified') required int devicesNotified,
}) = _VideoCallAcceptResponse;
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallAcceptResponseFromJson(json);
}

View File

@@ -0,0 +1,599 @@
import 'dart:async';
import 'package:eatvio_app/core/cubit/base_cubit.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:permission_handler/permission_handler.dart';
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
VideoCallSessionCubit({
required VideoCallManager videoCallManager,
VideoCallSystemPipCoordinator? systemPipCoordinator,
VideoCallScreenWakeLockCoordinator? screenWakeLockCoordinator,
}) : _videoCallManager = videoCallManager,
_systemPipCoordinator =
systemPipCoordinator ?? VideoCallSystemPipCoordinator(),
_screenWakeLock =
screenWakeLockCoordinator ?? VideoCallScreenWakeLockCoordinator(),
super(const VideoCallSessionState()) {
_videoCallManager.addListener(_onManagerSnapshot);
_syncFromManager(_videoCallManager.snapshot);
}
final VideoCallManager _videoCallManager;
final VideoCallSystemPipCoordinator _systemPipCoordinator;
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
bool _callScreenRouteVisible = false;
bool _autoEnterInFlight = false;
bool _counterpartPresentWhenLobbyReady = false;
bool _sawEmptyLobbyWhenConnected = false;
bool _lobbyReadyTracked = false;
String? _lobbyReadyTrackedConversationUuid;
Future<void>? _autoEnterFuture;
Future<void>? _outgoingAutoEnterFuture;
lk.Room? get liveKitRoom => _videoCallManager.room;
bool get isCallScreenRouteVisible => _callScreenRouteVisible;
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
return snapshot.conversationUuid.isEmpty ||
snapshot.connectPhase == VideoCallConnectPhase.idle;
}
VideoCallSessionState _sessionStateFromSnapshot(
VideoCallManagerSnapshot snapshot,
) {
final hideStaleConnectError =
snapshot.connectPhase == VideoCallConnectPhase.connected &&
!snapshot.linkIssue.needsManualReconnect;
final resetCallStage =
_isCallSessionInactive(snapshot) ||
snapshot.connectPhase == VideoCallConnectPhase.connecting;
return state.copyWith(
conversationUuid: snapshot.conversationUuid,
counterpartName: snapshot.counterpartName,
localParticipantName: snapshot.localParticipantName,
micEnabled: snapshot.micEnabled,
videoEnabled: snapshot.videoEnabled,
connectError: hideStaleConnectError ? null : snapshot.connectError,
connectPhase: snapshot.connectPhase,
linkIssue: snapshot.linkIssue,
callStageEntered: resetCallStage ? false : state.callStageEntered,
uiMode:
_isCallSessionInactive(snapshot)
? VideoCallUiMode.hidden
: state.uiMode,
);
}
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return;
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) {
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
return;
}
_resetLobbyReadyTracking();
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
return;
}
_trackLobbyReadyCounterpartPresence(snapshot);
final next = _sessionStateFromSnapshot(snapshot);
emit(next);
_scheduleAutoEnterIfNeeded(next);
_scheduleOutgoingAutoEnterIfNeeded(next, snapshot);
}
void _syncFromManager(VideoCallManagerSnapshot snapshot) {
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) return;
_resetLobbyReadyTracking();
emit(const VideoCallSessionState());
return;
}
_trackLobbyReadyCounterpartPresence(snapshot);
emit(_sessionStateFromSnapshot(snapshot));
}
void _resetLobbyReadyTracking() {
_lobbyReadyTrackedConversationUuid = null;
_counterpartPresentWhenLobbyReady = false;
_sawEmptyLobbyWhenConnected = false;
_lobbyReadyTracked = false;
}
void _trackLobbyReadyCounterpartPresence(VideoCallManagerSnapshot snapshot) {
final uuid = snapshot.conversationUuid;
if (uuid.isEmpty) {
_resetLobbyReadyTracking();
return;
}
if (snapshot.connectPhase != VideoCallConnectPhase.connected) return;
if (!_lobbyReadyTracked || _lobbyReadyTrackedConversationUuid != uuid) {
_lobbyReadyTracked = true;
_lobbyReadyTrackedConversationUuid = uuid;
_counterpartPresentWhenLobbyReady = snapshot.counterpartPresentInLobby;
_sawEmptyLobbyWhenConnected = !snapshot.counterpartPresentInLobby;
return;
}
if (_sawEmptyLobbyWhenConnected && snapshot.counterpartPresentInLobby) {
_counterpartPresentWhenLobbyReady = false;
_sawEmptyLobbyWhenConnected = false;
}
}
Future<void> _notifyIncomingCallAcceptedIfNeeded(String conversationUuid) {
return _videoCallManager.notifyIncomingCallAccepted(conversationUuid);
}
/// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification /
/// foreground task (без повторного disconnect).
Future<void> onCallEndedExternally() async {
await _onCallEndedExternally();
}
Future<void> _onCallEndedExternally() async {
if (isClosed) return;
await _systemPipCoordinator.disarm();
await _restoreSystemUiAfterCall();
emit(const VideoCallSessionState());
}
Future<void> startCall({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
}) async {
await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: state.micEnabled,
videoEnabled: state.videoEnabled,
);
}
Future<void> onCallScreenVisible({
required String conversationUuid,
String? counterpartName,
String? localParticipantName,
bool? counterpartIsConsultant,
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
}) async {
_callScreenRouteVisible = true;
await _screenWakeLock.acquire();
final effectiveEntryMode =
state.entryMode == VideoCallEntryMode.incomingAccepted ||
entryMode == VideoCallEntryMode.incomingAccepted
? VideoCallEntryMode.incomingAccepted
: entryMode;
final isIncomingAccepted =
effectiveEntryMode == VideoCallEntryMode.incomingAccepted;
final isResumingActiveCall =
state.conversationUuid == conversationUuid &&
state.callStageEntered &&
_videoCallManager.room != null;
final isSameCallConnecting =
state.conversationUuid == conversationUuid &&
!state.callStageEntered &&
state.connectPhase != VideoCallConnectPhase.idle;
if (isIncomingAccepted && !isResumingActiveCall) {
unawaited(_notifyIncomingCallAcceptedIfNeeded(conversationUuid));
}
if (!isResumingActiveCall && !isSameCallConnecting) {
_resetLobbyReadyTracking();
await _systemPipCoordinator.disarm();
}
if (isSameCallConnecting) {
emit(
state.copyWith(
counterpartName: counterpartName ?? state.counterpartName,
localParticipantName:
localParticipantName ?? state.localParticipantName,
counterpartIsConsultant:
counterpartIsConsultant ?? state.counterpartIsConsultant,
entryMode: effectiveEntryMode,
),
);
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
}
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
await _ensureSystemPipArmedIfInCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
return;
}
if (isResumingActiveCall) {
await _videoCallManager.onCallScreenVisible(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: state.micEnabled,
videoEnabled: state.videoEnabled,
);
if (counterpartIsConsultant != null) {
emit(state.copyWith(counterpartIsConsultant: counterpartIsConsultant));
}
} else {
emit(
state.copyWith(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
counterpartIsConsultant: counterpartIsConsultant ?? false,
micEnabled: false,
videoEnabled: false,
callStageEntered: false,
connectError: null,
linkIssue: VideoCallLinkIssue.none,
connectPhase: VideoCallConnectPhase.connecting,
entryMode: effectiveEntryMode,
),
);
await _videoCallManager.startCall(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
localParticipantName: localParticipantName,
micEnabled: false,
videoEnabled: false,
);
emit(state.copyWith(entryMode: effectiveEntryMode));
if (isIncomingAccepted) {
await _tryAutoEnterCallStage();
}
}
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
await _ensureSystemPipArmedIfInCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
}
Future<void> _ensureSystemPipArmedIfInCall() async {
if (!_videoCallManager.isActiveCall) {
await _systemPipCoordinator.disarm();
return;
}
await _systemPipCoordinator.armAutoPipOnLeave();
}
void _scheduleAutoEnterIfNeeded(VideoCallSessionState sessionState) {
if (sessionState.entryMode != VideoCallEntryMode.incomingAccepted) return;
if (sessionState.callStageEntered) return;
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
unawaited(_tryAutoEnterCallStage());
}
void _scheduleOutgoingAutoEnterIfNeeded(
VideoCallSessionState sessionState,
VideoCallManagerSnapshot snapshot,
) {
if (!_callScreenRouteVisible) return;
if (sessionState.entryMode != VideoCallEntryMode.outgoing) return;
if (sessionState.callStageEntered) return;
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
if (!snapshot.counterpartPresentInLobby) {
unawaited(_videoCallManager.ensureLobbyRoomJoined());
return;
}
unawaited(_tryAutoEnterWhenCounterpartJoins());
}
Future<void> _tryAutoEnterWhenCounterpartJoins() {
final pending = _outgoingAutoEnterFuture;
if (pending != null) return pending;
final run = _runOutgoingAutoEnterCallStage();
_outgoingAutoEnterFuture = run;
return run.whenComplete(() {
if (identical(_outgoingAutoEnterFuture, run)) {
_outgoingAutoEnterFuture = null;
}
});
}
Future<void> _runOutgoingAutoEnterCallStage() async {
if (isClosed || _autoEnterInFlight) return;
if (!_callScreenRouteVisible) return;
if (state.entryMode != VideoCallEntryMode.outgoing) return;
if (state.callStageEntered) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
_autoEnterInFlight = true;
try {
await enterCallStage();
} finally {
_autoEnterInFlight = false;
}
}
Future<void> _tryAutoEnterCallStage() {
final pending = _autoEnterFuture;
if (pending != null) return pending;
final run = _runAutoEnterCallStage();
_autoEnterFuture = run;
return run.whenComplete(() {
if (identical(_autoEnterFuture, run)) {
_autoEnterFuture = null;
}
});
}
Future<void> _runAutoEnterCallStage() async {
if (isClosed || _autoEnterInFlight) return;
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
if (state.callStageEntered) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
_autoEnterInFlight = true;
try {
await _requestMicBeforeIncomingEnter();
await enterCallStage();
} finally {
_autoEnterInFlight = false;
}
}
/// After the loading/connect phase, request mic before joining the room.
Future<void> _requestMicBeforeIncomingEnter() async {
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
final granted = await _requestMicPermissionForCall();
if (!granted || isClosed) return;
await _videoCallManager.toggleMic(state.isMicActiveForUi);
if (isClosed) return;
emit(state.copyWith(micEnabled: true));
}
@visibleForTesting
Future<bool> requestMicPermissionForCall() => _requestMicPermissionForCall();
Future<bool> _requestMicPermissionForCall() async {
try {
final status = await Permission.microphone.request();
return status.isGranted;
} catch (_) {
return false;
}
}
void onCallScreenDisposed() {
_callScreenRouteVisible = false;
if (state.uiMode != VideoCallUiMode.fullscreen) {
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
// PiP overlay after the call route is removed.
emit(state.copyWith());
}
return;
}
if (state.connectPhase == VideoCallConnectPhase.connected &&
!state.linkIssue.needsManualReconnect) {
if (state.callStageEntered) {
unawaited(minimizeToPip());
} else {
unawaited(endCall());
}
} else if (state.connectPhase == VideoCallConnectPhase.connecting) {
unawaited(endCall());
} else if (state.linkIssue.needsManualReconnect) {
unawaited(endCall());
}
}
bool get _canMinimizeToPip =>
state.connectPhase == VideoCallConnectPhase.connected &&
!state.linkIssue.needsManualReconnect &&
state.callStageEntered;
Future<void> _applyPipUiState() async {
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
await _restoreSystemUiAfterCall();
await _ensureSystemPipArmedIfInCall();
}
/// Called when Android enters system PiP (e.g. Home gesture).
Future<void> syncPipUiStateFromSystem() async {
if (!_canMinimizeToPip) return;
if (state.uiMode == VideoCallUiMode.pip) return;
// Call screen already renders the in-call mini preview; enabling the global
// overlay here would duplicate PiP after returning from system PiP.
if (_callScreenRouteVisible) return;
await _applyPipUiState();
}
/// Re-applies immersive UI after Android system PiP while the call screen
/// route is still on the stack.
Future<void> onSystemPipExitedWithCallScreenVisible() async {
if (!_callScreenRouteVisible) return;
if (state.uiMode != VideoCallUiMode.fullscreen) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
}
Future<void> minimizeToPip() async {
if (!_canMinimizeToPip) return;
await _applyPipUiState();
}
Future<void> restoreFullscreenFromPip() async {
if (state.uiMode != VideoCallUiMode.pip) return;
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
unawaited(_ensureSystemPipArmedIfInCall());
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
}
Future<void> leaveCallRoute() async {
if (state.connectPhase == VideoCallConnectPhase.connected &&
!state.linkIssue.needsManualReconnect) {
if (state.callStageEntered) {
_callScreenRouteVisible = false;
await minimizeToPip();
} else {
await endCall();
}
} else {
await endCall();
}
}
void updateCounterpartIsConsultant(bool isConsultant) {
if (isClosed || state.counterpartIsConsultant == isConsultant) return;
emit(state.copyWith(counterpartIsConsultant: isConsultant));
}
Future<void> enterCallStage() async {
if (isClosed) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
if (state.callStageEntered) return;
if (state.entryMode == VideoCallEntryMode.outgoing &&
_counterpartPresentWhenLobbyReady) {
unawaited(_notifyIncomingCallAcceptedIfNeeded(state.conversationUuid));
}
final entered = await _videoCallManager.onEnterCallStage();
if (!entered || isClosed) return;
emit(
state.copyWith(
callStageEntered: true,
splitParticipantView: false,
mainStageShowsLocalVideo: false,
),
);
await _ensureSystemPipArmedIfInCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_videoCallManager.scheduleRecordingNoticeOnce();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_videoCallManager.scheduleRecordingNoticeOnce();
});
});
}
Future<void> retryAfterConnectionLoss() async {
final entryMode = state.entryMode;
final wasInCallStage = state.callStageEntered;
await _videoCallManager.retryAfterConnectionLoss();
if (isClosed) return;
emit(state.copyWith(callStageEntered: false, entryMode: entryMode));
if (wasInCallStage &&
state.connectPhase == VideoCallConnectPhase.connected &&
!state.linkIssue.needsManualReconnect) {
await enterCallStage();
}
}
Future<void> endCall() async {
await _screenWakeLock.release();
await _systemPipCoordinator.disarm();
await _videoCallManager.endCall();
}
void expandLocal() {
emit(
state.copyWith(
splitParticipantView: false,
mainStageShowsLocalVideo: true,
),
);
}
void expandRemote() {
emit(
state.copyWith(
splitParticipantView: false,
mainStageShowsLocalVideo: false,
),
);
}
void shrinkToSplit() {
emit(state.copyWith(splitParticipantView: true));
}
void toggleStageSwap() {
emit(
state.copyWith(mainStageShowsLocalVideo: !state.mainStageShowsLocalVideo),
);
}
Future<void> toggleMic() async {
await _videoCallManager.toggleMic(state.isMicActiveForUi);
}
Future<void> toggleVideo() async {
await _videoCallManager.toggleVideo(state.videoEnabled);
}
Future<void> switchCameraFacing() async {
await _videoCallManager.switchCameraFacing(state.useFrontCamera);
emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
}
Future<void> _applyVideoCallSystemUi() async {
try {
await SystemChrome.setPreferredOrientations(const [
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
} on PlatformException catch (_) {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
}
Future<void> _restoreSystemUiAfterCall() async {
await _screenWakeLock.release();
try {
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
} on PlatformException catch (_) {}
}
String resolvedLocalDisplayName() =>
_videoCallManager.resolvedLocalDisplayName();
String resolvedRemoteDisplayName([String fallback = '']) =>
_videoCallManager.resolvedRemoteDisplayName(fallback);
@override
Future<void> close() async {
_videoCallManager.removeListener(_onManagerSnapshot);
await endCall();
return super.close();
}
}

View File

@@ -0,0 +1,53 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'video_call_session_state.freezed.dart';
enum VideoCallUiMode {
hidden,
fullscreen,
pip,
}
enum VideoCallConnectPhase { idle, connecting, connected }
enum VideoCallLinkIssue { none, reconnecting, lost }
extension VideoCallLinkIssueX on VideoCallLinkIssue {
/// True when the user must manually retry (lost link or LiveKit auto-reconnect).
bool get needsManualReconnect =>
this == VideoCallLinkIssue.lost || this == VideoCallLinkIssue.reconnecting;
}
/// How the user reached [VideoCallScreen].
enum VideoCallEntryMode {
/// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins.
outgoing,
/// Accepted incoming CallKit / VoIP / notification — loader then auto-enter.
incomingAccepted,
}
@freezed
abstract class VideoCallSessionState with _$VideoCallSessionState {
const VideoCallSessionState._();
const factory VideoCallSessionState({
@Default('') String conversationUuid,
String? counterpartName,
@Default(false) bool counterpartIsConsultant,
String? localParticipantName,
@Default(false) bool micEnabled,
@Default(false) bool videoEnabled,
@Default(false) bool splitParticipantView,
@Default(false) bool mainStageShowsLocalVideo,
@Default(true) bool useFrontCamera,
String? connectError,
@Default(VideoCallUiMode.hidden) VideoCallUiMode uiMode,
@Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase,
@Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue,
@Default(false) bool callStageEntered,
@Default(VideoCallEntryMode.outgoing) VideoCallEntryMode entryMode,
}) = _VideoCallSessionState;
bool get isMicActiveForUi => micEnabled;
}

View File

@@ -2,24 +2,35 @@ 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/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart';
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_room_tracks_listenable.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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.counterpartAvatarUrl,
this.counterpartUserId,
this.counterpartIsOnline,
this.counterpartLastSeenText,
this.counterpartIsConsultant,
this.localParticipantName,
this.entryMode = VideoCallEntryMode.outgoing,
this.onBackToChat,
this.onHangUp,
});
@@ -29,7 +40,13 @@ class VideoCallScreen extends StatefulWidget {
final String conversationUuid;
final String? counterpartName;
final String? counterpartAvatarUrl;
final int? counterpartUserId;
final bool? counterpartIsOnline;
final String? counterpartLastSeenText;
final bool? counterpartIsConsultant;
final String? localParticipantName;
final VideoCallEntryMode entryMode;
final VoidCallback? onBackToChat;
final VoidCallback? onHangUp;
@@ -38,437 +55,151 @@ class VideoCallScreen extends StatefulWidget {
}
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;
static const SystemUiOverlayStyle _videoCallOverlayStyle =
SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false,
);
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();
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
WsManager get _wsManager => sl<WsManager>();
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,
);
}
bool _counterpartIsOnline = false;
String _counterpartLastSeenText = '';
void Function(bool isOnline)? _presenceOnlineListener;
@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;
}),
);
});
_counterpartIsOnline = widget.counterpartIsOnline ?? false;
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
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,
unawaited(_initCounterpartMetadata());
unawaited(
_cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid,
counterpartName: widget.counterpartName,
localParticipantName: widget.localParticipantName,
counterpartIsConsultant: widget.counterpartIsConsultant,
entryMode: widget.entryMode,
),
);
} 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;
Future<void> _initCounterpartMetadata() async {
var isOnline = widget.counterpartIsOnline ?? false;
var lastSeenText = widget.counterpartLastSeenText ?? '';
var counterpartUserId = widget.counterpartUserId;
var counterpartIsConsultant = widget.counterpartIsConsultant;
final shouldFetchChatRoom =
counterpartUserId == null ||
widget.counterpartIsOnline == null ||
widget.counterpartLastSeenText == null ||
counterpartIsConsultant == null;
if (shouldFetchChatRoom) {
try {
final chatRoom = await sl<ChatRepository>().getChatRoom(
widget.conversationUuid,
);
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: _wsManager.userId,
);
isOnline = counterpart.isOnline;
lastSeenText = counterpart.lastSeenText;
counterpartUserId = counterpart.id;
if (counterpartIsConsultant == null) {
try {
final profile = await sl<ProfileRepository>().getProfileBySlug(
counterpart.slug,
);
counterpartIsConsultant = profile.roles.contains('consultant');
} catch (_) {
counterpartIsConsultant = false;
}
}
} catch (_) {}
}
_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),
if (!mounted) return;
setState(() {
_counterpartIsOnline = isOnline;
_counterpartLastSeenText = lastSeenText;
});
if (counterpartIsConsultant != null) {
_cubit.updateCounterpartIsConsultant(counterpartIsConsultant);
}
if (counterpartUserId == null) return;
_presenceOnlineListener = (isOnline) {
if (!mounted) return;
if (isOnline) {
setState(() {
_counterpartIsOnline = true;
_counterpartLastSeenText = '';
});
return;
}
unawaited(_refreshCounterpartOnlineStatus());
};
await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid);
_wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
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;
Future<void> _refreshCounterpartOnlineStatus() async {
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,
]);
final chatRoom = await sl<ChatRepository>().getChatRoom(
widget.conversationUuid,
);
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: _wsManager.userId,
);
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();
}
setState(() {
_counterpartIsOnline = counterpart.isOnline;
_counterpartLastSeenText = counterpart.lastSeenText;
});
} 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());
if (_presenceOnlineListener != null) {
_wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
final poor = _poorConnectionEntry;
_poorConnectionEntry = null;
if (poor != null && poor.mounted) {
poor.remove();
}
unawaited(_cleanupLiveKitAndNotifyBackend());
unawaited(_restoreSystemUiAfterCall());
_cubit.onCallScreenDisposed();
super.dispose();
}
Future<void> _handleBackToChat() async {
widget.onBackToChat?.call();
await _cleanupLiveKitAndNotifyBackend();
await _cubit.leaveCallRoute();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _handleHangUp() async {
widget.onHangUp?.call();
await _cleanupLiveKitAndNotifyBackend();
await _cubit.endCall();
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);
@@ -476,56 +207,202 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
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);
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,
return PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) return;
await _cubit.leaveCallRoute();
if (!context.mounted) return;
if (context.canPop()) context.pop();
},
child: BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: _cubit,
buildWhen: (a, b) =>
a.connectPhase != b.connectPhase ||
a.connectError != b.connectError ||
a.linkIssue != b.linkIssue ||
a.counterpartName != b.counterpartName ||
a.counterpartIsConsultant != b.counterpartIsConsultant ||
a.localParticipantName != b.localParticipantName ||
a.callStageEntered != b.callStageEntered ||
a.micEnabled != b.micEnabled ||
a.videoEnabled != b.videoEnabled ||
a.splitParticipantView != b.splitParticipantView ||
a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
a.useFrontCamera != b.useFrontCamera ||
a.entryMode != b.entryMode,
builder: (context, sessionState) {
final room = _cubit.liveKitRoom;
final showLost =
sessionState.linkIssue.needsManualReconnect ||
(sessionState.connectPhase == VideoCallConnectPhase.idle &&
sessionState.connectError != null);
if (showLost) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: VideoCallConnectionLostScreen(
detailMessage: sessionState.connectError,
onRetry: () => unawaited(_cubit.retryAfterConnectionLoss()),
onLeave: () => unawaited(_handleHangUp()),
),
),
);
},
),
}
final showEntranceLobby =
sessionState.entryMode == VideoCallEntryMode.outgoing &&
!sessionState.callStageEntered &&
sessionState.linkIssue == VideoCallLinkIssue.none &&
sessionState.connectPhase != VideoCallConnectPhase.idle;
final showAutoEnterLoading =
sessionState.entryMode == VideoCallEntryMode.incomingAccepted &&
!sessionState.callStageEntered &&
sessionState.linkIssue == VideoCallLinkIssue.none &&
sessionState.connectPhase != VideoCallConnectPhase.idle;
if (showAutoEnterLoading) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: VideoCallLoadingScreen(
onClose: () => unawaited(_handleHangUp()),
),
),
);
}
if (showEntranceLobby) {
final remoteName = _resolveRemoteDisplayName(sessionState);
final canEnterCall =
sessionState.connectPhase == VideoCallConnectPhase.connected;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: VideoCallConnectingEntranceScreen(
counterpartDisplayName: remoteName,
counterpartAvatarUrl: widget.counterpartAvatarUrl,
counterpartIsOnline: _counterpartIsOnline,
counterpartLastSeenText: _counterpartLastSeenText,
micEnabled: sessionState.isMicActiveForUi,
videoEnabled: sessionState.videoEnabled,
canEnterCall: canEnterCall,
onEnterCall: _cubit.enterCallStage,
labels: (
chat: s.video_call_chat,
sound: s.video_call_sound,
video: s.video_call_video,
leave: s.video_call_leave,
),
controlLabelStyle: controlLabelStyle,
onToggleMic: () => unawaited(_cubit.toggleMic()),
onToggleVideo: () => unawaited(_cubit.toggleVideo()),
onHangUp: () => unawaited(_handleHangUp()),
bottomInset: bottomInset,
),
),
);
}
if (room == null &&
sessionState.linkIssue == VideoCallLinkIssue.none &&
sessionState.connectError == null) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: VideoCallLoadingScreen(
onClose: () => unawaited(_handleHangUp()),
),
),
);
}
final activeRoom = room;
if (activeRoom == null) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: VideoCallLoadingScreen(
onClose: () => unawaited(_handleHangUp()),
),
),
);
}
return AnnotatedRegion<SystemUiOverlayStyle>(
value: _videoCallOverlayStyle,
child: Scaffold(
backgroundColor: appTheme.whiteA700,
body: ListenableBuilder(
listenable: videoCallRoomTracksListenable(activeRoom),
builder: (context, _) {
return VideoCallStageLayout(
room: activeRoom,
connectError: sessionState.connectError,
localDisplayName: _resolveLocalDisplayName(sessionState),
remoteDisplayName: _resolveRemoteDisplayName(sessionState),
remoteIsConsultant: sessionState.counterpartIsConsultant,
micEnabled: sessionState.micEnabled,
videoEnabled: sessionState.videoEnabled,
splitParticipantView: sessionState.splitParticipantView,
mainStageShowsLocalVideo:
sessionState.mainStageShowsLocalVideo,
useFrontCamera: sessionState.useFrontCamera,
onExpandLocal: () => _cubit.expandLocal(),
onExpandRemote: () => _cubit.expandRemote(),
onShrinkFromFullscreen: () => _cubit.shrinkToSplit(),
onToggleStageSwap: () => _cubit.toggleStageSwap(),
onChat: () => unawaited(_handleBackToChat()),
onToggleMic: () => unawaited(_cubit.toggleMic()),
onToggleVideo: () => unawaited(_cubit.toggleVideo()),
onHangUp: () => unawaited(_handleHangUp()),
onSwitchCamera: () =>
unawaited(_cubit.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,
);
},
),
),
);
},
),
);
}
String _resolveLocalDisplayName(VideoCallSessionState sessionState) {
final resolved = _cubit.resolvedLocalDisplayName().trim();
if (resolved.isNotEmpty) return resolved;
return sessionState.localParticipantName?.trim() ??
widget.localParticipantName?.trim() ??
'';
}
String _resolveRemoteDisplayName(VideoCallSessionState sessionState) {
final resolved = _cubit.resolvedRemoteDisplayName().trim();
if (resolved.isNotEmpty) return resolved;
return sessionState.counterpartName?.trim() ??
widget.counterpartName?.trim() ??
'';
}
}

View File

@@ -0,0 +1,56 @@
import 'dart:io';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS.
class VideoCallAppShell extends StatelessWidget {
const VideoCallAppShell({super.key, required this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
final shell = Stack(
clipBehavior: Clip.none,
children: [
child ?? const SizedBox.shrink(),
Overlay(
key: VideoCallBannerOverlay.overlayKey,
initialEntries: const <OverlayEntry>[],
),
const VideoCallGlobalPipLayer(),
const VideoCallSystemPipListener(),
],
);
if (!Platform.isAndroid) return shell;
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: sl<VideoCallSessionCubit>(),
buildWhen: (previous, current) =>
previous.callStageEntered != current.callStageEntered ||
previous.connectPhase != current.connectPhase ||
previous.linkIssue != current.linkIssue,
builder: (context, state) {
final allowSystemPipShell = sl<VideoCallManager>().isActiveCall;
if (!allowSystemPipShell) return shell;
return PiPSwitcher(
childWhenDisabled: shell,
childWhenEnabled: const VideoCallSystemPipContent(),
);
},
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
/// Dedicated overlay layer above the app navigator for in-call banners.
abstract final class VideoCallBannerOverlay {
static final GlobalKey<OverlayState> overlayKey = GlobalKey<OverlayState>(
debugLabel: 'videoCallBannerOverlay',
);
static OverlayState? get overlayState => overlayKey.currentState;
}

View File

@@ -0,0 +1,352 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class VideoCallConnectingEntranceScreen extends StatelessWidget {
const VideoCallConnectingEntranceScreen({
super.key,
required this.counterpartDisplayName,
this.counterpartAvatarUrl,
required this.counterpartIsOnline,
required this.counterpartLastSeenText,
required this.micEnabled,
required this.videoEnabled,
required this.canEnterCall,
required this.onEnterCall,
required this.labels,
required this.controlLabelStyle,
required this.onToggleMic,
required this.onToggleVideo,
required this.onHangUp,
required this.bottomInset,
});
final String counterpartDisplayName;
final String? counterpartAvatarUrl;
final bool counterpartIsOnline;
final String counterpartLastSeenText;
final bool micEnabled;
final bool videoEnabled;
final bool canEnterCall;
final VoidCallback onEnterCall;
final VideoCallControlLabels labels;
final TextStyle controlLabelStyle;
final VoidCallback onToggleMic;
final VoidCallback onToggleVideo;
final VoidCallback onHangUp;
final double bottomInset;
static const double _bottomPanelHeight = 132;
static const double _topContentSpacing = 60;
String _firstChar(String s) {
if (s.isEmpty) return '';
return String.fromCharCode(s.runes.first);
}
String _initials(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return '?';
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length >= 2) {
final a = _firstChar(parts[0]);
final b = _firstChar(parts[1]);
if (a.isNotEmpty && b.isNotEmpty) {
return (a + b).toUpperCase();
}
}
final single = parts[0];
final runes = single.runes.toList();
if (runes.length >= 2) {
return String.fromCharCodes([runes[0], runes[1]]).toUpperCase();
}
final c = _firstChar(single);
return c.isNotEmpty ? c.toUpperCase() : '?';
}
Widget _initialsPlaceholder(String initials) {
return ColoredBox(
color: appTheme.backgroundAvatar,
child: Center(
child: Text(
initials,
style: TextStyles.textFS24FW700.copyWith(
color: appTheme.blueA400,
fontWeight: FontWeight.w600,
fontSize: 28,
),
),
),
);
}
Widget _avatar(BuildContext context, String name, String? urlRaw) {
final url = urlRaw?.trim();
final initials = _initials(name);
final border = Border.all(color: appTheme.blueA400, width: 1);
if (url != null && url.isNotEmpty) {
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(shape: BoxShape.circle, border: border),
child: ClipOval(
child: Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
_initialsPlaceholder(initials),
),
),
);
}
return Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: appTheme.backgroundAvatar,
border: border,
),
alignment: Alignment.center,
child: Text(
initials,
style: TextStyles.textFS24FW700.copyWith(
color: appTheme.blueA400,
fontWeight: FontWeight.w600,
fontSize: 28,
),
),
);
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
return ColoredBox(
color: appTheme.whiteA700,
child: Stack(
fit: StackFit.expand,
children: [
SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: _bottomPanelHeight + bottomInset,
),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: _topContentSpacing),
_avatar(context, counterpartDisplayName, counterpartAvatarUrl),
const SizedBox(height: 16),
_buildStatusLine(context, s),
SizedBox(height: canEnterCall ? 20 : 16),
Text(
s.video_call_screen_connecting_title,
textAlign: TextAlign.center,
style: CustomTextStyles.videoCallFlowPlaceholderTitle,
),
const SizedBox(height: 16),
Text(
s.video_call_recording_notice,
textAlign: TextAlign.center,
style: CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle,
),
const SizedBox(height: 8),
Text(
counterpartDisplayName,
textAlign: TextAlign.center,
style:
CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle
.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 24),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _bottomPanelHeight + bottomInset,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.52),
],
),
),
child: Padding(
padding: EdgeInsets.fromLTRB(12, 16, 12, 12 + bottomInset),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Center(
child: IgnorePointer(
ignoring: !canEnterCall,
child: Opacity(
opacity: canEnterCall ? 1 : 0.45,
child: VideoCallControlTile(
label: s.video_call_enter,
labelStyle: controlLabelStyle,
onTap: onEnterCall,
face: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 48,
height: 48,
child: ColoredBox(
color: appTheme.greenA700,
child: Center(
child: SvgPicture.asset(
'assets/icons/video_call/vc_control_enter.svg',
width: 28,
height: 28,
fit: BoxFit.contain,
),
),
),
),
),
),
),
),
),
),
Expanded(
child: Center(
child: 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,
),
),
),
),
),
),
),
),
Expanded(
child: Center(
child: 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,
),
),
),
),
),
),
),
),
Expanded(
child: Center(
child: 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),
),
),
),
),
),
),
],
),
),
),
),
],
),
);
}
Widget _buildStatusLine(BuildContext context, S s) {
final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith(
height: 14 / 12,
);
final onlineStatus = ChatUserOnlineStatusLine(
isOnline: counterpartIsOnline,
lastSeenText: counterpartLastSeenText,
style: statusStyle,
);
if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) {
return Center(child: onlineStatus);
}
if (!canEnterCall) {
return Text(
s.video_call_screen_connecting_subtitle,
textAlign: TextAlign.center,
style: statusStyle,
);
}
return const SizedBox.shrink();
}
}

View File

@@ -0,0 +1,305 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
const double _kVideoCallFlowBottomBarHeight = 132;
Widget _videoCallFlowBottomBarGradient({
required double bottomInset,
required Widget child,
}) {
return Positioned(
left: 0,
right: 0,
bottom: 0,
height: _kVideoCallFlowBottomBarHeight + bottomInset,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.52),
],
),
),
child: Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomInset),
child: child,
),
),
);
}
ButtonStyle _videoCallFlowBarButtonStyle({required Color backgroundColor}) {
return FilledButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: appTheme.whiteA700,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
minimumSize: const Size(0, 48),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
);
}
class _VideoCallFlowScaffold extends StatelessWidget {
const _VideoCallFlowScaffold({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: appTheme.whiteA700,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
const SizedBox(height: 40),
Image.asset(
'assets/icons/video_call/eatvio_c.png',
width: 88,
height: 88,
fit: BoxFit.contain,
),
const SizedBox(height: 32),
Expanded(child: Center(child: child)),
],
),
),
),
);
}
}
class VideoCallLoadingScreen extends StatelessWidget {
const VideoCallLoadingScreen({super.key, required this.onClose});
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
return ColoredBox(
color: appTheme.whiteA700,
child: Stack(
fit: StackFit.expand,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(
bottom: _kVideoCallFlowBottomBarHeight + bottomInset,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 40,
height: 40,
child: CircularProgressIndicator(
strokeWidth: 3,
color: appTheme.blueA400,
),
),
const SizedBox(height: 28),
Text(
s.video_call_screen_loading_title,
textAlign: TextAlign.center,
style: CustomTextStyles.videoCallFlowPlaceholderTitle,
),
],
),
),
),
),
_videoCallFlowBottomBarGradient(
bottomInset: bottomInset,
child: Align(
alignment: Alignment.bottomCenter,
child: FilledButton.icon(
onPressed: onClose,
style: _videoCallFlowBarButtonStyle(
backgroundColor: appTheme.videoCallControlOffBackground,
),
icon: Icon(
Icons.close,
size: 18,
color: appTheme.whiteA700,
),
label: Text(
s.video_call_close,
style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
),
),
),
),
],
),
);
}
}
class VideoCallConnectingScreen extends StatelessWidget {
const VideoCallConnectingScreen({super.key, required this.isReconnecting});
final bool isReconnecting;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final title = isReconnecting
? s.video_call_screen_reconnecting_title
: s.video_call_screen_connecting_title;
final subtitle = isReconnecting
? s.video_call_screen_reconnecting_subtitle
: s.video_call_screen_connecting_subtitle;
return _VideoCallFlowScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 44,
height: 44,
child: CircularProgressIndicator(
strokeWidth: 3,
color: appTheme.blueA400,
),
),
const SizedBox(height: 28),
Text(
title,
textAlign: TextAlign.center,
style: CustomTextStyles.videoCallFlowPlaceholderTitle,
),
const SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: isReconnecting
? CustomTextStyles.videoCallFlowPlaceholderReconnectingSubtitle
: CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle,
),
],
),
);
}
}
class VideoCallConnectionLostScreen extends StatelessWidget {
const VideoCallConnectionLostScreen({
super.key,
required this.detailMessage,
required this.onRetry,
required this.onLeave,
});
final String? detailMessage;
final VoidCallback onRetry;
final VoidCallback onLeave;
@override
Widget build(BuildContext context) {
final s = S.of(context);
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
return ColoredBox(
color: appTheme.whiteA700,
child: Stack(
fit: StackFit.expand,
children: [
SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.only(
left: 24,
right: 24,
bottom: _kVideoCallFlowBottomBarHeight + bottomInset,
),
child: Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
'assets/icons/video_call/poor_connection.svg',
width: 120,
height: 107,
fit: BoxFit.contain,
),
const SizedBox(height: 16),
Text(
s.video_call_connection_lost_title,
textAlign: TextAlign.center,
style: CustomTextStyles.videoCallFlowPlaceholderTitle,
),
const SizedBox(height: 8),
Text(
s.video_call_connection_lost_message,
textAlign: TextAlign.center,
style: CustomTextStyles
.videoCallFlowPlaceholderConnectionLostBody,
),
if (detailMessage != null &&
detailMessage!.trim().isNotEmpty) ...[
const SizedBox(height: 16),
Text(
detailMessage!,
textAlign: TextAlign.center,
style: CustomTextStyles
.videoCallFlowPlaceholderConnectionLostDetail,
),
],
],
),
),
),
),
),
_videoCallFlowBottomBarGradient(
bottomInset: bottomInset,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FilledButton(
onPressed: onRetry,
style: _videoCallFlowBarButtonStyle(
backgroundColor: appTheme.greenA700,
),
child: Text(
s.video_call_try_again,
style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
),
),
FilledButton.icon(
onPressed: onLeave,
style: _videoCallFlowBarButtonStyle(
backgroundColor: appTheme.videoCallControlOffBackground,
),
icon: Icon(
Icons.close,
size: 18,
color: appTheme.whiteA700,
),
label: Text(
s.video_call_close,
style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,160 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class VideoCallGlobalPipLayer extends StatefulWidget {
const VideoCallGlobalPipLayer({super.key});
@override
State<VideoCallGlobalPipLayer> createState() =>
_VideoCallGlobalPipLayerState();
}
class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
static const double _pipW = 142;
static const double _pipH = 201;
static const double _edgeInset = 8;
static const double _pipInitialExtraLeft = 28;
static const double _pipInitialExtraDown = 16;
double? _pipLeft;
double? _pipTop;
Offset? _pipPanPointerGlobal;
double? _pipPanOriginLeft;
double? _pipPanOriginTop;
void _resetPipPanState() {
_pipPanPointerGlobal = null;
_pipPanOriginLeft = null;
_pipPanOriginTop = null;
}
void _openFullscreenRoute(VideoCallSessionState s) {
final ctx = AppRouter.navigatorKey.currentContext;
if (ctx == null) return;
unawaited(sl<VideoCallSessionCubit>().restoreFullscreenFromPip());
GoRouter.of(ctx).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': s.conversationUuid,
'counterpartName': s.counterpartName,
'localParticipantName': s.localParticipantName,
},
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: sl<VideoCallSessionCubit>(),
buildWhen: (a, b) =>
a.uiMode != b.uiMode ||
a.connectPhase != b.connectPhase ||
a.callStageEntered != b.callStageEntered ||
a.videoEnabled != b.videoEnabled,
builder: (context, state) {
if (state.uiMode != VideoCallUiMode.pip ||
state.connectPhase != VideoCallConnectPhase.connected ||
!state.callStageEntered) {
return const SizedBox.shrink();
}
final cubit = context.read<VideoCallSessionCubit>();
if (Platform.isAndroid && cubit.isCallScreenRouteVisible) {
return const SizedBox.shrink();
}
final room = cubit.liveKitRoom;
if (room == null) return const SizedBox.shrink();
final mq = MediaQuery.of(context);
final maxW = mq.size.width;
final maxH = mq.size.height;
final topInset = mq.viewPadding.top > 0 ? mq.viewPadding.top : 16.0;
final bottomInset = mq.viewPadding.bottom;
final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft;
final minPipTop = topInset + _edgeInset;
final defaultPipTop = minPipTop + _pipInitialExtraDown;
final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset);
final maxPipTop = math.max(
minPipTop,
maxH - _pipH - _edgeInset - bottomInset,
);
final pipLeft =
((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num)
.toDouble();
final pipTop =
((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num)
.toDouble();
return Material(
type: MaterialType.transparency,
child: ListenableBuilder(
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: pipLeft,
top: pipTop,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openFullscreenRoute(state),
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: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: _pipW,
height: _pipH,
child: VideoCallPipVideoPreview(
sessionState: state,
room: room,
),
),
),
),
),
],
);
},
),
);
},
);
}
}

View File

@@ -8,6 +8,9 @@ abstract final class VideoCallLiveKitTrackUtils {
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
if (pub == null || pub.muted) return null;
if (pub is lk.RemoteTrackPublication && !pub.subscribed) {
return null;
}
final v = asVideoTrack(pub.track);
if (v == null || v.muted) return null;
return v;
@@ -37,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils {
room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera),
);
}
static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty;
}

View File

@@ -2,40 +2,74 @@ import 'dart:ui';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class VideoCallParticipantNameLabel extends StatelessWidget {
const VideoCallParticipantNameLabel({super.key, required this.name});
const VideoCallParticipantNameLabel({
super.key,
required this.name,
this.isConsultant = false,
});
final String name;
final bool isConsultant;
@override
Widget build(BuildContext context) {
final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null;
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: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (roleLabel != null) ...[
Container(
constraints: const BoxConstraints(maxWidth: 240),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: appTheme.whiteA700,
borderRadius: BorderRadius.circular(60),
),
child: Text(
roleLabel,
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.blueA400,
height: 14 / 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
child: Text(
name,
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
const SizedBox(height: 16),
],
ClipRRect(
borderRadius: BorderRadius.circular(60),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
child: Container(
constraints: const BoxConstraints(maxWidth: 240),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: appTheme.transparentBlack,
borderRadius: BorderRadius.circular(60),
),
child: Text(
name,
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
],
),
),
);

View File

@@ -0,0 +1,73 @@
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.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:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
/// Shared compact preview for both in-app and Android system PiP.
class VideoCallPipVideoPreview extends StatelessWidget {
const VideoCallPipVideoPreview({
super.key,
required this.sessionState,
required this.room,
});
final VideoCallSessionState sessionState;
final lk.Room room;
static const double _pipNoVideoLogoSize = 56;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) {
if (VideoCallLiveKitTrackUtils.isSoloInRoom(room)) {
return _VideoCallPipLocalPreview(
room: room,
videoEnabled: sessionState.videoEnabled,
);
}
return VideoCallRemoteVideoStage(
room: room,
connectError: null,
noVideoLogoSize: _pipNoVideoLogoSize,
);
},
);
}
}
class _VideoCallPipLocalPreview extends StatelessWidget {
const _VideoCallPipLocalPreview({
required this.room,
required this.videoEnabled,
});
final lk.Room room;
final bool videoEnabled;
@override
Widget build(BuildContext context) {
if (!videoEnabled) {
return const VideoCallNoVideoBackground(logoSize: 56);
}
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(
local,
key: ValueKey(local.sid),
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
);
}
return const VideoCallNoVideoBackground(logoSize: 56);
}
}

View File

@@ -2,6 +2,7 @@ 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:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
import 'package:flutter/material.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
@@ -10,25 +11,34 @@ class VideoCallRemoteVideoStage extends StatelessWidget {
super.key,
required this.room,
required this.connectError,
this.noVideoLogoSize = 96,
});
final lk.Room room;
final String? connectError;
final double noVideoLogoSize;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: room,
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) {
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
if (remote != null) {
return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover);
return lk.VideoTrackRenderer(
remote,
key: ValueKey(remote.sid),
fit: lk.VideoViewFit.cover,
);
}
final showConnectError =
connectError != null &&
room.connectionState != lk.ConnectionState.connected;
return Stack(
fit: StackFit.expand,
children: [
const VideoCallNoVideoBackground(),
if (connectError != null)
VideoCallNoVideoBackground(logoSize: noVideoLogoSize),
if (showConnectError)
Center(
child: Padding(
padding: const EdgeInsets.all(24),

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
Listenable videoCallRoomTracksListenable(lk.Room room) {
return Listenable.merge([
room,
...room.remoteParticipants.values,
if (room.localParticipant != null) room.localParticipant!,
]);
}

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_
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:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
@@ -24,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget {
required this.connectError,
required this.localDisplayName,
required this.remoteDisplayName,
this.remoteIsConsultant = false,
required this.micEnabled,
required this.videoEnabled,
required this.splitParticipantView,
@@ -48,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget {
final String? connectError;
final String localDisplayName;
final String remoteDisplayName;
final bool remoteIsConsultant;
final bool micEnabled;
final bool videoEnabled;
final bool splitParticipantView;
@@ -83,6 +86,8 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
static const double _pipInitialExtraDown = 16;
static const double _bottomGradientHeight = 140;
static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop;
static const int _splitRemoteFlex = 7;
static const int _splitLocalFlex = 3;
double? _pipLeft;
double? _pipTop;
@@ -90,13 +95,6 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
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(
@@ -110,15 +108,17 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
}
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(
return ListenableBuilder(
listenable: videoCallRoomTracksListenable(widget.room),
builder: (context, _) {
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
if (soloInRoom) {
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
child: _SplitParticipantPane(
room: widget.room,
displayName: widget.localDisplayName,
@@ -128,40 +128,73 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
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,
),
borderRadius: BorderRadius.circular(_mainStageCornerRadius),
),
),
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(),
],
);
}
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
child: Column(
children: [
Expanded(
flex: _splitRemoteFlex,
child: _SplitParticipantPane(
room: widget.room,
displayName: widget.remoteDisplayName,
isConsultant: widget.remoteIsConsultant,
isLocal: false,
connectError: widget.connectError,
videoEnabled: widget.videoEnabled,
useFrontCamera: widget.useFrontCamera,
onExpand: widget.onExpandRemote,
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(
flex: _splitLocalFlex,
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.zero,
topRight: Radius.zero,
bottomLeft: Radius.circular(_mainStageCornerRadius),
bottomRight: Radius.circular(_mainStageCornerRadius),
),
),
),
],
),
],
),
),
),
),
..._bottomBarGradient(),
..._bottomControlsRow(),
],
..._bottomBarGradient(),
..._bottomControlsRow(),
],
);
},
);
}
@@ -176,99 +209,106 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
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,
),
return ListenableBuilder(
listenable: videoCallRoomTracksListenable(widget.room),
builder: (context, _) {
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
final mainShowsLocal =
soloInRoom || widget.mainStageShowsLocalVideo;
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: mainShowsLocal,
videoEnabled: widget.videoEnabled,
),
VideoCallParticipantNameLabel(
name: mainShowsLocal
? widget.localDisplayName
: widget.remoteDisplayName,
isConsultant: !mainShowsLocal && widget.remoteIsConsultant,
),
],
),
),
),
);
},
),
],
),
..._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),
),
),
),
if (!soloInRoom)
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: mainShowsLocal
? _VideoCallPipRemoteContent(room: widget.room)
: _VideoCallPipLocalContent(
room: widget.room,
videoEnabled: widget.videoEnabled,
useFrontCamera: widget.useFrontCamera,
onSwitchCamera: widget.onSwitchCamera,
),
),
),
),
),
],
);
},
);
}
@@ -332,6 +372,7 @@ class _SplitParticipantPane extends StatelessWidget {
const _SplitParticipantPane({
required this.room,
required this.displayName,
this.isConsultant = false,
required this.isLocal,
required this.connectError,
required this.videoEnabled,
@@ -343,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget {
final lk.Room room;
final String displayName;
final bool isConsultant;
final bool isLocal;
final String? connectError;
final bool videoEnabled;
@@ -362,7 +404,7 @@ class _SplitParticipantPane extends StatelessWidget {
children: [
Positioned.fill(
child: ListenableBuilder(
listenable: room,
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) {
if (isLocal) {
if (!videoEnabled) {
@@ -371,7 +413,12 @@ class _SplitParticipantPane extends StatelessWidget {
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
child: lk.VideoTrackRenderer(
local,
key: ValueKey(local.sid),
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
);
}
return const VideoCallNoVideoBackground();
@@ -380,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget {
},
),
),
VideoCallParticipantNameLabel(name: displayName),
VideoCallParticipantNameLabel(
name: displayName,
isConsultant: !isLocal && isConsultant,
),
Positioned(
left: 0,
top: _VideoCallStageLayoutState._splitExpandButtonTop,
@@ -421,7 +471,7 @@ class _VideoCallMainStage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: room,
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) {
if (mainShowsLocal) {
if (!videoEnabled) {
@@ -430,7 +480,12 @@ class _VideoCallMainStage extends StatelessWidget {
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
if (local != null) {
return IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
child: lk.VideoTrackRenderer(
local,
key: ValueKey(local.sid),
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
);
}
return const VideoCallNoVideoBackground();
@@ -448,7 +503,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null));
return IgnorePointer(
child: VideoCallRemoteVideoStage(
room: room,
connectError: null,
noVideoLogoSize: 56,
),
);
}
}
@@ -470,7 +531,12 @@ class _VideoCallPipLocalContent extends StatelessWidget {
const VideoCallNoVideoBackground(logoSize: 56)
else if (local != null)
IgnorePointer(
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
child: lk.VideoTrackRenderer(
local,
key: ValueKey(local.sid),
fit: lk.VideoViewFit.cover,
mirrorMode: lk.VideoViewMirrorMode.auto,
),
)
else
const VideoCallNoVideoBackground(logoSize: 56),

View File

@@ -0,0 +1,66 @@
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode.
class VideoCallSystemPipContent extends StatelessWidget {
const VideoCallSystemPipContent({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
bloc: sl<VideoCallSessionCubit>(),
buildWhen: (a, b) =>
a.connectPhase != b.connectPhase ||
a.linkIssue != b.linkIssue ||
a.callStageEntered != b.callStageEntered ||
a.videoEnabled != b.videoEnabled,
builder: (context, state) {
if (state.connectPhase != VideoCallConnectPhase.connected ||
state.linkIssue.needsManualReconnect ||
!state.callStageEntered) {
if (state.conversationUuid.isNotEmpty &&
state.connectPhase != VideoCallConnectPhase.idle &&
!state.linkIssue.needsManualReconnect) {
return ColoredBox(
color: appTheme.gray100,
child: Center(
child: SizedBox(
width: 36,
height: 36,
child: CircularProgressIndicator(
strokeWidth: 3,
color: appTheme.blueA400,
),
),
),
);
}
return const ColoredBox(color: Colors.black);
}
final cubit = sl<VideoCallSessionCubit>();
final room = cubit.liveKitRoom;
if (room == null) {
return const ColoredBox(color: Colors.black);
}
return Material(
color: Colors.black,
child: ListenableBuilder(
listenable: videoCallRoomTracksListenable(room),
builder: (context, _) => VideoCallPipVideoPreview(
sessionState: state,
room: room,
),
),
);
},
);
}
}

View File

@@ -0,0 +1,117 @@
import 'dart:async';
import 'dart:io';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:floating/floating.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events.
class VideoCallSystemPipListener extends StatefulWidget {
const VideoCallSystemPipListener({super.key});
@override
State<VideoCallSystemPipListener> createState() =>
_VideoCallSystemPipListenerState();
}
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
with WidgetsBindingObserver {
static const MethodChannel _userLeaveHintChannel = MethodChannel(
'com.eatvioapp/video_call_pip',
);
StreamSubscription<PiPStatus>? _subscription;
PiPStatus? _lastStatus;
@override
void initState() {
super.initState();
if (!Platform.isAndroid) return;
WidgetsBinding.instance.addObserver(this);
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
final coordinator = sl<VideoCallSystemPipCoordinator>();
_subscription = coordinator.statusStream.listen(_onPipStatus);
unawaited(_disarmSystemPipIfNoActiveCall());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_disarmSystemPipIfNoActiveCall());
}
}
Future<void> _disarmSystemPipIfNoActiveCall() async {
if (!sl<VideoCallManager>().isActiveCall) {
await sl<VideoCallSystemPipCoordinator>().disarm();
}
}
Future<void> _onNativePipMethodCall(MethodCall call) async {
if (call.method != 'onUserLeaveHint') return;
await sl<VideoCallSessionCubit>().syncPipUiStateFromSystem();
}
Future<void> _onPipStatus(PiPStatus status) async {
final cubit = sl<VideoCallSessionCubit>();
final wasEnabled = _lastStatus == PiPStatus.enabled;
_lastStatus = status;
if (status == PiPStatus.enabled) {
await cubit.syncPipUiStateFromSystem();
return;
}
if (!wasEnabled || status != PiPStatus.disabled) return;
if (cubit.isCallScreenRouteVisible) {
await cubit.onSystemPipExitedWithCallScreenVisible();
return;
}
if (cubit.state.uiMode != VideoCallUiMode.pip) return;
await cubit.restoreFullscreenFromPip();
if (!mounted) return;
if (cubit.isCallScreenRouteVisible) return;
_openCallScreen(cubit.state);
}
void _openCallScreen(VideoCallSessionState state) {
if (state.conversationUuid.isEmpty) return;
final ctx = AppRouter.navigatorKey.currentContext;
if (ctx == null) return;
GoRouter.of(ctx).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': state.conversationUuid,
'counterpartName': state.counterpartName,
'localParticipantName': state.localParticipantName,
},
);
}
@override
void dispose() {
if (Platform.isAndroid) {
WidgetsBinding.instance.removeObserver(this);
_userLeaveHintChannel.setMethodCallHandler(null);
}
unawaited(_subscription?.cancel());
super.dispose();
}
@override
Widget build(BuildContext context) => const SizedBox.shrink();
}

View File

@@ -1,12 +1,75 @@
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.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 poorConnectionSvg =
'assets/icons/video_call/poor_connection.svg';
static const String warningSvg = 'assets/icons/video_call/warning.svg';
static const double _poorConnectionIconSize = 44;
static const double _poorConnectionGlyphSize = 22;
static OverlayEntry? _poorConnectionEntry;
static bool get isPoorConnectionIconVisible =>
_poorConnectionEntry?.mounted ?? false;
static OverlayState? _resolveOverlayState(BuildContext context) {
return VideoCallBannerOverlay.overlayState ??
AppRouter.navigatorKey.currentState?.overlay ??
Overlay.maybeOf(context, rootOverlay: true);
}
/// Compact poor-connection indicator: red square with icon only.
static void showPoorConnectionIcon(BuildContext context) {
if (isPoorConnectionIconVisible) return;
hidePoorConnectionIcon();
final overlayState = _resolveOverlayState(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) {
final topInset = MediaQuery.viewPaddingOf(ctx).top;
return Positioned(
left: 16,
top: topInset + 8,
width: _poorConnectionIconSize,
height: _poorConnectionIconSize,
child: Material(
color: appTheme.strongRed,
borderRadius: BorderRadius.circular(10),
elevation: 6,
child: Center(
child: SvgPicture.asset(
poorConnectionSvg,
width: _poorConnectionGlyphSize,
height: _poorConnectionGlyphSize,
),
),
),
);
},
);
_poorConnectionEntry = entry;
if (overlayState == null) {
_poorConnectionEntry = null;
return;
}
overlayState.insert(entry);
}
static void hidePoorConnectionIcon() {
final entry = _poorConnectionEntry;
_poorConnectionEntry = null;
if (entry != null && entry.mounted) {
entry.remove();
}
}
static OverlayEntry insertBanner(
BuildContext context, {
@@ -15,7 +78,7 @@ abstract final class VideoCallTopOverlay {
required String svgAsset,
Duration? autoRemoveAfter,
}) {
final overlayState = Overlay.of(context, rootOverlay: true);
final overlayState = _resolveOverlayState(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => Positioned(
@@ -50,6 +113,9 @@ abstract final class VideoCallTopOverlay {
),
),
);
if (overlayState == null) {
return entry;
}
overlayState.insert(entry);
if (autoRemoveAfter != null) {
Future<void>.delayed(autoRemoveAfter, () {

View File

@@ -24,4 +24,14 @@ class VideoCallRepository {
body: _emptyJsonBody,
);
}
Future<VideoCallAcceptResponse> notifyCallAccepted({
required String conversationUuid,
required String hwId,
}) {
return videoCallService.notifyCallAccepted(
conversationUuid: conversationUuid,
body: <String, dynamic>{'hw_id': hwId},
);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:audio_session/audio_session.dart';
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
/// Configures and owns the platform audio session for an active VoIP call.
class VideoCallAudioSessionCoordinator {
VideoCallAudioSessionCoordinator({
VideoCallAudioSessionFactory? audioSessionFactory,
}) : _audioSessionFactory =
audioSessionFactory ?? (() => AudioSession.instance);
final VideoCallAudioSessionFactory _audioSessionFactory;
static final AudioSessionConfiguration callConfiguration =
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.voiceChat,
avAudioSessionRouteSharingPolicy:
AVAudioSessionRouteSharingPolicy.defaultPolicy,
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
);
AudioSession? _session;
bool _configured = false;
bool get isActive => _session != null;
Future<void> start() async {
final session = await _audioSessionFactory();
_session = session;
if (!_configured) {
await session.configure(callConfiguration);
_configured = true;
}
await session.setActive(true);
}
Future<bool> reactivate() async {
final session = _session;
if (session == null) return false;
try {
return await session.setActive(true);
} catch (_) {
return false;
}
}
Future<void> stop() async {
final session = _session;
_session = null;
if (session == null) return;
try {
await session.setActive(false);
} catch (_) {}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
/// Dismisses incoming call UI after a remote `video_call_ended` push.
abstract final class VideoCallEndedPushHandler {
static const MethodChannel _androidPushChannel = MethodChannel(
'com.eatvioapp/video_call_push',
);
static Future<void> dismissIncomingCallUi() async {
if (Platform.isAndroid) {
try {
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
return;
} catch (_) {}
}
await _dismissViaCallKit();
}
static Future<void> _dismissViaCallKit() async {
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
for (final call in activeCalls) {
try {
await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {}
}
} catch (_) {}
try {
await FlutterCallkitIncoming.endAllCalls();
} catch (_) {}
}
}

View File

@@ -0,0 +1,11 @@
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
/// Marker for a data-only `video_call_ended` push (no conversation uuid required).
class VideoCallEndedPushPayload {
const VideoCallEndedPushPayload._();
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
return const VideoCallEndedPushPayload._();
}
}

View File

@@ -0,0 +1,111 @@
import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:eatvio_app/firebase_options.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/widgets.dart';
/// Top-level FCM background handler (separate isolate).
@pragma('vm:entry-point')
Future<void> videoCallFirebaseMessagingBackgroundHandler(
RemoteMessage message,
) async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await VideoCallFcmHandler.handleRemoteMessage(
message,
source: 'background',
);
}
abstract final class VideoCallFcmHandler {
static bool _foregroundListenerRegistered = false;
static void registerBackgroundHandler() {
FirebaseMessaging.onBackgroundMessage(
videoCallFirebaseMessagingBackgroundHandler,
);
}
static void registerForegroundListener() {
if (_foregroundListenerRegistered) return;
_foregroundListenerRegistered = true;
FirebaseMessaging.onMessage.listen((message) {
handleRemoteMessage(message, source: 'foreground');
});
}
static Future<void> handleRemoteMessage(
RemoteMessage message, {
required String source,
}) async {
final data = Map<String, dynamic>.from(message.data);
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
if (endedPayload != null) {
if (Platform.isAndroid && source == 'background') {
log(
'VideoCallFcmHandler: skip background ended call, handled by native FCM service',
name: 'VideoCallFcm',
);
return;
}
log(
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
name: 'VideoCallFcm',
);
try {
await VideoCallEndedPushHandler.dismissIncomingCallUi();
} catch (e, st) {
log(
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',
name: 'VideoCallFcm',
stackTrace: st,
);
}
return;
}
if (!Platform.isAndroid) return;
if (data.isEmpty) return;
final payload = VideoCallPushPayload.tryParse(data);
if (payload == null) {
log(
'VideoCallFcmHandler: skip non-video-call push, source=$source, data=$data',
name: 'VideoCallFcm',
);
return;
}
if (source == 'background') {
log(
'VideoCallFcmHandler: skip background video call, handled by native FCM service, conversation=${payload.conversationUuid}',
name: 'VideoCallFcm',
);
return;
}
log(
'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}',
name: 'VideoCallFcm',
);
try {
await VideoCallIncomingPushPresenter.showIncomingCall(payload);
} catch (e, st) {
log(
'VideoCallFcmHandler: failed to show incoming call UI: $e',
name: 'VideoCallFcm',
stackTrace: st,
);
}
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
abstract final class VideoCallForegroundBootstrap {
static const int _serviceId = 1001;
static bool _initialized = false;
static void ensureInitialized() {
if (_initialized) return;
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'video_call_ongoing',
channelName: 'Video call',
channelDescription: 'Ongoing video call notifications',
channelImportance: NotificationChannelImportance.LOW,
priority: NotificationPriority.LOW,
onlyAlertOnce: true,
),
iosNotificationOptions: const IOSNotificationOptions(
showNotification: true,
playSound: false,
),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.nothing(),
autoRunOnBoot: false,
autoRunOnMyPackageReplaced: false,
allowWakeLock: true,
allowWifiLock: true,
),
);
_initialized = true;
}
static int get serviceId => _serviceId;
}

View File

@@ -0,0 +1,79 @@
import 'dart:io';
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:permission_handler/permission_handler.dart';
class VideoCallForegroundCoordinator {
Future<void> ensureNotificationPermission() async {
final permission = await FlutterForegroundTask.checkNotificationPermission();
if (permission == NotificationPermission.granted) return;
await FlutterForegroundTask.requestNotificationPermission();
}
Future<void> startOngoingCall({
required String conversationUuid,
required String notificationTitle,
required String notificationBody,
required String endCallButtonLabel,
}) async {
VideoCallForegroundBootstrap.ensureInitialized();
await ensureNotificationPermission();
await FlutterForegroundTask.saveData(
key: videoCallActiveConversationStorageKey,
value: conversationUuid,
);
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.updateService(
notificationTitle: notificationTitle,
notificationText: notificationBody,
notificationButtons: [
NotificationButton(
id: videoCallForegroundEndCallButtonId,
text: endCallButtonLabel,
),
],
);
return;
}
if (Platform.isAndroid) {
final micStatus = await Permission.microphone.status;
if (!micStatus.isGranted) {
await Permission.microphone.request();
}
}
await FlutterForegroundTask.startService(
serviceId: VideoCallForegroundBootstrap.serviceId,
serviceTypes: const [ForegroundServiceTypes.microphone],
notificationTitle: notificationTitle,
notificationText: notificationBody,
notificationInitialRoute: '/video-call-screen',
notificationButtons: [
NotificationButton(
id: videoCallForegroundEndCallButtonId,
text: endCallButtonLabel,
),
],
callback: videoCallForegroundTaskStartCallback,
);
}
Future<void> stopOngoingCall() async {
if (!await FlutterForegroundTask.isRunningService) return;
await FlutterForegroundTask.stopService();
await FlutterForegroundTask.removeData(
key: videoCallActiveConversationStorageKey,
);
}
Future<String?> readActiveConversationUuid() {
return FlutterForegroundTask.getData<String>(
key: videoCallActiveConversationStorageKey,
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_callkit_incoming/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5';
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
final callerName =
payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim()
: 'Итвио';
final params = CallKitParams(
id: payload.conversationUuid,
nameCaller: callerName,
appName: 'Итвио',
avatar: payload.counterpartAvatarUrl,
handle: payload.conversationUuid,
type: 0,
duration: 45000,
extra: <String, dynamic>{
'conversationUuid': payload.conversationUuid,
if (payload.counterpartName != null)
'counterpartName': payload.counterpartName,
if (payload.counterpartAvatarUrl != null)
'counterpartAvatarUrl': payload.counterpartAvatarUrl,
if (payload.localParticipantName != null)
'localParticipantName': payload.localParticipantName,
if (payload.counterpartIsConsultant != null)
'counterpartIsConsultant': payload.counterpartIsConsultant,
},
callingNotification: const NotificationParams(showNotification: false),
android: const AndroidParams(
isCustomNotification: true,
isShowLogo: false,
isShowFullLockedScreen: false,
isFullScreen: false,
ringtonePath: 'system_ringtone_default',
backgroundColor: _androidAccentColor,
actionColor: _androidAccentColor,
textColor: '#ffffff',
textAccept: 'Answer',
textDecline: 'Decline',
isShowCallID: false,
),
ios: const IOSParams(
handleType: 'generic',
supportsVideo: true,
ringtonePath: 'system_ringtone_default',
),
);
await FlutterCallkitIncoming.showCallkitIncoming(params);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/widgets.dart';
typedef VideoCallLifecycleCallback = void Function(AppLifecycleState state);
class VideoCallLifecycleBinding {
AppLifecycleListener? _listener;
VideoCallLifecycleCallback? _callback;
@visibleForTesting
void simulateLifecycle(AppLifecycleState state) => _callback?.call(state);
void attach(VideoCallLifecycleCallback onStateChanged) {
_callback = onStateChanged;
_listener?.dispose();
_listener = AppLifecycleListener(
onResume: () => onStateChanged(AppLifecycleState.resumed),
onInactive: () => onStateChanged(AppLifecycleState.inactive),
onHide: () => onStateChanged(AppLifecycleState.hidden),
onPause: () => onStateChanged(AppLifecycleState.paused),
onDetach: () => onStateChanged(AppLifecycleState.detached),
);
}
void detach() {
_listener?.dispose();
_listener = null;
_callback = null;
}
}

View File

@@ -0,0 +1,196 @@
import 'dart:async';
import 'dart:developer';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
class VideoCallNotificationNavigator {
static bool _pushInFlight = false;
static Future<void> openActiveCall({String? conversationUuid}) async {
final manager = sl<VideoCallManager>();
final uuid = conversationUuid ?? manager.conversationUuid;
if (uuid.isEmpty) return;
await _pushCallScreen(
conversationUuid: uuid,
counterpartName: manager.counterpartName,
localParticipantName: manager.localParticipantName,
);
await manager.onCallScreenVisible(
conversationUuid: uuid,
counterpartName: manager.counterpartName,
localParticipantName: manager.localParticipantName,
);
}
/// Returns `true` when the call UI is visible or navigation succeeded.
static Future<bool> openIncomingCall({
required String conversationUuid,
String? counterpartName,
String? counterpartAvatarUrl,
String? localParticipantName,
bool? counterpartIsConsultant,
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
}) async {
if (conversationUuid.isEmpty) return false;
FlutterNativeSplash.remove();
final readySince = DateTime.now();
await _waitUntilNavigationReady(since: readySince);
for (var attempt = 0; attempt < 8; attempt++) {
if (attempt > 0) {
await Future<void>.delayed(Duration(milliseconds: 200 * attempt));
await _waitUntilNavigationReady(since: readySince);
}
if (isOnCallRoute()) {
return true;
}
final pushed = await _pushCallScreen(
conversationUuid: conversationUuid,
counterpartName: counterpartName,
counterpartAvatarUrl: counterpartAvatarUrl,
localParticipantName: localParticipantName,
counterpartIsConsultant: counterpartIsConsultant,
entryMode: entryMode,
);
if (pushed) {
return true;
}
}
return isOnCallRoute();
}
static bool isOnCallRoute() {
final matches =
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
if (matches.isEmpty) return false;
return matches.last.matchedLocation.contains(VideoCallScreen.path);
}
static Future<void> _waitUntilNavigationReady({
required DateTime since,
Duration timeout = const Duration(seconds: 12),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
final lifecycle = WidgetsBinding.instance.lifecycleState;
final context = AppRouter.navigatorKey.currentContext;
final routerReady = _isRouterReadyForCallNavigation(since: since);
if (lifecycle == AppLifecycleState.resumed &&
context != null &&
context.mounted &&
routerReady) {
for (var frame = 0; frame < 2; frame++) {
await SchedulerBinding.instance.endOfFrame;
}
return;
}
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
static bool _isRouterReadyForCallNavigation({required DateTime since}) {
final matches =
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
if (matches.isEmpty) return false;
final topLocation = matches.last.matchedLocation;
if (topLocation == RouteConstants.updateScreen) {
// Cold start: wait for version-check redirect unless it is taking too long.
return DateTime.now().difference(since) >
const Duration(seconds: 6);
}
return true;
}
static Future<void> _waitForRouteToSettle() async {
for (var frame = 0; frame < 4; frame++) {
await SchedulerBinding.instance.endOfFrame;
}
await Future<void>.delayed(const Duration(milliseconds: 80));
}
static Future<bool> _waitForCallRouteVisible({
Duration timeout = const Duration(seconds: 3),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (isOnCallRoute()) {
await _waitForRouteToSettle();
return isOnCallRoute();
}
await SchedulerBinding.instance.endOfFrame;
await Future<void>.delayed(const Duration(milliseconds: 30));
}
return isOnCallRoute();
}
static Future<bool> _pushCallScreen({
required String conversationUuid,
String? counterpartName,
String? counterpartAvatarUrl,
String? localParticipantName,
bool? counterpartIsConsultant,
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
}) async {
if (isOnCallRoute()) {
return true;
}
if (_pushInFlight) {
return false;
}
final context = AppRouter.navigatorKey.currentContext;
if (context == null || !context.mounted) {
return false;
}
final extra = <String, dynamic>{
'conversationUuid': conversationUuid,
'counterpartName': counterpartName,
'counterpartAvatarUrl': counterpartAvatarUrl,
'localParticipantName': localParticipantName,
'counterpartIsConsultant': ?counterpartIsConsultant,
'entryMode': entryMode.name,
};
_pushInFlight = true;
try {
try {
// pushNamed completes when the route is popped, not when it opens.
unawaited(
AppRouter.appRouter.pushNamed(
VideoCallScreen.name,
extra: extra,
),
);
} catch (error, stackTrace) {
log(
'VideoCallNotificationNavigator: pushNamed failed: $error',
name: 'VideoCallCallKit',
stackTrace: stackTrace,
);
return false;
}
return _waitForCallRouteVisible();
} finally {
_pushInFlight = false;
}
}
}

View File

@@ -0,0 +1,187 @@
class VideoCallPushPayload {
const VideoCallPushPayload({
required this.conversationUuid,
this.counterpartName,
this.counterpartAvatarUrl,
this.localParticipantName,
this.counterpartIsConsultant,
});
final String conversationUuid;
final String? counterpartName;
final String? counterpartAvatarUrl;
final String? localParticipantName;
final bool? counterpartIsConsultant;
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
if (!isIncomingVideoCall(data)) return null;
final conversationUuid = _conversationUuid(data);
if (conversationUuid == null || conversationUuid.isEmpty) return null;
return VideoCallPushPayload(
conversationUuid: conversationUuid,
counterpartName: _string(data, const [
'counterpartName',
'counterpart_name',
'caller_name',
'callerName',
'nameCaller',
'caller',
]),
counterpartAvatarUrl: _resolveAvatarUrl(
_string(data, const [
'counterpartAvatarUrl',
'counterpart_avatar_url',
'avatar',
'avatarUrl',
'avatar_url',
'caller_avatar',
]),
),
localParticipantName: _string(data, const [
'localParticipantName',
'local_participant_name',
]),
counterpartIsConsultant: _bool(data, const [
'counterpartIsConsultant',
'counterpart_is_consultant',
'is_consultant',
'isConsultant',
]),
);
}
static bool isEndedVideoCall(Map<String, dynamic> data) {
final type = _string(data, const [
'type',
'notification_type',
'event',
'action',
'push_type',
'call_type',
]);
if (type != null) {
final normalized = type.toLowerCase().replaceAll('-', '_');
if (normalized == 'video_call_ended' || normalized == 'video_call_end') {
return true;
}
if (normalized.contains('video_call') &&
(normalized.contains('ended') || normalized.endsWith('_end'))) {
return true;
}
}
return false;
}
static bool isIncomingVideoCall(Map<String, dynamic> data) {
final type = _string(data, const [
'type',
'notification_type',
'event',
'action',
'push_type',
'call_type',
]);
if (type != null) {
final normalized = type.toLowerCase().replaceAll('-', '_');
if (normalized.contains('video_call')) {
if (normalized.contains('end') || normalized.contains('ended')) {
return false;
}
return true;
}
if (normalized == 'incoming_call' ||
normalized == 'video_call_incoming' ||
normalized == 'incoming_video_call') {
return true;
}
}
if (_isTruthy(data['is_video_call']) ||
_isTruthy(data['incoming_call']) ||
_isTruthy(data['is_incoming_call'])) {
return _conversationUuid(data) != null;
}
final actionUrl = _string(data, const ['action_url']);
if (actionUrl != null &&
(actionUrl.contains('video-call') || actionUrl.contains('videoCall'))) {
return true;
}
return false;
}
static String? conversationUuidFromData(Map<String, dynamic> data) =>
_conversationUuid(data);
static String? _conversationUuid(Map<String, dynamic> data) {
final fromData = _string(data, const [
'conversationUuid',
'conversation_uuid',
'conversation_id',
'chat_uuid',
'uuid',
]);
if (fromData != null && fromData.isNotEmpty) return fromData;
final actionUrl = _string(data, const ['action_url']);
if (actionUrl == null) return null;
final uri = Uri.tryParse(actionUrl);
if (uri == null) return null;
final fromQuery = uri.queryParameters['uuid'] ??
uri.queryParameters['conversationUuid'] ??
uri.queryParameters['conversation_uuid'];
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
return fromQuery.trim();
}
final segments = uri.pathSegments;
for (var i = 0; i < segments.length - 1; i++) {
if (segments[i] == 'video-call' && segments[i + 1].isNotEmpty) {
return segments[i + 1];
}
}
return null;
}
static String? _string(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = map[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
static bool? _bool(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
if (!map.containsKey(key)) continue;
return _isTruthy(map[key]);
}
return null;
}
static bool _isTruthy(Object? value) {
if (value == null) return false;
if (value is bool) return value;
if (value is num) return value != 0;
final normalized = value.toString().trim().toLowerCase();
return normalized == '1' ||
normalized == 'true' ||
normalized == 'yes';
}
static String? _resolveAvatarUrl(String? raw) {
if (raw == null || raw.isEmpty) return null;
if (raw.startsWith('http://') || raw.startsWith('https://')) {
return raw;
}
return raw;
}
}

View File

@@ -0,0 +1,28 @@
import 'package:wakelock_plus/wakelock_plus.dart';
class VideoCallScreenWakeLockCoordinator {
bool _held = false;
bool get isHeld => _held;
Future<void> acquire() async {
if (_held) return;
try {
await WakelockPlus.enable();
_held = true;
} catch (_) {
_held = false;
}
}
Future<void> release() async {
if (!_held) return;
_held = false;
try {
await WakelockPlus.disable();
} catch (_) {}
}
}

View File

@@ -23,4 +23,10 @@ abstract class VideoCallService {
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
@POST('/api/v2/chat/{conversation}/video-call/accept')
Future<VideoCallAcceptResponse> notifyCallAccepted({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -0,0 +1,63 @@
import 'dart:io';
import 'package:floating/floating.dart';
import 'package:flutter/services.dart';
/// Android system Picture-in-Picture via the [floating] package.
class VideoCallSystemPipCoordinator {
VideoCallSystemPipCoordinator({Floating? floating})
: _floating = floating ?? Floating();
static const MethodChannel _nativePipChannel = MethodChannel(
'com.eatvioapp/video_call_pip',
);
final Floating _floating;
/// Portrait ratio close to the in-call mini preview (142×201).
static const Rational callAspectRatio = Rational(2, 3);
bool get isPlatformSupported => Platform.isAndroid;
Future<bool> get isAvailable async {
if (!isPlatformSupported) return false;
return _floating.isPipAvailable;
}
Stream<PiPStatus> get statusStream => _floating.pipStatusStream;
/// Enter PiP when the user leaves the app (Home / recent apps).
///
/// Requires Android 12+ (API 31); on older versions this is a no-op.
Future<void> armAutoPipOnLeave() async {
if (!await isAvailable) return;
await _setNativeUserLeaveHintPipEnabled(true);
try {
await _floating.enable(
const OnLeavePiP(aspectRatio: callAspectRatio),
);
} on PlatformException {
// OnLeavePiP is unavailable below Android 12; native onUserLeaveHint
// still handles Home / recent apps on Android 8+.
}
}
Future<void> disarm() async {
if (!isPlatformSupported) return;
await _setNativeUserLeaveHintPipEnabled(false);
try {
await _floating.cancelOnLeavePiP();
} on PlatformException {
// The activity may already be detached while the call is being torn down.
}
}
Future<void> _setNativeUserLeaveHintPipEnabled(bool enabled) async {
if (!isPlatformSupported) return;
try {
await _nativePipChannel.invokeMethod<void>('setPipEnabled', enabled);
} on PlatformException {
// Native channel may be unavailable during startup/teardown.
}
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
const String videoCallForegroundTaskActionKey = 'action';
const String videoCallForegroundOpenAction = 'open';
const String videoCallForegroundEndCallAction = 'end_call';
const String videoCallForegroundEndCallButtonId = 'end_call';
const String videoCallActiveConversationStorageKey = 'video_call_active_conversation_uuid';
@pragma('vm:entry-point')
void videoCallForegroundTaskStartCallback() {
FlutterForegroundTask.setTaskHandler(VideoCallTaskHandler());
}
class VideoCallTaskHandler extends TaskHandler {
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
@override
void onRepeatEvent(DateTime timestamp) {}
@override
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
@override
void onReceiveData(Object data) {}
@override
void onNotificationButtonPressed(String id) {
if (id == videoCallForegroundEndCallButtonId) {
FlutterForegroundTask.sendDataToMain(<String, String>{
videoCallForegroundTaskActionKey: videoCallForegroundEndCallAction,
});
}
}
@override
void onNotificationPressed() {
FlutterForegroundTask.sendDataToMain(<String, String>{
videoCallForegroundTaskActionKey: videoCallForegroundOpenAction,
});
}
@override
void onNotificationDismissed() {}
}

View File

@@ -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("Оказание услуг"),
@@ -875,13 +898,33 @@ class MessageLookup extends MessageLookupByLibrary {
),
"video_call": MessageLookupByLibrary.simpleMessage("Video call"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"),
"video_call_close": MessageLookupByLibrary.simpleMessage("Close"),
"video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
"It looks like there is an internet issue. Tap Retry to reconnect.",
),
"video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
"Connection lost",
),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Participant",
),
"video_call_enter": MessageLookupByLibrary.simpleMessage("Enter"),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"),
"video_call_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
"Microphone is muted during the phone call",
),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Attention!\nYour microphone is muted",
),
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
"End call",
),
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
"Call in progress",
),
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
"Return to call",
),
"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.",
@@ -904,7 +947,24 @@ class MessageLookup extends MessageLookupByLibrary {
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Eatvio consultant",
),
"video_call_screen_connecting_subtitle":
MessageLookupByLibrary.simpleMessage("This may take a few seconds"),
"video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage(
"Connecting to\nthe consultation",
),
"video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage(
"Loading…",
),
"video_call_screen_reconnecting_subtitle":
MessageLookupByLibrary.simpleMessage("Check your internet and wait"),
"video_call_screen_reconnecting_title":
MessageLookupByLibrary.simpleMessage("Reconnecting"),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"),
"video_call_status_online": MessageLookupByLibrary.simpleMessage("Online"),
"video_call_system_interruption": MessageLookupByLibrary.simpleMessage(
"Another call or app is using audio.\nYour consultation is paused until it ends.",
),
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Retry"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Video"),
"video_call_you": MessageLookupByLibrary.simpleMessage("You"),
@@ -944,6 +1004,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя",
),
"yes": MessageLookupByLibrary.simpleMessage("Yes"),
"youCanComplain": MessageLookupByLibrary.simpleMessage(
"Если вас что-то не устроило, вы можете подать жалобу",
),

View File

@@ -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("Поделиться"),
@@ -355,13 +378,33 @@ class MessageLookup extends MessageLookupByLibrary {
),
"video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"),
"video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"),
"video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
"Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.",
),
"video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
"Связь прервалась",
),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Собеседник",
),
"video_call_enter": MessageLookupByLibrary.simpleMessage("Войти"),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"),
"video_call_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
"Микрофон выключен на время телефонного звонка",
),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Внимание!\nУ вас выключен микрофон",
),
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
"Завершить звонок",
),
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
"Звонок идёт",
),
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
"Вернуться к звонку",
),
"video_call_permission_camera_description":
MessageLookupByLibrary.simpleMessage(
"Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
@@ -384,7 +427,24 @@ class MessageLookup extends MessageLookupByLibrary {
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Консультант Итвио",
),
"video_call_screen_connecting_subtitle":
MessageLookupByLibrary.simpleMessage("Это займёт несколько секунд"),
"video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage(
"Подключение\nк консультации",
),
"video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage(
"Загрузка…",
),
"video_call_screen_reconnecting_subtitle":
MessageLookupByLibrary.simpleMessage("Проверьте интернет и подождите"),
"video_call_screen_reconnecting_title":
MessageLookupByLibrary.simpleMessage("Восстанавливаем соединение"),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"),
"video_call_status_online": MessageLookupByLibrary.simpleMessage("Онлайн"),
"video_call_system_interruption": MessageLookupByLibrary.simpleMessage(
"Другой звонок или приложение использует аудио.\nКонсультация приостановлена до его завершения.",
),
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Повторить"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Видео"),
"video_call_you": MessageLookupByLibrary.simpleMessage("Вы"),
@@ -404,6 +464,7 @@ class MessageLookup extends MessageLookupByLibrary {
"wrong_credentials": MessageLookupByLibrary.simpleMessage(
"Неверные данные пользователя",
),
"yes": MessageLookupByLibrary.simpleMessage("Да"),
"you_will_get": MessageLookupByLibrary.simpleMessage("Вы получите"),
"your_blood_pressure": MessageLookupByLibrary.simpleMessage(
"Ваше артериальное давление?",

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