forked from eatvio/eatvio_app
Compare commits
118 Commits
issue-974
...
personaliz
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9de0efa28e | ||
|
|
2d6b042d45 | ||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
|
|
2cccc9e253 | ||
| dcf7b25989 | |||
| 163f56ff61 | |||
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 | ||
|
|
5dc1f48fd1 | ||
|
|
5ec877ef72 | ||
|
|
dbaf639ac6 | ||
|
|
2a07ed596b | ||
| bd2de07b8f | |||
|
|
eee912f05b | ||
|
|
e316bf1e0e | ||
|
|
de19af14bf | ||
|
|
b064c7016e | ||
|
|
e94681266a | ||
|
|
0ca2d1a1cc | ||
|
|
be924265a1 | ||
| a0edfe942b | |||
|
|
51397003b5 | ||
|
|
e10610d93c | ||
|
|
d21cc46558 | ||
|
|
2ab26bbda5 | ||
|
|
41e556d172 | ||
|
|
6d920857f6 | ||
|
|
7183fc5d7d | ||
|
|
3852a06ab4 | ||
|
|
219908684f | ||
|
|
9ce7b035ff | ||
|
|
c8ad600d97 | ||
|
|
f493e07f9f | ||
|
|
c0c7e2e658 | ||
|
|
f913ae60fa | ||
|
|
2100e5ca02 | ||
|
|
10b36117a6 | ||
|
|
bde4eea7d1 | ||
|
|
59f42c9b1e | ||
|
|
c5bf5acfe7 | ||
| 7db9af9894 | |||
|
|
8ff826fa3e | ||
|
|
1e29cda65c | ||
|
|
ed72ed9799 | ||
|
|
8920fc280f | ||
|
|
7493870a3d | ||
|
|
e3c22f8ac8 | ||
|
|
ea5fced456 | ||
|
|
e3dd8162fa | ||
|
|
90e5c7e58d | ||
|
|
65d529b0af | ||
|
|
95c1d40c79 | ||
|
|
895443e747 | ||
|
|
367753d409 | ||
|
|
e82866f936 | ||
|
|
b13ff74220 | ||
|
|
e0d697e8d0 | ||
|
|
705538ac85 | ||
|
|
03f037518e | ||
|
|
a07565fca9 | ||
|
|
b9cb4eeb84 | ||
|
|
4d531036fa | ||
|
|
1cafb4b05a | ||
|
|
5daa187045 | ||
|
|
f4f884484d | ||
|
|
4dd32e1a42 | ||
|
|
3e89bb5ca9 | ||
|
|
885c6f66f9 | ||
|
|
9dc568ceab | ||
|
|
6fb151ef32 | ||
|
|
5fa024a7a8 | ||
|
|
de795c5274 | ||
|
|
27fe4afd81 | ||
|
|
8e2b7147d5 | ||
|
|
f51a68324b | ||
| 8a0d51add9 | |||
|
|
634d83fd61 | ||
|
|
9e9f2a9f45 | ||
|
|
a495f98f30 | ||
|
|
9f8f9a7aaa | ||
|
|
af1980930b | ||
|
|
1c262da81a | ||
|
|
8a617509de | ||
|
|
73cec1a01e | ||
| fb99a42115 | |||
|
|
1acb158f36 | ||
| 01282a35cb | |||
| 0991ac555e | |||
| b5a889cbb0 | |||
| 985154c863 | |||
| f1e07b3d86 | |||
| 062079e7f1 | |||
| 24652884ee | |||
| ea9cdb93d4 | |||
| 7c4010b275 | |||
| 4458aaf74f | |||
| f20f871c88 | |||
| f61fb8c6b3 | |||
| 3d14dee4e9 | |||
| 38647b4ac6 | |||
| c836b600c6 | |||
| dfe11a6df2 | |||
| 35cad4e05e |
@@ -15,11 +15,12 @@ jobs:
|
|||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
|
|
||||||
- name: Checkout env repository
|
- name: Checkout env repository
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: mso7nueh/eatvio_app_env
|
repository: eatvio/eatvio_app_env
|
||||||
path: env_repo
|
path: env_repo
|
||||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
# Здесь может быть токен любого юзера с доступом в gitea
|
||||||
|
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Copy .env file
|
- name: Copy .env file
|
||||||
@@ -57,23 +58,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter upgrade
|
flutter --version
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub upgrade
|
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
# Configure Flutter for release mode
|
|
||||||
pod repo update
|
|
||||||
flutter build ios --config-only --release
|
flutter build ios --config-only --release
|
||||||
cd ios
|
cd ios
|
||||||
pod update
|
pod install
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
LC_ALL: en_US.UTF-8
|
LC_ALL: en_US.UTF-8
|
||||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
|
||||||
FASTLANE_USER: eatvio2023@icloud.com
|
FASTLANE_USER: eatvio2023@icloud.com
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: Eatvio App Android Test
|
name: Eatvio App Android Test
|
||||||
|
|
||||||
on: [pull_request]
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
flutter_version: '3.41.2'
|
flutter_version: '3.41.2'
|
||||||
@@ -152,9 +155,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: |
|
run: |
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub cache repair
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
flutter pub get
|
||||||
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
- name: Native Analyze
|
- name: Native Analyze
|
||||||
run: flutter analyze
|
run: flutter analyze
|
||||||
@@ -162,66 +166,66 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: flutter test --coverage
|
run: flutter test --coverage
|
||||||
|
|
||||||
- name: Test coverage
|
# - name: Test coverage
|
||||||
run: |
|
# run: |
|
||||||
echo "📊 Analyzing test coverage..."
|
# echo "📊 Analyzing test coverage..."
|
||||||
echo ""
|
# echo ""
|
||||||
awk -F: '
|
# awk -F: '
|
||||||
/^SF:/ {
|
# /^SF:/ {
|
||||||
file = $0
|
# file = $0
|
||||||
sub(/^SF:/, "", file)
|
# sub(/^SF:/, "", file)
|
||||||
sub(/^[[:space:]]+/, "", file)
|
# sub(/^[[:space:]]+/, "", file)
|
||||||
is_generated = 0
|
# is_generated = 0
|
||||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
# } else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else {
|
# } else {
|
||||||
source_files++
|
# source_files++
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LF:/ {
|
# /^LF:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
total += $2
|
# total += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_lines += $2
|
# generated_lines += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LH:/ {
|
# /^LH:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
covered += $2
|
# covered += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_covered += $2
|
# generated_covered += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
END {
|
# END {
|
||||||
print "📁 Files:"
|
# print "📁 Files:"
|
||||||
printf " Source files: %d\n", source_files
|
# printf " Source files: %d\n", source_files
|
||||||
printf " Generated files: %d (excluded)\n", generated_files
|
# printf " Generated files: %d (excluded)\n", generated_files
|
||||||
print ""
|
# print ""
|
||||||
print "📝 Lines:"
|
# print "📝 Lines:"
|
||||||
printf " Total lines: %d\n", total
|
# printf " Total lines: %d\n", total
|
||||||
printf " Covered lines: %d\n", covered
|
# printf " Covered lines: %d\n", covered
|
||||||
printf " Uncovered lines: %d\n", total - covered
|
# printf " Uncovered lines: %d\n", total - covered
|
||||||
print ""
|
# print ""
|
||||||
if (total == 0) {
|
# if (total == 0) {
|
||||||
print "⚠️ Coverage: 0%"
|
# print "⚠️ Coverage: 0%"
|
||||||
exit 1
|
# exit 1
|
||||||
} else {
|
# } else {
|
||||||
percentage = (covered / total) * 100
|
# percentage = (covered / total) * 100
|
||||||
printf "✅ Coverage: %.2f%%\n", percentage
|
# printf "✅ Coverage: %.2f%%\n", percentage
|
||||||
print ""
|
# print ""
|
||||||
if (percentage < 70) {
|
# if (percentage < 70) {
|
||||||
printf "❌ FAIL: Coverage is below 70%%\n"
|
# printf "❌ FAIL: Coverage is below 70%%\n"
|
||||||
exit 1
|
# exit 1
|
||||||
} else {
|
# } else {
|
||||||
printf "💚 Excellent: Coverage is above 70%%\n"
|
# printf "💚 Excellent: Coverage is above 70%%\n"
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
' coverage/lcov.info
|
# ' coverage/lcov.info
|
||||||
|
|
||||||
- name: Build apk
|
- name: Build apk
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Current Context
|
# Current Context
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||||
|
|
||||||
## Recent Activity
|
## Recent Activity
|
||||||
- Memory Bank initialization completed
|
- Memory Bank initialization completed
|
||||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
|||||||
- Architecture and technology stack catalogued
|
- Architecture and technology stack catalogued
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
Ready for development tasks. No active work in progress.
|
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||||
|
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||||
|
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||||
|
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||||
|
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||||
|
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||||
|
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||||
|
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
None documented at initialization.
|
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||||
|
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||||
|
связаны с fixtures/маппингом, не с видеоплеером.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
Awaiting user direction for:
|
Awaiting user direction for:
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||||
|
implementation 'com.google.firebase:firebase-messaging'
|
||||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||||
|
|||||||
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@@ -4,6 +4,12 @@
|
|||||||
-keep class org.bouncycastle.** { *; }
|
-keep class org.bouncycastle.** { *; }
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
|
|
||||||
|
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
|
||||||
|
-dontwarn com.hiennv.flutter_callkit_incoming.**
|
||||||
|
|
||||||
|
-keep class com.pravera.flutter_foreground_task.** { *; }
|
||||||
|
-dontwarn com.pravera.flutter_foreground_task.**
|
||||||
|
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-dontwarn io.flutter.**
|
-dontwarn io.flutter.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
@@ -13,6 +17,16 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<service
|
||||||
|
android:name=".EatvioFcmMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||||
|
tools:node="remove" />
|
||||||
<service
|
<service
|
||||||
android:name=".HmsPushService"
|
android:name=".HmsPushService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
@@ -33,7 +47,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:supportsPictureInPicture="true"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -77,6 +91,68 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Tap on notification body opens CallkitIncomingActivity via PendingIntent.
|
||||||
|
Replace it with a bridge that routes to the same accept flow as Answer. -->
|
||||||
|
<activity
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".EatvioCallkitIncomingBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:configChanges="orientation" />
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||||
|
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:configChanges="orientation"
|
||||||
|
android:permission="${applicationId}.PERMISSION_CALL">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".EatvioCallkitTransparentBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/TranslucentTheme"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||||
|
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/TranslucentTheme"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:launchMode="singleTop" />
|
||||||
|
<service
|
||||||
|
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||||
|
android:foregroundServiceType="microphone"
|
||||||
|
android:exported="false"
|
||||||
|
android:stopWithTask="true" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
@@ -88,5 +164,168 @@
|
|||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- SBP bank packages for СБП payment detection -->
|
||||||
|
<package android:name="ru.sberbankmobile" />
|
||||||
|
<package android:name="com.idamob.tinkoff.android" />
|
||||||
|
<package android:name="ru.vtb24.mobilebanking.android" />
|
||||||
|
<package android:name="ru.alfabank.mobile.android" />
|
||||||
|
<package android:name="ru.raiffeisennews" />
|
||||||
|
<package android:name="com.openbank" />
|
||||||
|
<package android:name="ru.gazprombank.android.mobilebank.app" />
|
||||||
|
<package android:name="com.logo.mbanking" />
|
||||||
|
<package android:name="ru.sovcomcard.halva.v1" />
|
||||||
|
<package android:name="ru.rosbank.android" />
|
||||||
|
<package android:name="ru.rshb.dbo" />
|
||||||
|
<package android:name="ru.mkb.mobile" />
|
||||||
|
<package android:name="ru.ucb.android" />
|
||||||
|
<package android:name="ru.unicredit.android" />
|
||||||
|
<package android:name="ru.skbbank.ib" />
|
||||||
|
<package android:name="ru.gebank.ib" />
|
||||||
|
<package android:name="ru.avangard.sbpent" />
|
||||||
|
<package android:name="ru.elplat.elplat2" />
|
||||||
|
<package android:name="com.bifit.rncbbeta" />
|
||||||
|
<package android:name="ru.ftc.faktura.expobank" />
|
||||||
|
<package android:name="com.bssys.vbrrretail" />
|
||||||
|
<package android:name="ru.wildberries.razz" />
|
||||||
|
<package android:name="ru.artsofte.russiafl" />
|
||||||
|
<package android:name="ru.ftc.faktura.multibank" />
|
||||||
|
<package android:name="ru.bank_hlynov.xbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.vesta" />
|
||||||
|
<package android:name="com.isimplelab.ibank.solidarnost" />
|
||||||
|
<package android:name="com.bssys.roscapretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.kbhmb" />
|
||||||
|
<package android:name="ru.mts.money" />
|
||||||
|
<package android:name="ru.ftc.faktura.pskb" />
|
||||||
|
<package android:name="ru.ftc.faktura.nskbl" />
|
||||||
|
<package android:name="ru.akbars.mobile" />
|
||||||
|
<package android:name="ru.ftc.faktura.rostfinance" />
|
||||||
|
<package android:name="ru.ilb.bbmobile" />
|
||||||
|
<package android:name="ru.mcbankrus.MCBankRus" />
|
||||||
|
<package android:name="ru.albank.online.aebit" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.icbru" />
|
||||||
|
<package android:name="ru.ftc.faktura.orbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.agropromkredit" />
|
||||||
|
<package android:name="com.energobank.digital" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.kbnk" />
|
||||||
|
<package android:name="ru.ftc.faktura.sdm" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.mbb" />
|
||||||
|
<package android:name="ru.ftc.faktura.absolutbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.akcept" />
|
||||||
|
<package android:name="ru.faktura.etbank.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.razvitiestolica" />
|
||||||
|
<package android:name="ru.ftc.faktura.resokreditbank" />
|
||||||
|
<package android:name="ru.yoo.money" />
|
||||||
|
<package android:name="ru.bspb" />
|
||||||
|
<package android:name="com.isimplelab.ibank.kubankredit" />
|
||||||
|
<package android:name="com.tochka.bank" />
|
||||||
|
<package android:name="com.bssys.novikomretail" />
|
||||||
|
<package android:name="ru.rosdorbank.physical" />
|
||||||
|
<package android:name="ru.ftc.faktura.primsoc" />
|
||||||
|
<package android:name="ru.ftc.faktura.banksaratov" />
|
||||||
|
<package android:name="ru.ftc.faktura.tavrich" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.thbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.kremlevskiy" />
|
||||||
|
<package android:name="ru.ftc.faktura.tkbbank" />
|
||||||
|
<package android:name="ru.chelyabinvestbank.investpay" />
|
||||||
|
<package android:name="ru.ftc.faktura.derzhava" />
|
||||||
|
<package android:name="ru.nbd.android" />
|
||||||
|
<package android:name="ru.sngb.dbo.client.android" />
|
||||||
|
<package android:name="ru.ftc.faktura.united" />
|
||||||
|
<package android:name="ru.ftc.faktura.venetsbank" />
|
||||||
|
<package android:name="ru.letobank.Prometheus" />
|
||||||
|
<package android:name="ru.simpls.brs2.mobbank" />
|
||||||
|
<package android:name="com.bifit.dvbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.intesabank" />
|
||||||
|
<package android:name="com.mmonline.mobile" />
|
||||||
|
<package android:name="cb.ibank.vuz" />
|
||||||
|
<package android:name="ru.uralprombank.mobilebanknew.googleplay" />
|
||||||
|
<package android:name="ru.ftc.faktura.nsbank" />
|
||||||
|
<package android:name="ru.emb.android" />
|
||||||
|
<package android:name="ru.ftc.faktura.maritimebank" />
|
||||||
|
<package android:name="com.credituralbank.CUBmobilenew" />
|
||||||
|
<package android:name="com.isimplelab.isimpleceo.uglemet" />
|
||||||
|
<package android:name="com.bssys.stroylesretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.iturup" />
|
||||||
|
<package android:name="ru.ftc.faktura.finbank" />
|
||||||
|
<package android:name="ru.faktura.gaztransbank.person" />
|
||||||
|
<package android:name="com.isimplelab.ibank.chelind" />
|
||||||
|
<package android:name="ru.ftc.faktura.nokss" />
|
||||||
|
<package android:name="ru.ftc.faktura.vlbb" />
|
||||||
|
<package android:name="ru.ftc.faktura.kbb" />
|
||||||
|
<package android:name="ru.faktura.avtograd.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.forshtadt" />
|
||||||
|
<package android:name="ru.ftc.faktura.solidbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.alexbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.akibank" />
|
||||||
|
<package android:name="ru.ftc.faktura.nipbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.alefbank" />
|
||||||
|
<package android:name="com.bifit.vfbank" />
|
||||||
|
<package android:name="com.bssys.uralfdretail" />
|
||||||
|
<package android:name="com.snb.online" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.okbank" />
|
||||||
|
<package android:name="com.tatsotsbank.dbomobile" />
|
||||||
|
<package android:name="ru.vtkbank.android" />
|
||||||
|
<package android:name="ru.faktura.jtbank.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.ns" />
|
||||||
|
<package android:name="ru.ftc.faktura.zemskybank" />
|
||||||
|
<package android:name="com.bssys.aversretail" />
|
||||||
|
<package android:name="ru.rusnarbank.correqts.retail" />
|
||||||
|
<package android:name="ru.ftc.faktura.baikalinvestbank" />
|
||||||
|
<package android:name="ru.sbi.android" />
|
||||||
|
<package android:name="com.metkombank.mobile" />
|
||||||
|
<package android:name="com.isimplelab.ionic.kazan.fl" />
|
||||||
|
<package android:name="com.intervale.sbpas" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.zarech" />
|
||||||
|
<package android:name="ru.centrinvest.mobilebanking2018" />
|
||||||
|
<package android:name="com.mifors.izhcombank" />
|
||||||
|
<package android:name="com.bifit.pmobile.isbank" />
|
||||||
|
<package android:name="ru.zenit.android" />
|
||||||
|
<package android:name="ru.briginvest.sense" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.cfb" />
|
||||||
|
<package android:name="com.isimplelab.ionic.gorbank.prod" />
|
||||||
|
<package android:name="ru.ftc.faktura.moscombank" />
|
||||||
|
<package android:name="com.timerbank.retail" />
|
||||||
|
<package android:name="com.mob.android" />
|
||||||
|
<package android:name="com.citibank.mobile.ru" />
|
||||||
|
<package android:name="com.bifit.atbbank" />
|
||||||
|
<package android:name="ru.mw" />
|
||||||
|
<package android:name="ru.bankuralsib.mb.android" />
|
||||||
|
<package android:name="com.banksoyuz.artsofte" />
|
||||||
|
<package android:name="ru.rubank.ubsmobile" />
|
||||||
|
<package android:name="cb.ibank" />
|
||||||
|
<package android:name="ru.ipb.ubsmobile" />
|
||||||
|
<package android:name="com.openwaygroup.ic.panda.poidem" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.tenderbank" />
|
||||||
|
<package android:name="ru.otpbank.mobile" />
|
||||||
|
<package android:name="ru.krk.ubsmobile" />
|
||||||
|
<package android:name="ru.homecredit.mycredit" />
|
||||||
|
<package android:name="cz.bsc.rc" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.belsocbank" />
|
||||||
|
<package android:name="com.hicebank.android" />
|
||||||
|
<package android:name="com.bpc.crossplatform_trading.bpc_trading" />
|
||||||
|
<package android:name="com.bifit.nrb" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.moskb" />
|
||||||
|
<package android:name="ru.isfront.android.kt" />
|
||||||
|
<package android:name="ru.ftc.faktura.novobank" />
|
||||||
|
<package android:name="com.smp.android" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.nkbank" />
|
||||||
|
<package android:name="com.isimplelab.ibank.toyota" />
|
||||||
|
<package android:name="com.bifit.mobile.private.bbr" />
|
||||||
|
<package android:name="ru.ftc.faktura.lanta" />
|
||||||
|
<package android:name="com.yandex.bank" />
|
||||||
|
<package android:name="ru.ftc.faktura.bgfbank" />
|
||||||
|
<package android:name="com.idamobile.android.LockoBank" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.MCBank" />
|
||||||
|
<package android:name="com.cetelem.cetelem_android" />
|
||||||
|
<package android:name="com.bssys.gutaretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.siab" />
|
||||||
|
<package android:name="com.isimplelab.ionic.round.prod" />
|
||||||
|
<package android:name="com.priobank.prio" />
|
||||||
|
<package android:name="com.inbank.mobilebank" />
|
||||||
|
<package android:name="com.isimplelab.isimplemobile.payjet" />
|
||||||
|
<package android:name="ru.ftc.faktura.agroros" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.invb" />
|
||||||
|
<package android:name="com.compassplus.mobicash.customer" />
|
||||||
|
<package android:name="ru.finambank.app" />
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,17 +20,23 @@ class HmsPushService : HmsMessageService() {
|
|||||||
|
|
||||||
if (message == null) return
|
if (message == null) return
|
||||||
|
|
||||||
// Берём action_url из data-поля, если оно есть.
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
val actionUrl = message.dataOfMap["action_url"]
|
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
|
if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
|
||||||
|
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionUrl = message.dataOfMap["action_url"]
|
||||||
showNotification(message, actionUrl)
|
showNotification(message, actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||||
val context = applicationContext
|
val context = applicationContext
|
||||||
|
|
||||||
// Интент для запуска Flutter-активити.
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
if (!actionUrl.isNullOrBlank()) {
|
if (!actionUrl.isNullOrBlank()) {
|
||||||
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
val channelId = "hms_push_channel"
|
val channelId = "hms_push_channel"
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, channelId)
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
||||||
.setContentText(message.notification?.body ?: "")
|
.setContentText(message.notification?.body ?: "")
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
|
|||||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Rational
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
import com.huawei.hms.aaid.HmsInstanceId
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -13,13 +18,68 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
var isInForeground: Boolean = false
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
|
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
|
||||||
|
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipChannel: MethodChannel? = null
|
||||||
|
private var videoCallPushChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipEnabled: Boolean = false
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isInForeground = true
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
isInForeground = false
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"dismissIncomingCalls" -> {
|
||||||
|
try {
|
||||||
|
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"drainPendingCallKitAccept" -> {
|
||||||
|
result.success(PendingCallKitAcceptBridge.drain())
|
||||||
|
}
|
||||||
|
"peekPendingCallKitAccept" -> {
|
||||||
|
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
videoCallPipChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"setPipEnabled" -> {
|
||||||
|
videoCallPipEnabled = call.arguments as? Boolean ?: false
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"getHuaweiToken" -> {
|
"getHuaweiToken" -> {
|
||||||
@@ -75,17 +135,67 @@ class MainActivity: FlutterActivity() {
|
|||||||
|
|
||||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
enterVideoCallPictureInPictureIfNeeded()
|
||||||
|
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun captureCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
|
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitAccept() {
|
||||||
|
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitAcceptToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitAcceptToFlutter() {
|
||||||
|
val payload = PendingCallKitAcceptBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Вариант 1: action_url лежит в extras.
|
// Вариант 1: action_url лежит в extras.
|
||||||
val fromExtras = intent.extras?.getString("action_url")
|
val fromExtras = intent.extras?.getString("action_url")
|
||||||
|
|
||||||
@@ -101,4 +211,19 @@ class MainActivity: FlutterActivity() {
|
|||||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enterVideoCallPictureInPictureIfNeeded() {
|
||||||
|
if (!videoCallPipEnabled) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
if (isInPictureInPictureMode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(Rational(2, 3))
|
||||||
|
.build()
|
||||||
|
enterPictureInPictureMode(params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
3
assets/icons/video_call/vc_control_enter.svg
Normal file
3
assets/icons/video_call/vc_control_enter.svg
Normal 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 |
@@ -47,8 +47,6 @@ post_install do |installer|
|
|||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
## dart: PermissionGroup.mediaLibrary
|
|
||||||
'PERMISSION_MEDIA_LIBRARY=1',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -397,7 +397,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -548,7 +548,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -1,30 +1,517 @@
|
|||||||
|
import AVFAudio
|
||||||
|
import CallKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import PushKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UserNotifications
|
||||||
import YandexLoginSDK
|
import YandexLoginSDK
|
||||||
|
import flutter_callkit_incoming
|
||||||
|
import flutter_foreground_task
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate {
|
||||||
|
private var voipRegistry: PKPushRegistry?
|
||||||
|
private var voipDiagnosticsChannel: FlutterMethodChannel?
|
||||||
|
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||||
|
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||||
|
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||||
|
private let appUrlScheme = "eatvio"
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
|
||||||
|
GeneratedPluginRegistrant.register(with: registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainQueue = DispatchQueue.main
|
||||||
|
voipRegistry = PKPushRegistry(queue: mainQueue)
|
||||||
|
voipRegistry?.delegate = self
|
||||||
|
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
|
||||||
|
|
||||||
|
if #available(iOS 10.0, *) {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
|
||||||
|
if let controller = window?.rootViewController as? FlutterViewController {
|
||||||
|
let channel = FlutterMethodChannel(
|
||||||
|
name: voipDiagnosticsChannelName,
|
||||||
|
binaryMessenger: controller.binaryMessenger
|
||||||
|
)
|
||||||
|
voipDiagnosticsChannel = channel
|
||||||
|
channel.setMethodCallHandler { [weak self] call, result in
|
||||||
|
guard let self else {
|
||||||
|
result(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch call.method {
|
||||||
|
case "drainVoipDiagnostics":
|
||||||
|
result(self.drainVoipDiagnostics())
|
||||||
|
case "drainPendingAcceptedVoipCall":
|
||||||
|
result(self.drainPendingAcceptedVoipCall())
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return launched
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 8.0, *)
|
@available(iOS 8.0, *)
|
||||||
func application(
|
override func application(
|
||||||
_ application: UIApplication, continue userActivity: NSUserActivity,
|
_ application: UIApplication,
|
||||||
restorationHandler: @escaping ([Any]?) -> Void
|
continue userActivity: NSUserActivity,
|
||||||
|
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
YXLSdk.shared.processUserActivity(userActivity)
|
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
return super.application(
|
||||||
|
application,
|
||||||
|
continue: userActivity,
|
||||||
|
restorationHandler: restorationHandler
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override public func application(
|
override public func application(
|
||||||
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
_ app: UIApplication,
|
||||||
|
open url: URL,
|
||||||
|
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
if url.scheme == appUrlScheme {
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
let queryItems = components?.queryItems ?? []
|
||||||
|
let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let callKitId = queryItems.first { $0.name == "callKitId" }?.value?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
recordVoipDiagnostic(
|
||||||
|
"accepted_call_url_opened",
|
||||||
|
data: [
|
||||||
|
"callKitId": callKitId ?? "",
|
||||||
|
"hasCallKitId": callKitId != nil,
|
||||||
|
"conversationUuid": conversationUuid,
|
||||||
|
"url": url.absoluteString,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty {
|
||||||
|
voipDiagnosticsChannel?.invokeMethod(
|
||||||
|
"acceptedVoipCall",
|
||||||
|
arguments: [
|
||||||
|
"id": callKitId,
|
||||||
|
"handle": conversationUuid,
|
||||||
|
"extra": [
|
||||||
|
"conversationUuid": conversationUuid
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return YXLSdk.shared.handleOpen(
|
return YXLSdk.shared.handleOpen(
|
||||||
url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String)
|
url,
|
||||||
|
sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,20 +31,22 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Нужен для звука в видеозвонках с консультантом</string>
|
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string></string>
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>voip</string>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
@@ -74,6 +76,57 @@
|
|||||||
<string>yandexauth</string>
|
<string>yandexauth</string>
|
||||||
<string>yandexauth2</string>
|
<string>yandexauth2</string>
|
||||||
<string>yandexauth4</string>
|
<string>yandexauth4</string>
|
||||||
|
<!-- SBP bank schemes (top ~50 by popularity, iOS 15+ caps at 50) -->
|
||||||
|
<string>bank100000000111</string>
|
||||||
|
<string>bank100000000004</string>
|
||||||
|
<string>bank100000000005</string>
|
||||||
|
<string>bank100000000008</string>
|
||||||
|
<string>bank100000000007</string>
|
||||||
|
<string>bank100000000015</string>
|
||||||
|
<string>bank100000000001</string>
|
||||||
|
<string>bank100000000010</string>
|
||||||
|
<string>bank100000000013</string>
|
||||||
|
<string>bank100000000012</string>
|
||||||
|
<string>bank100000000020</string>
|
||||||
|
<string>bank100000000025</string>
|
||||||
|
<string>bank100000000030</string>
|
||||||
|
<string>bank100000000030_new</string>
|
||||||
|
<string>bank100000000003</string>
|
||||||
|
<string>bank100000000043</string>
|
||||||
|
<string>bank100000000028</string>
|
||||||
|
<string>bank100000000086</string>
|
||||||
|
<string>bank100000000011</string>
|
||||||
|
<string>bank100000000044</string>
|
||||||
|
<string>bank100000000049</string>
|
||||||
|
<string>bank100000000500</string>
|
||||||
|
<string>bank100000000095</string>
|
||||||
|
<string>bank100000000900</string>
|
||||||
|
<string>bank100000000056</string>
|
||||||
|
<string>bank100000000053</string>
|
||||||
|
<string>bank100000000121</string>
|
||||||
|
<string>bank100000000082</string>
|
||||||
|
<string>bank100000000127</string>
|
||||||
|
<string>bank100000000017</string>
|
||||||
|
<string>bank100000000087</string>
|
||||||
|
<string>bank100000000052</string>
|
||||||
|
<string>bank100000000006</string>
|
||||||
|
<string>bank100000000098</string>
|
||||||
|
<string>bank100000000092</string>
|
||||||
|
<string>bank100000000229</string>
|
||||||
|
<string>bank100000000027</string>
|
||||||
|
<string>bank100000000080</string>
|
||||||
|
<string>bank100000000122</string>
|
||||||
|
<string>bank100000000124</string>
|
||||||
|
<string>bank100000000118</string>
|
||||||
|
<string>bank100000000159</string>
|
||||||
|
<string>bank100000000146</string>
|
||||||
|
<string>bank100000000069</string>
|
||||||
|
<string>bank100000000140</string>
|
||||||
|
<string>bank100000000047</string>
|
||||||
|
<string>bank100000000099</string>
|
||||||
|
<string>bank100000000135</string>
|
||||||
|
<string>bank100000000139</string>
|
||||||
|
<string>bank100000000022</string>
|
||||||
</array>
|
</array>
|
||||||
<key>YAClientId</key>
|
<key>YAClientId</key>
|
||||||
<string>5399b03636104c6d920b87c1ad329254</string>
|
<string>5399b03636104c6d920b87c1ad329254</string>
|
||||||
@@ -87,6 +140,14 @@
|
|||||||
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Eatvio</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>eatvio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -76,15 +76,23 @@ platform :ios do
|
|||||||
build_number: flutter_build_number
|
build_number: flutter_build_number
|
||||||
)
|
)
|
||||||
|
|
||||||
|
update_code_signing_settings(
|
||||||
|
use_automatic_signing: false,
|
||||||
|
path: "Runner.xcodeproj",
|
||||||
|
team_id: "VL68C36HYS",
|
||||||
|
targets: ["Runner"],
|
||||||
|
code_sign_identity: "Apple Distribution",
|
||||||
|
profile_name: "match AppStore com.eatvioapp.app 1781098260"
|
||||||
|
)
|
||||||
|
|
||||||
build_app(
|
build_app(
|
||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
scheme: "Runner",
|
scheme: "Runner",
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
codesigning_identity: "Apple Distribution",
|
# codesigning_identity: "Apple Distribution",
|
||||||
|
export_team_id: "VL68C36HYS"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app_store_connect_api_key(
|
app_store_connect_api_key(
|
||||||
key_id: "325JS9JCZH",
|
key_id: "325JS9JCZH",
|
||||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||||
@@ -93,6 +101,7 @@ platform :ios do
|
|||||||
in_house: false
|
in_house: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
upload_to_testflight(
|
upload_to_testflight(
|
||||||
skip_waiting_for_build_processing: true,
|
skip_waiting_for_build_processing: true,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_
|
|||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -35,6 +36,7 @@ class AppConfig {
|
|||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
|
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||||
|
|||||||
@@ -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/error_reporting/error_report_policy.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||||
import 'package:eatvio_app/firebase_options.dart';
|
import 'package:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
@@ -47,6 +48,7 @@ class AppInitializer {
|
|||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
VideoCallFcmHandler.registerBackgroundHandler();
|
||||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||||
try {
|
try {
|
||||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||||
|
|||||||
@@ -123,7 +123,21 @@ class AppRouter {
|
|||||||
return '${InfoByOrderScreen.path}?planId=$consultationId';
|
return '${InfoByOrderScreen.path}?planId=$consultationId';
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _resolveDeepLinkUri(Uri uri) {
|
/// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
|
||||||
|
static String? _resolveFinanceAlfaReturnDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 4 ||
|
||||||
|
effectiveSegments[1] != 'alfa' ||
|
||||||
|
effectiveSegments[2] != 'return') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final orderId = effectiveSegments[3];
|
||||||
|
if (orderId.isEmpty) return null;
|
||||||
|
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
|
||||||
|
return '${RouteConstants.chatScreen}?orderId=$orderId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает deep link URI в внутренний route target (или null, если host не наш).
|
||||||
|
static String? resolveDeepLinkTarget(Uri uri) {
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
final segments = uri.pathSegments;
|
final segments = uri.pathSegments;
|
||||||
|
|
||||||
@@ -189,6 +203,10 @@ class AppRouter {
|
|||||||
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
||||||
if (target != null) return target;
|
if (target != null) return target;
|
||||||
break;
|
break;
|
||||||
|
case 'finance':
|
||||||
|
final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
|
||||||
|
if (target != null) return target;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
|
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
|
||||||
@@ -196,7 +214,7 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||||
return _resolveDeepLinkUri(state.uri);
|
return resolveDeepLinkTarget(state.uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateByDeepLinkUrl(String url) {
|
static void navigateByDeepLinkUrl(String url) {
|
||||||
@@ -230,7 +248,7 @@ class AppRouter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final target = _resolveDeepLinkUri(uri);
|
final target = resolveDeepLinkTarget(uri);
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
appRouter.go(target);
|
appRouter.go(target);
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
|||||||
const newMessagesChannelName = 'private-new-chat-messages';
|
const newMessagesChannelName = 'private-new-chat-messages';
|
||||||
const newNotificationsChannelName = 'private-message-events';
|
const newNotificationsChannelName = 'private-message-events';
|
||||||
const directChatChannelName = 'private-direct-chat';
|
const directChatChannelName = 'private-direct-chat';
|
||||||
|
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||||
const thresholdForPagination = 150;
|
const thresholdForPagination = 150;
|
||||||
|
|||||||
16
lib/core/device/device_hw_id_provider.dart
Normal file
16
lib/core/device/device_hw_id_provider.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
|
|||||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||||
@@ -51,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
|
|||||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||||
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
@@ -71,6 +74,18 @@ class CubitRegistrations {
|
|||||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||||
|
sl.registerLazySingleton<VideoCallManager>(
|
||||||
|
() => VideoCallManager(
|
||||||
|
videoCallRepository: sl(),
|
||||||
|
profileRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||||
|
() => VideoCallSessionCubit(
|
||||||
|
videoCallManager: sl(),
|
||||||
|
systemPipCoordinator: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Регистрация Cubit'ов как фабрик
|
/// Регистрация Cubit'ов как фабрик
|
||||||
@@ -135,6 +150,9 @@ class CubitRegistrations {
|
|||||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
||||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||||
|
sl.registerFactory<ConsultationPaymentCubit>(
|
||||||
|
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
|
||||||
|
);
|
||||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
|
|||||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
||||||
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
||||||
@@ -74,6 +76,8 @@ class RepositoryRegistrations {
|
|||||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||||
|
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||||
|
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
|
||||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
|||||||
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
||||||
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
||||||
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
||||||
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
||||||
@@ -35,7 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
|
|||||||
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
||||||
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
||||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
/// Регистрация всех Service в системе внедрения зависимостей
|
/// Регистрация всех Service в системе внедрения зависимостей
|
||||||
@@ -75,6 +78,12 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||||
|
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||||
|
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||||
|
() => VideoCallSystemPipCoordinator(),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||||
@@ -82,6 +91,7 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||||
|
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class ErrorReportPolicy {
|
|||||||
case DioExceptionType.connectionTimeout:
|
case DioExceptionType.connectionTimeout:
|
||||||
case DioExceptionType.sendTimeout:
|
case DioExceptionType.sendTimeout:
|
||||||
case DioExceptionType.receiveTimeout:
|
case DioExceptionType.receiveTimeout:
|
||||||
|
case DioExceptionType.transformTimeout:
|
||||||
case DioExceptionType.connectionError:
|
case DioExceptionType.connectionError:
|
||||||
return true;
|
return true;
|
||||||
case DioExceptionType.unknown:
|
case DioExceptionType.unknown:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart';
|
|||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
final ErrorReporter _errorReporter;
|
final ErrorReporter _errorReporter;
|
||||||
@@ -87,6 +88,9 @@ class FirebaseApi {
|
|||||||
throw TimeoutException('registerPushToken timeout');
|
throw TimeoutException('registerPushToken timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _registerIosVoipPushToken();
|
||||||
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
@@ -95,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 {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -50,7 +57,37 @@ class PushNavigationHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||||
final data = message.data;
|
final data = Map<String, dynamic>.from(message.data);
|
||||||
|
|
||||||
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
|
if (endedPayload != null) {
|
||||||
|
developer.log(
|
||||||
|
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||||
|
name: 'PushNavigationHandler',
|
||||||
|
);
|
||||||
|
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final videoCallPayload = VideoCallPushPayload.tryParse(data);
|
||||||
|
if (videoCallPayload != null) {
|
||||||
|
developer.log(
|
||||||
|
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
|
||||||
|
name: 'PushNavigationHandler',
|
||||||
|
);
|
||||||
|
unawaited(
|
||||||
|
VideoCallNotificationNavigator.openIncomingCall(
|
||||||
|
conversationUuid: videoCallPayload.conversationUuid,
|
||||||
|
counterpartName: videoCallPayload.counterpartName,
|
||||||
|
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
|
||||||
|
localParticipantName: videoCallPayload.localParticipantName,
|
||||||
|
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
|
||||||
|
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final actionUrl = data['action_url'] as String?;
|
final actionUrl = data['action_url'] as String?;
|
||||||
if (actionUrl == null || actionUrl.isEmpty) {
|
if (actionUrl == null || actionUrl.isEmpty) {
|
||||||
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
|
|||||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||||
@@ -211,13 +212,23 @@ class OtherRoutes {
|
|||||||
name: 'chatScreen',
|
name: 'chatScreen',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
||||||
if (uuidFromQuery != null) {
|
final orderIdFromQuery = state.uri.queryParameters['orderId'];
|
||||||
return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false);
|
if (uuidFromQuery != null || orderIdFromQuery != null) {
|
||||||
|
return ChatScreen(
|
||||||
|
uuid: uuidFromQuery,
|
||||||
|
orderId: orderIdFromQuery,
|
||||||
|
sendInitialMessage: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
final uuid = data['uuid'] as String;
|
final uuid = data['uuid'] as String?;
|
||||||
|
final orderId = data['orderId'] as String?;
|
||||||
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
||||||
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
|
return ChatScreen(
|
||||||
|
uuid: uuid,
|
||||||
|
orderId: orderId,
|
||||||
|
sendInitialMessage: sendInitialMessage,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -234,7 +245,13 @@ class OtherRoutes {
|
|||||||
return VideoCallScreen(
|
return VideoCallScreen(
|
||||||
conversationUuid: extra['conversationUuid'] as String,
|
conversationUuid: extra['conversationUuid'] as String,
|
||||||
counterpartName: extra['counterpartName'] as String?,
|
counterpartName: extra['counterpartName'] as String?,
|
||||||
|
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
|
||||||
|
counterpartUserId: extra['counterpartUserId'] as int?,
|
||||||
|
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
|
||||||
|
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
|
||||||
|
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
|
||||||
localParticipantName: extra['localParticipantName'] as String?,
|
localParticipantName: extra['localParticipantName'] as String?,
|
||||||
|
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||||
onHangUp: extra['onHangUp'] as void Function()?,
|
onHangUp: extra['onHangUp'] as void Function()?,
|
||||||
);
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,48 @@ class CustomTextStyles {
|
|||||||
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
||||||
|
|
||||||
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 32 / 24,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 24 / 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 24 / 13,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 24 / 14,
|
||||||
|
color: appTheme.gray600,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.35,
|
||||||
|
color: appTheme.red,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on TextStyle {
|
extension on TextStyle {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ class WsManager {
|
|||||||
PrivateChannel? _privateNotificationChannel;
|
PrivateChannel? _privateNotificationChannel;
|
||||||
PrivateChannel? _privateMessagesChannel;
|
PrivateChannel? _privateMessagesChannel;
|
||||||
PrivateChannel? _privateDirectChatChannel;
|
PrivateChannel? _privateDirectChatChannel;
|
||||||
|
PresenceChannel? _presenceDirectChatChannel;
|
||||||
|
|
||||||
|
StreamSubscription? _presenceMemberAddedSubscription;
|
||||||
|
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||||
|
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||||
|
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||||
|
bool _presenceChannelEventsBound = false;
|
||||||
|
|
||||||
StreamSubscription? _lifecycleSubscription;
|
StreamSubscription? _lifecycleSubscription;
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
@@ -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() {
|
void _subscribe() {
|
||||||
_privateMessagesChannel?.subscribe();
|
_privateMessagesChannel?.subscribe();
|
||||||
_privateNotificationChannel?.subscribe();
|
_privateNotificationChannel?.subscribe();
|
||||||
@@ -193,6 +301,19 @@ class WsManager {
|
|||||||
_privateDirectChatChannel?.unsubscribe();
|
_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 {
|
Future<void> clear() async {
|
||||||
await client?.disconnect();
|
await client?.disconnect();
|
||||||
await _lifecycleSubscription?.cancel();
|
await _lifecycleSubscription?.cancel();
|
||||||
@@ -204,6 +325,7 @@ class WsManager {
|
|||||||
client = null;
|
client = null;
|
||||||
userId = null;
|
userId = null;
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
unsubscribePresenceDirectChatChannel();
|
||||||
_privateMessagesChannel?.unsubscribe();
|
_privateMessagesChannel?.unsubscribe();
|
||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
@@ -211,3 +333,13 @@ class WsManager {
|
|||||||
_privateNotificationChannel = null;
|
_privateNotificationChannel = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PresenceDirectChatListener {
|
||||||
|
const _PresenceDirectChatListener({
|
||||||
|
required this.counterpartUserId,
|
||||||
|
required this.onCounterpartOnlineChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int counterpartUserId;
|
||||||
|
final void Function(bool isOnline) onCounterpartOnlineChanged;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
int? replyId;
|
int? replyId;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
@@ -242,8 +250,29 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
Builder(
|
||||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
builder: (context) {
|
||||||
|
final post = blogPostState.blogPostModel;
|
||||||
|
if (post == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
return BlogPostMediaContent(
|
||||||
|
galleryImages: post.images,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||||
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
onOpenGallery: _openGallery,
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||||
@JsonKey(name: 'room_id') required String? roomId,
|
@JsonKey(name: 'room_id') required String? roomId,
|
||||||
@JsonKey(name: 'type') String? type,
|
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||||
}) = _ChatMessageModel;
|
}) = _ChatMessageModel;
|
||||||
@@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum MessageState { loading, sent, error }
|
enum MessageState { loading, sent, error }
|
||||||
|
|
||||||
|
enum ChatMessageType {
|
||||||
|
@JsonValue('text')
|
||||||
|
text,
|
||||||
|
@JsonValue('system')
|
||||||
|
system,
|
||||||
|
@JsonValue('attachment')
|
||||||
|
attachment,
|
||||||
|
@JsonValue('image')
|
||||||
|
image,
|
||||||
|
@JsonValue('plan_order')
|
||||||
|
planOrder,
|
||||||
|
@JsonValue('video_call_started')
|
||||||
|
videoCallStarted,
|
||||||
|
@JsonValue('video_call_ended')
|
||||||
|
videoCallEnded,
|
||||||
|
@JsonValue('unknown')
|
||||||
|
unknown;
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
|
|||||||
@JsonKey(name: "name") required String name,
|
@JsonKey(name: "name") required String name,
|
||||||
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
||||||
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
||||||
|
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||||
|
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||||
}) = _ChatUser;
|
}) = _ChatUser;
|
||||||
|
|
||||||
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
|
|||||||
required String name,
|
required String name,
|
||||||
required String slug,
|
required String slug,
|
||||||
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
||||||
|
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||||
|
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||||
}) = _ChatUserModel;
|
}) = _ChatUserModel;
|
||||||
|
|
||||||
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
||||||
|
|||||||
15
lib/features/chat/models/order_id_to_conversation_model.dart
Normal file
15
lib/features/chat/models/order_id_to_conversation_model.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'order_id_to_conversation_model.freezed.dart';
|
||||||
|
part 'order_id_to_conversation_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class OrderIdToConversationModel with _$OrderIdToConversationModel {
|
||||||
|
factory OrderIdToConversationModel({
|
||||||
|
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||||
|
@JsonKey(name: 'conversation_uuid') required String conversationUuid,
|
||||||
|
}) = _OrderIdToConversationModel;
|
||||||
|
|
||||||
|
factory OrderIdToConversationModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$OrderIdToConversationModelFromJson(json);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ part 'payment_model.g.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class PaymentModel with _$PaymentModel {
|
abstract class PaymentModel with _$PaymentModel {
|
||||||
|
|
||||||
factory PaymentModel({
|
factory PaymentModel({
|
||||||
@JsonKey(name: 'checkout_url') required String paymentUrl,
|
@JsonKey(name: 'checkout_url') required String paymentUrl,
|
||||||
|
@JsonKey(name: 'order_id') String? orderId,
|
||||||
|
@JsonKey(name: 'payment_provider') String? paymentProvider,
|
||||||
}) = _PaymentModel;
|
}) = _PaymentModel;
|
||||||
|
|
||||||
factory PaymentModel.fromJson(Map<String, dynamic> json) => _$PaymentModelFromJson(json);
|
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PaymentModelFromJson(json);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
|||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||||
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
||||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
late int lastPage;
|
late int lastPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
String? _activeChatUuid;
|
||||||
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
var uuid = const Uuid();
|
var uuid = const Uuid();
|
||||||
|
|
||||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||||
@@ -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 {
|
try {
|
||||||
emit(const ChatRoomState.loading());
|
emit(const ChatRoomState.loading());
|
||||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
|
||||||
ChosenPlanModel? chosenPlan;
|
String? resolvedUuid = uuid;
|
||||||
if (chatRoom.chosenPlanId != null) {
|
int? resolvedChosenPlanId;
|
||||||
chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
|
|
||||||
|
if (orderId != null && orderId.isNotEmpty) {
|
||||||
|
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
||||||
|
resolvedUuid = resolved.conversationUuid;
|
||||||
|
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||||
}
|
}
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage);
|
|
||||||
|
if (resolvedUuid == null || resolvedUuid.isEmpty) {
|
||||||
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
|
}
|
||||||
|
|
||||||
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
|
ChosenPlanModel? chosenPlan;
|
||||||
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
|
if (planId != null) {
|
||||||
|
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||||
|
}
|
||||||
|
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
||||||
lastPage = chatMessages.meta.lastPage;
|
lastPage = chatMessages.meta.lastPage;
|
||||||
await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id);
|
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||||
emit(
|
emit(
|
||||||
ChatRoomState.loaded(
|
ChatRoomState.loaded(
|
||||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||||
@@ -115,6 +139,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
await checkCards();
|
await checkCards();
|
||||||
}
|
}
|
||||||
|
return resolvedUuid;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
@@ -122,6 +147,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
messagesSubscription?.cancel();
|
messagesSubscription?.cancel();
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,8 +196,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||||
final client = wsManager.client;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
|
_activeChatUuid = uuid;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatId);
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
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 {
|
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||||
try {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
@@ -297,7 +437,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
if (planId == null) return null;
|
if (planId == null) return null;
|
||||||
final paymentModel = await chatRepository.getPaymentLink(id: planId);
|
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
||||||
return paymentModel.paymentUrl;
|
return paymentModel.paymentUrl;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -337,6 +477,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
if (_presenceOnlineListener != null) {
|
||||||
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
|
_presenceOnlineListener = null;
|
||||||
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
messagesSubscription?.cancel();
|
||||||
@@ -435,3 +579,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _ChatCounterpartField { sender, receiver }
|
||||||
|
|||||||
@@ -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/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/about_order_button.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.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/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/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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.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/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/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/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/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||||
@@ -36,10 +42,11 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:visibility_detector/visibility_detector.dart';
|
import 'package:visibility_detector/visibility_detector.dart';
|
||||||
|
|
||||||
class ChatScreen extends StatefulWidget {
|
class ChatScreen extends StatefulWidget {
|
||||||
final String uuid;
|
final String? uuid;
|
||||||
|
final String? orderId;
|
||||||
final bool? sendInitialMessage;
|
final bool? sendInitialMessage;
|
||||||
|
|
||||||
const ChatScreen({required this.uuid, super.key, required this.sendInitialMessage});
|
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatScreen> createState() => _ChatScreenState();
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
@@ -55,6 +62,11 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
|
|
||||||
|
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
||||||
|
String? _effectiveUuid;
|
||||||
|
|
||||||
|
String? get conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||||
|
|
||||||
void _onHide() {
|
void _onHide() {
|
||||||
chatRoomCubit.cancelTimer();
|
chatRoomCubit.cancelTimer();
|
||||||
}
|
}
|
||||||
@@ -64,26 +76,41 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
chatRoomCubit.setTimer();
|
chatRoomCubit.setTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _initChatRoom() async {
|
||||||
|
final uuid = await chatRoomCubit.initChat(
|
||||||
|
conversationUuid,
|
||||||
|
orderId: _effectiveUuid == null ? widget.orderId : null,
|
||||||
|
sendInitialMessage: widget.sendInitialMessage ?? false,
|
||||||
|
);
|
||||||
|
if (uuid != null && mounted) {
|
||||||
|
setState(() => _effectiveUuid = uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ChatScreen oldWidget) {
|
void didUpdateWidget(ChatScreen oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.uuid != widget.uuid) {
|
if (oldWidget.uuid != widget.uuid || oldWidget.orderId != widget.orderId) {
|
||||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
_effectiveUuid = widget.uuid;
|
||||||
|
_initChatRoom();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_effectiveUuid = widget.uuid;
|
||||||
scrollController.addListener(_onScroll);
|
scrollController.addListener(_onScroll);
|
||||||
chatRoomCubit.initChat(widget.uuid, sendInitialMessage: widget.sendInitialMessage ?? false);
|
_initChatRoom();
|
||||||
chatRoomCubit.setTimer();
|
chatRoomCubit.setTimer();
|
||||||
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
|
_listener = AppLifecycleListener(onHide: _onHide, onResume: _onResume);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
|
final uuid = conversationUuid;
|
||||||
|
if (uuid == null) return;
|
||||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||||
chatRoomCubit.loadNextPage(widget.uuid);
|
chatRoomCubit.loadNextPage(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +144,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: DataErrorWidget(onReload: () => chatRoomCubit.initChat(widget.uuid)),
|
body: DataErrorWidget(onReload: _initChatRoom),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -141,6 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||||
|
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||||
final chosenPlan = state.chatRoom.chosenPlan;
|
final chosenPlan = state.chatRoom.chosenPlan;
|
||||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||||
final showUserDetailsButton = [
|
final showUserDetailsButton = [
|
||||||
@@ -217,19 +245,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Column(
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)),
|
children: [
|
||||||
AnimatedCrossFade(
|
Text(
|
||||||
firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary),
|
state.chatRoom.users.receiver.name,
|
||||||
firstCurve: Curves.easeIn,
|
maxLines: 1,
|
||||||
secondCurve: Curves.easeOut,
|
overflow: TextOverflow.ellipsis,
|
||||||
secondChild: const SizedBox.shrink(),
|
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
),
|
||||||
duration: const Duration(milliseconds: 150),
|
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: [
|
actions: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: _ChatHeaderVideoCallButton(
|
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -283,7 +312,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
index + 1 == state.chatMessages.length) {
|
index + 1 == state.chatMessages.length) {
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == 'plan_order';
|
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
@@ -324,18 +353,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ChatBubbleWidget(
|
child: _buildMessageBubble(
|
||||||
isRead: message.isRead,
|
context: context,
|
||||||
sendTime: message.updatedAt,
|
state: state,
|
||||||
text: message.data.trim(),
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
mediaData: message.mediaData ?? [],
|
message: message,
|
||||||
messageState: message.messageState,
|
amIConsultant: amIConsultant,
|
||||||
messageUuid: message.uuid ?? '',
|
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
|
||||||
messageType: message.type,
|
|
||||||
isConsultant: amIConsultant,
|
|
||||||
urlsMediaInfo: message.urlsMediaInfo,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -343,7 +367,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||||
final message = state.chatMessages[index];
|
final message = state.chatMessages[index];
|
||||||
final isPlanOrderMessage = message.type == 'plan_order';
|
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||||
|
|
||||||
PacketOptions? messageOption;
|
PacketOptions? messageOption;
|
||||||
if (isPlanOrderMessage && chosenPlan != null) {
|
if (isPlanOrderMessage && chosenPlan != null) {
|
||||||
@@ -380,18 +404,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ChatBubbleWidget(
|
child: _buildMessageBubble(
|
||||||
isRead: message.isRead,
|
context: context,
|
||||||
sendTime: message.updatedAt,
|
state: state,
|
||||||
text: message.data.trim(),
|
chatCounterpart: chatCounterpart,
|
||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
mediaData: message.mediaData ?? [],
|
message: message,
|
||||||
messageState: message.messageState,
|
amIConsultant: amIConsultant,
|
||||||
messageUuid: message.uuid ?? '',
|
|
||||||
userSlug: state.chatRoom.users.receiver.slug,
|
|
||||||
messageType: message.type,
|
|
||||||
isConsultant: amIConsultant,
|
|
||||||
urlsMediaInfo: message.urlsMediaInfo,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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 {
|
class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||||
@@ -779,9 +844,7 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
child: Center(
|
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||||
child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
final MessageState messageState;
|
final MessageState messageState;
|
||||||
final String messageUuid;
|
final String messageUuid;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
final String? messageType;
|
final ChatMessageType? messageType;
|
||||||
final bool? isConsultant;
|
final bool? isConsultant;
|
||||||
final List<Map<String, dynamic>>? urlsMediaInfo;
|
final List<Map<String, dynamic>>? urlsMediaInfo;
|
||||||
@override
|
@override
|
||||||
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListTile(
|
return Material(
|
||||||
contentPadding: const EdgeInsets.all(4),
|
color: Colors.transparent,
|
||||||
horizontalTitleGap: 8,
|
child: ListTile(
|
||||||
onTap: () async {
|
contentPadding: const EdgeInsets.all(4),
|
||||||
if (mediaItem is File) {
|
horizontalTitleGap: 8,
|
||||||
// Для локальных файлов используем file:// URI
|
onTap: () async {
|
||||||
final fileUri = Uri.file(mediaItem.path);
|
if (mediaItem is File) {
|
||||||
if (await canLaunchUrl(fileUri)) {
|
// Для локальных файлов используем file:// URI
|
||||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
final fileUri = Uri.file(mediaItem.path);
|
||||||
|
if (await canLaunchUrl(fileUri)) {
|
||||||
|
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Для URL файлов используем обычный launchUrl
|
||||||
|
final urlString = mediaItem.toString();
|
||||||
|
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||||
|
? Uri.parse(urlString)
|
||||||
|
: Uri.parse('https://$urlString');
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
// Для URL файлов используем обычный launchUrl
|
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||||
final urlString = mediaItem.toString();
|
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
title: Text(
|
||||||
? Uri.parse(urlString)
|
fileName,
|
||||||
: Uri.parse('https://$urlString');
|
maxLines: 2,
|
||||||
if (await canLaunchUrl(uri)) {
|
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
),
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
|
||||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
|
||||||
title: Text(
|
|
||||||
fileName,
|
|
||||||
maxLines: 2,
|
|
||||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getDisplayText() {
|
String _getDisplayText() {
|
||||||
if (messageType == 'plan_order') {
|
if (messageType == ChatMessageType.planOrder) {
|
||||||
if (isConsultant == true) {
|
if (isConsultant == true) {
|
||||||
// Текст для консультанта
|
// Текст для консультанта
|
||||||
return '1. Уточните у подопечного его цели и пожелания\n'
|
return '1. Уточните у подопечного его цели и пожелания\n'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NotificationBannerState extends State<NotificationBanner> {
|
class _NotificationBannerState extends State<NotificationBanner> {
|
||||||
bool isLoading = false;
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final size = MediaQuery.sizeOf(context);
|
final size = MediaQuery.sizeOf(context);
|
||||||
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
|||||||
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
|
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
|
||||||
child: _StarIcon(),
|
child: _StarIcon(),
|
||||||
),
|
),
|
||||||
if (isLoading)
|
Padding(
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(left: 40, top: 24),
|
|
||||||
child: Loader(),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () {
|
||||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
setState(() {
|
if (planId != null) {
|
||||||
isLoading = true;
|
showConsultationPaymentFlow(context, planId: planId);
|
||||||
});
|
|
||||||
final paymentLink = await context.read<ChatRoomCubit>().getPaymentLink();
|
|
||||||
setState(() {
|
|
||||||
isLoading = false;
|
|
||||||
});
|
|
||||||
if (paymentLink != null && planId != null) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
GoRouter.of(context).pushReplacement(
|
|
||||||
PaymentScreen.path,
|
|
||||||
extra: {'url': paymentLink, 'planId': planId},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
|||||||
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||||
@@ -98,8 +99,19 @@ class ChatRepository {
|
|||||||
|
|
||||||
Future<PaymentModel> getPaymentLink({
|
Future<PaymentModel> getPaymentLink({
|
||||||
required int id,
|
required int id,
|
||||||
|
required String paymentProvider,
|
||||||
}) async {
|
}) async {
|
||||||
return await chatService.getPaymentLink(id: id);
|
return await chatService.getPaymentLink(
|
||||||
|
id: id,
|
||||||
|
body: {'payment_provider': paymentProvider},
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
|
required String orderId,
|
||||||
|
}) async {
|
||||||
|
return await chatService.resolveOrderIdToConversation(orderId: orderId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
|||||||
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
@@ -65,6 +66,12 @@ abstract class ChatService {
|
|||||||
@POST('/api/chosen_plans/{id}/start_payment_process')
|
@POST('/api/chosen_plans/{id}/start_payment_process')
|
||||||
Future<PaymentModel> getPaymentLink({
|
Future<PaymentModel> getPaymentLink({
|
||||||
@Path() required int id,
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||||
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
|
@Path() required String orderId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@GET('/api/chosen_plans/show/{id}')
|
@GET('/api/chosen_plans/show/{id}')
|
||||||
|
|||||||
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'feed_blog_post_model.freezed.dart';
|
part 'feed_blog_post_model.freezed.dart';
|
||||||
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
|||||||
@JsonKey(name: "total_views") required int totalViews,
|
@JsonKey(name: "total_views") required int totalViews,
|
||||||
@JsonKey(name: "total_comments") required int totalComments,
|
@JsonKey(name: "total_comments") required int totalComments,
|
||||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||||
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
|
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||||
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
}) = _FeedBlogPostModel;
|
}) = _FeedBlogPostModel;
|
||||||
|
|
||||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||||
|
|||||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
|
|
||||||
|
class PostFeedMapper {
|
||||||
|
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||||
|
return FeedBlogPostModel(
|
||||||
|
id: item.postId,
|
||||||
|
state: item.postState,
|
||||||
|
image: resolveMediaUrl(item.postImageUrl),
|
||||||
|
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||||
|
author: Author(
|
||||||
|
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||||
|
authorName: item.postAuthorName,
|
||||||
|
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||||
|
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||||
|
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||||
|
),
|
||||||
|
videoUrl: '',
|
||||||
|
createdAt: item.postCreatedAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
title: item.postTitle,
|
||||||
|
totalLikes: item.postLikesCount,
|
||||||
|
totalViews: item.postViewsCount,
|
||||||
|
totalComments: item.postCommentsCount,
|
||||||
|
wasLikedByMe: item.postIsLikedByUser,
|
||||||
|
images: item.images,
|
||||||
|
videoEmbedUrl: item.videoEmbedUrl,
|
||||||
|
videoUrlThumb: item.videoUrlThumb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolveMediaUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||||
|
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'post_feed_pagination_model.freezed.dart';
|
||||||
|
part 'post_feed_pagination_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||||
|
const factory PostFeedPaginationModel({
|
||||||
|
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||||
|
required CursorLinks links,
|
||||||
|
required CursorMeta meta,
|
||||||
|
}) = _PostFeedPaginationModel;
|
||||||
|
|
||||||
|
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||||
|
const factory PostFeedItemModel({
|
||||||
|
required int id,
|
||||||
|
@JsonKey(name: 'post_id') required int postId,
|
||||||
|
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||||
|
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||||
|
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||||
|
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||||
|
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||||
|
@JsonKey(name: 'post_title') required String postTitle,
|
||||||
|
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||||
|
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||||
|
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||||
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
|
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||||
|
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||||
|
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||||
|
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||||
|
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||||
|
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||||
|
@JsonKey(name: 'post_url') String? postUrl,
|
||||||
|
@JsonKey(name: 'post_state') required String postState,
|
||||||
|
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||||
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
|
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||||
|
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||||
|
}) = _PostFeedItemModel;
|
||||||
|
|
||||||
|
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||||
|
const factory FeedGalleryImage({
|
||||||
|
required String url,
|
||||||
|
@Default('horizontal') String orientation,
|
||||||
|
int? id,
|
||||||
|
int? position,
|
||||||
|
}) = _FeedGalleryImage;
|
||||||
|
|
||||||
|
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||||
|
const factory PostAuthorDetails({
|
||||||
|
required String slug,
|
||||||
|
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||||
|
}) = _PostAuthorDetails;
|
||||||
|
|
||||||
|
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CursorLinks with _$CursorLinks {
|
||||||
|
const factory CursorLinks({
|
||||||
|
required String? first,
|
||||||
|
required String? last,
|
||||||
|
required String? prev,
|
||||||
|
required String? next,
|
||||||
|
}) = _CursorLinks;
|
||||||
|
|
||||||
|
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CursorMeta with _$CursorMeta {
|
||||||
|
const factory CursorMeta({
|
||||||
|
required String path,
|
||||||
|
@JsonKey(name: 'per_page') required int perPage,
|
||||||
|
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||||
|
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||||
|
}) = _CursorMeta;
|
||||||
|
|
||||||
|
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
|||||||
|
|
||||||
class FeedCubit extends Cubit<FeedState> {
|
class FeedCubit extends Cubit<FeedState> {
|
||||||
final FeedRepository feedRepository;
|
final FeedRepository feedRepository;
|
||||||
int currentPage = 1;
|
String? _nextCursor;
|
||||||
late int lastPage;
|
bool _hasMore = true;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
String? _query;
|
String? _query;
|
||||||
|
|
||||||
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||||
try {
|
try {
|
||||||
_query = query;
|
_query = query;
|
||||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
_nextCursor = null;
|
||||||
lastPage = feedBlogPosts.meta.lastPage;
|
_hasMore = true;
|
||||||
|
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||||
|
_nextCursor = feed.nextCursor;
|
||||||
|
_hasMore = _nextCursor != null;
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadNextPage() async {
|
Future<void> loadNextPage({int? currentUserId}) async {
|
||||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||||
isLoading = true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isClosed) {
|
isLoading = true;
|
||||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentPage < lastPage && !isClosed) {
|
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||||
currentPage++;
|
_nextCursor = feed.nextCursor;
|
||||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
_hasMore = _nextCursor != null;
|
||||||
isLoading = false;
|
if (!isClosed && state is FeedStateLoaded) {
|
||||||
if (!isClosed) {
|
emit(
|
||||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
FeedState.loaded(
|
||||||
}
|
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||||
}
|
isLoading: false,
|
||||||
} catch (e, st) {
|
),
|
||||||
await reportFrom(e, st, this);
|
);
|
||||||
if (!isClosed) {
|
|
||||||
emit(FeedState.error(errorMessage: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
if (!isClosed && state is FeedStateLoaded) {
|
||||||
|
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||||
|
} else if (!isClosed) {
|
||||||
|
emit(FeedState.error(errorMessage: e.toString()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
final state = feedCubit.state;
|
final state = feedCubit.state;
|
||||||
// Загружаем данные, если они еще не загружены
|
// Загружаем данные, если они еще не загружены
|
||||||
if (state is! FeedStateLoaded) {
|
if (state is! FeedStateLoaded) {
|
||||||
feedCubit.loadFeedBlogPostList();
|
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||||
switch (_selectedChipIndex) {
|
switch (_selectedChipIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||||
|
query: query,
|
||||||
|
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 1 when !hidePlans:
|
case 1 when !hidePlans:
|
||||||
context.read<PlansCubit>().loadPlans(query: query);
|
context.read<PlansCubit>().loadPlans(query: query);
|
||||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||||
switch (_selectedChipIndex) {
|
switch (_selectedChipIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||||
|
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 1 when !hidePlans:
|
case 1 when !hidePlans:
|
||||||
context.read<PlansCubit>().loadPlans();
|
context.read<PlansCubit>().loadPlans();
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Gallery + optional embed video for feed card and blog post detail.
|
||||||
|
class BlogPostMediaContent extends StatefulWidget {
|
||||||
|
const BlogPostMediaContent({
|
||||||
|
super.key,
|
||||||
|
required this.galleryImages,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.videoEmbedUrl,
|
||||||
|
required this.currentGalleryIndex,
|
||||||
|
required this.onOpenGallery,
|
||||||
|
required this.onGalleryChanged,
|
||||||
|
required this.videoUrlThumb,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<FeedGalleryImage> galleryImages;
|
||||||
|
final String imageUrl;
|
||||||
|
final String? videoEmbedUrl;
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
final int currentGalleryIndex;
|
||||||
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
|
final ValueChanged<int> onGalleryChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||||
|
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||||
|
|
||||||
|
/// Landscape / video slide aspect (width / height).
|
||||||
|
static const _horizontalAspectRatio = 16 / 9;
|
||||||
|
|
||||||
|
/// Portrait slide aspect (width / height).
|
||||||
|
static const _verticalAspectRatio = 3 / 4;
|
||||||
|
|
||||||
|
bool _isCounterVisible = false;
|
||||||
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
|
bool get _hasVideo => widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
|
List<FeedGalleryImage> get _gallery {
|
||||||
|
return [
|
||||||
|
for (final image in widget.galleryImages)
|
||||||
|
if (PostFeedMapper.resolveMediaUrl(image.url) case final url when url.isNotEmpty)
|
||||||
|
FeedGalleryImage(
|
||||||
|
url: url,
|
||||||
|
orientation: image.orientation,
|
||||||
|
id: image.id,
|
||||||
|
position: image.position,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
|
||||||
|
|
||||||
|
int get _slideCount {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.length + (_hasVideo ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCurrentSlideVideo => _hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||||
|
|
||||||
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
|
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||||
|
String get _postOrientation {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.first.orientation;
|
||||||
|
}
|
||||||
|
return 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
double _heightForOrientation(String orientation, double width) {
|
||||||
|
final aspectRatio = orientation == 'vertical' ? _verticalAspectRatio : _horizontalAspectRatio;
|
||||||
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||||
|
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleCounterHideOnVideo() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCurrentSlideVideo) return;
|
||||||
|
|
||||||
|
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||||
|
if (mounted && _isCurrentSlideVideo) {
|
||||||
|
setState(() => _isCounterVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||||
|
if (notification is ScrollStartNotification || notification is ScrollUpdateNotification) {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCounterVisible) {
|
||||||
|
setState(() => _isCounterVisible = true);
|
||||||
|
}
|
||||||
|
} else if (notification is ScrollEndNotification && _isCounterVisible && _isCurrentSlideVideo) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: MediaQuery.sizeOf(context).width,
|
||||||
|
height: height,
|
||||||
|
imageUrl: itemUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _videoSlide() {
|
||||||
|
return FeedVideoPlayer(embedUrl: widget.videoEmbedUrl!, videoUrlThumb: widget.videoUrlThumb);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _carousel({required int itemCount, required NullableIndexedWidgetBuilder itemBuilder, required double height}) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleCarouselScroll,
|
||||||
|
child: CarouselSlider.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, itemIndex, _) => itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||||
|
options: CarouselOptions(
|
||||||
|
viewportFraction: 1,
|
||||||
|
height: height,
|
||||||
|
enableInfiniteScroll: false,
|
||||||
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (itemCount > 1)
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _shouldShowCounter ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _GalleryPhotoCounter(currentIndex: widget.currentGalleryIndex, total: itemCount),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final gallery = _gallery;
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||||
|
|
||||||
|
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||||
|
final itemCount = _slideCount;
|
||||||
|
return _carousel(
|
||||||
|
itemCount: itemCount,
|
||||||
|
height: mediaHeight,
|
||||||
|
itemBuilder: (context, itemIndex) {
|
||||||
|
if (itemIndex < gallery.length) {
|
||||||
|
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return _videoSlide();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gallery.length == 1) {
|
||||||
|
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasVideo) {
|
||||||
|
return SizedBox(
|
||||||
|
height: mediaHeight,
|
||||||
|
width: width,
|
||||||
|
child: _videoSlide(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.imageUrl.isNotEmpty) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
|
child: Hero(
|
||||||
|
tag: widget.imageUrl,
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
width: width,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryPhotoCounter extends StatelessWidget {
|
||||||
|
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||||
|
|
||||||
|
final int currentIndex;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(999)),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.photo_library_outlined, size: 14, color: Colors.white),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${currentIndex + 1} / $total',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, height: 1.2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
@@ -9,8 +8,10 @@ import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -19,9 +20,9 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class BlogPostWidget extends StatefulWidget {
|
class BlogPostWidget extends StatefulWidget {
|
||||||
final int index;
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
const BlogPostWidget({super.key, required this.index});
|
const BlogPostWidget({super.key, required this.post});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||||
@@ -30,243 +31,267 @@ class BlogPostWidget extends StatefulWidget {
|
|||||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||||
late bool isLiked;
|
late bool isLiked;
|
||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
bool isMoreOptionsVisible = false;
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
|
FeedBlogPostModel get post => widget.post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.post.id != post.id) {
|
||||||
|
currentGalleryIndex = 0;
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe || oldWidget.post.totalLikes != post.totalLikes) {
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleLike() async {
|
||||||
|
final nextLiked = !isLiked;
|
||||||
|
setState(() {
|
||||||
|
isLiked = nextLiked;
|
||||||
|
nextLiked ? totalLikes++ : totalLikes--;
|
||||||
|
});
|
||||||
|
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
|
||||||
|
if (nextLiked) {
|
||||||
|
await context.read<FeedCubit>().likePost(post.id);
|
||||||
|
} else {
|
||||||
|
await context.read<FeedCubit>().unlikedPost(post.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<FeedCubit, FeedState>(
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
builder: (BuildContext context, FeedState state) {
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
if (state is FeedStateLoaded) {
|
|
||||||
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
|
|
||||||
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
|
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
|
||||||
child: Material(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.white,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
|
|
||||||
},
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 20,
|
|
||||||
backgroundColor: Colors.black12,
|
|
||||||
foregroundImage: NetworkImage(
|
|
||||||
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
|
|
||||||
? state.feedBlogPostList[widget.index].author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
|
|
||||||
),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
// Имя и дата внутри Expanded
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.feedBlogPostList[widget.index].author.authorName,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Кнопка подписки (если нужно)
|
|
||||||
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
|
|
||||||
!state.feedBlogPostList[widget.index].author.authorIsMe)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
|
|
||||||
),
|
|
||||||
// Кнопка меню
|
|
||||||
CustomPopup(
|
|
||||||
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
|
|
||||||
complainedContentType: ComplainedContent.posts,
|
|
||||||
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
|
|
||||||
id: state.feedBlogPostList[widget.index].id,
|
|
||||||
feedBlogPostModel: state.feedBlogPostList[widget.index],
|
|
||||||
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
state.feedBlogPostList[widget.index].title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
String description = state.feedBlogPostList[widget.index].description;
|
|
||||||
final isLong = description.length > 200;
|
|
||||||
|
|
||||||
if (isLong) {
|
return InkWell(
|
||||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
borderRadius: BorderRadius.circular(12),
|
||||||
int cutPoint = 200;
|
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
child: Material(
|
||||||
// Проверяем, есть ли незакрытые markdown теги
|
borderRadius: BorderRadius.circular(12),
|
||||||
String preview = description.substring(0, cutPoint);
|
color: Colors.white,
|
||||||
|
child: Column(
|
||||||
// Считаем открывающие и закрывающие теги
|
mainAxisSize: MainAxisSize.min,
|
||||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
children: [
|
||||||
bool hasUnclosedBold = boldOpen > boldClose;
|
const SizedBox(height: 12),
|
||||||
|
_BlogPostHeader(post: post),
|
||||||
// Если есть незакрытый bold, ищем закрытие
|
_BlogPostTextContent(post: post),
|
||||||
if (hasUnclosedBold) {
|
BlogPostMediaContent(
|
||||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
galleryImages: post.images,
|
||||||
int boldClosePos = description.indexOf('**', cutPoint);
|
imageUrl: imageUrl,
|
||||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
cutPoint = boldClosePos + 2;
|
videoUrlThumb: post.videoUrlThumb,
|
||||||
} else {
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
// Если не нашли, обрезаем до последнего пробела
|
onOpenGallery: _openGallery,
|
||||||
cutPoint = description.lastIndexOf(' ', 200);
|
onGalleryChanged: (index) {
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
setState(() {
|
||||||
}
|
currentGalleryIndex = index;
|
||||||
} else {
|
});
|
||||||
// Обрезаем до последнего пробела для красоты
|
},
|
||||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
description = description.substring(0, cutPoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
return MarkdownHtmlWidget(
|
|
||||||
markdownText: description,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.feedBlogPostList[widget.index].description.length > 200)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
'Показать ещё',
|
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
context.pushTransparentRoute(
|
|
||||||
rootNavigator: true,
|
|
||||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Hero(
|
|
||||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FeedIconButton(
|
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
text: totalLikes.toString(),
|
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
onTap: () async {
|
|
||||||
setState(() {
|
|
||||||
isLiked = !isLiked;
|
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
|
||||||
});
|
|
||||||
// Update feed list immediately by ID (no reload)
|
|
||||||
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
|
|
||||||
// Fire network call without introducing UI delay
|
|
||||||
if (isLiked) {
|
|
||||||
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
|
|
||||||
} else {
|
|
||||||
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
'assets/icons/comment.svg',
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
text: state.feedBlogPostList[widget.index].totalComments.toString(),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
height: 15,
|
|
||||||
width: 12,
|
|
||||||
'assets/icons/repost.svg',
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
|
||||||
SharePlus.instance.share(
|
|
||||||
ShareParams(
|
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
|
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
state.feedBlogPostList[widget.index].totalViews.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 12),
|
||||||
}
|
_BlogPostActionsRow(post: post, isLiked: isLiked, totalLikes: totalLikes, onToggleLike: _toggleLike),
|
||||||
return const SizedBox.shrink();
|
const SizedBox(height: 8),
|
||||||
},
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BlogPostHeader extends StatelessWidget {
|
||||||
|
const _BlogPostHeader({required this.post});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
foregroundImage: post.author.authorProfileIcon.isNotEmpty ? NetworkImage(PostFeedMapper.resolveMediaUrl(post.author.authorProfileIcon)) : null,
|
||||||
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.author.authorName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!(post.author.isEntitySubscribedByMe ?? false) && !post.author.authorIsMe)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||||
|
),
|
||||||
|
CustomPopup(
|
||||||
|
isAuthor: post.author.authorIsMe,
|
||||||
|
complainedContentType: ComplainedContent.posts,
|
||||||
|
path: 'blog_post/${post.id}',
|
||||||
|
id: post.id,
|
||||||
|
feedBlogPostModel: post,
|
||||||
|
userSlug: post.author.authorSlug,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostTextContent extends StatelessWidget {
|
||||||
|
const _BlogPostTextContent({required this.post});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (post.title.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
post.title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
|
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
||||||
|
if (post.description.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: MarkdownHtmlWidget(markdownText: _truncateDescription(post.description)),
|
||||||
|
),
|
||||||
|
if (post.description.length > 200)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
'Показать ещё',
|
||||||
|
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostActionsRow extends StatelessWidget {
|
||||||
|
const _BlogPostActionsRow({required this.post, required this.isLiked, required this.totalLikes, required this.onToggleLike});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
final bool isLiked;
|
||||||
|
final int totalLikes;
|
||||||
|
final Future<void> Function() onToggleLike;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FeedIconButton(
|
||||||
|
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
|
text: totalLikes.toString(),
|
||||||
|
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
|
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||||
|
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
|
onTap: onToggleLike,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset('assets/icons/comment.svg', height: 20, width: 20, colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||||
|
text: post.totalComments.toString(),
|
||||||
|
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(height: 15, width: 12, 'assets/icons/repost.svg', colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn)),
|
||||||
|
onTap: () {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
||||||
|
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
post.totalViews.toString(),
|
||||||
|
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _truncateDescription(String description) {
|
||||||
|
final isLong = description.length > 200;
|
||||||
|
if (!isLong) return description;
|
||||||
|
|
||||||
|
var cutPoint = 200;
|
||||||
|
final preview = description.substring(0, cutPoint);
|
||||||
|
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||||
|
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||||
|
final hasUnclosedBold = boldOpen > boldClose;
|
||||||
|
|
||||||
|
if (hasUnclosedBold) {
|
||||||
|
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||||
|
final boldClosePos = description.indexOf('**', cutPoint);
|
||||||
|
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||||
|
cutPoint = boldClosePos + 2;
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', 200);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.substring(0, cutPoint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
|||||||
|
|
||||||
class BlogPosts extends StatelessWidget {
|
class BlogPosts extends StatelessWidget {
|
||||||
const BlogPosts({super.key, required this.handleVisibility});
|
const BlogPosts({super.key, required this.handleVisibility});
|
||||||
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
|
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||||
child: BlocBuilder<FeedCubit, FeedState>(
|
child: BlocBuilder<FeedCubit, FeedState>(
|
||||||
builder: (BuildContext context, FeedState state) {
|
builder: (BuildContext context, FeedState state) {
|
||||||
if (state is FeedStateLoaded) {
|
if (state is FeedStateLoaded) {
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
|
|||||||
if (index == state.feedBlogPostList.length) {
|
if (index == state.feedBlogPostList.length) {
|
||||||
return const Loader();
|
return const Loader();
|
||||||
}
|
}
|
||||||
|
final post = state.feedBlogPostList[index];
|
||||||
return VisibilityDetector(
|
return VisibilityDetector(
|
||||||
|
key: ValueKey(post.id),
|
||||||
onVisibilityChanged: (info) {
|
onVisibilityChanged: (info) {
|
||||||
final id = state.feedBlogPostList[index].id;
|
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
|
||||||
},
|
},
|
||||||
key: ValueKey(index),
|
child: BlogPostWidget(post: post),
|
||||||
child: BlogPostWidget(index: index),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (state is FeedStateError) {
|
} else if (state is FeedStateError) {
|
||||||
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
|
return DataErrorWidget(
|
||||||
|
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||||
|
|
||||||
|
enum FeedVideoEmbedProvider { vk, other }
|
||||||
|
|
||||||
|
/// Сборка URL и HTML для embed-плеера ленты.
|
||||||
|
abstract final class FeedVideoEmbed {
|
||||||
|
static FeedVideoEmbedProvider detectProvider(Uri uri) {
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
final isVk = host == 'vk.com' ||
|
||||||
|
host.endsWith('.vk.com') ||
|
||||||
|
host == 'vk.ru' ||
|
||||||
|
host.endsWith('.vk.ru') ||
|
||||||
|
host == 'vkvideo.ru' ||
|
||||||
|
host.endsWith('.vkvideo.ru');
|
||||||
|
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
|
||||||
|
static Uri uriForPlayback(String url) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
final params = Map<String, String>.from(uri.queryParameters);
|
||||||
|
params['autoplay'] = '1';
|
||||||
|
params['mute'] = '1';
|
||||||
|
params.putIfAbsent('playsinline', () => '1');
|
||||||
|
|
||||||
|
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
|
||||||
|
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
|
||||||
|
params['js_api'] = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.replace(queryParameters: params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildHtml(String embedUrl) {
|
||||||
|
final uri = Uri.parse(embedUrl);
|
||||||
|
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
|
||||||
|
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
|
||||||
|
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
|
||||||
|
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
|
||||||
|
|
||||||
|
return '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||||
|
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||||
|
</style>
|
||||||
|
$headScripts
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<iframe
|
||||||
|
id="player"
|
||||||
|
src="$src"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||||
|
allowfullscreen
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
></iframe>
|
||||||
|
<script>
|
||||||
|
$bootstrap
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/// JS-фрагменты для embed-плеера ленты.
|
||||||
|
///
|
||||||
|
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
|
||||||
|
abstract final class FeedVideoEmbedScripts {
|
||||||
|
static const readyChannel = 'EmbedReady';
|
||||||
|
|
||||||
|
/// Уведомляет Flutter, что embed готов к показу.
|
||||||
|
static String get notifyReady =>
|
||||||
|
'''
|
||||||
|
function notifyReady() {
|
||||||
|
try {
|
||||||
|
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Уведомляет Flutter об ошибке воспроизведения.
|
||||||
|
static String get notifyError =>
|
||||||
|
'''
|
||||||
|
function notifyError() {
|
||||||
|
try {
|
||||||
|
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Rutube: готовность через postMessage `player:canPlay`.
|
||||||
|
static String get rutubeCanPlayListener => '''
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
var type = JSON.parse(event.data).type;
|
||||||
|
if (type === 'player:canPlay') {
|
||||||
|
notifyReady();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// VK Video JS API: воспроизведение началось на событии `started`.
|
||||||
|
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
|
||||||
|
static String get vkPlayerReadyHandler => '''
|
||||||
|
try {
|
||||||
|
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
|
||||||
|
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
|
||||||
|
vkPlayer.on('started', function () {
|
||||||
|
notifyReady();
|
||||||
|
});
|
||||||
|
vkPlayer.on('error', function () {
|
||||||
|
notifyError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Внешний скрипт VK VideoPlayer API.
|
||||||
|
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
|
||||||
|
|
||||||
|
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
|
||||||
|
static String bootstrap({required bool includeVk}) {
|
||||||
|
final vkPart = includeVk ? vkPlayerReadyHandler : '';
|
||||||
|
return '''
|
||||||
|
(function () {
|
||||||
|
$notifyReady
|
||||||
|
$notifyError
|
||||||
|
$rutubeCanPlayListener
|
||||||
|
$vkPart
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
|
||||||
|
/// Embed-видео в ленте.
|
||||||
|
///
|
||||||
|
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||||
|
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||||
|
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||||
|
///
|
||||||
|
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||||
|
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
|
||||||
|
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||||
|
class FeedVideoPlayer extends StatefulWidget {
|
||||||
|
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||||
|
|
||||||
|
final String embedUrl;
|
||||||
|
|
||||||
|
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||||
|
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||||
|
|
||||||
|
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||||
|
InAppWebViewInitialData? _initialData;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isReady = false;
|
||||||
|
bool _hasError = false;
|
||||||
|
|
||||||
|
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
|
||||||
|
Timer? _readyFallback;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reset() {
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
_initialData = null;
|
||||||
|
_isLoading = false;
|
||||||
|
_isReady = false;
|
||||||
|
_hasError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markReady() {
|
||||||
|
if (!mounted || _isReady || _hasError) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
setState(() {
|
||||||
|
_isReady = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markError() {
|
||||||
|
if (!mounted || _isReady) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
setState(() {
|
||||||
|
_hasError = true;
|
||||||
|
_isLoading = false;
|
||||||
|
_initialData = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPlayPressed() async {
|
||||||
|
if (_isLoading || _isReady || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasError = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
|
||||||
|
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_initialData = InAppWebViewInitialData(
|
||||||
|
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
|
||||||
|
mimeType: 'text/html',
|
||||||
|
encoding: 'utf8',
|
||||||
|
baseUrl: WebUri(baseUrl),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||||
|
}
|
||||||
|
_markError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||||
|
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||||
|
mediaPlaybackRequiresUserGesture: false,
|
||||||
|
allowsInlineMediaPlayback: true,
|
||||||
|
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||||
|
iframeAllowFullscreen: true,
|
||||||
|
transparentBackground: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _onWebViewCreated(InAppWebViewController controller) {
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: FeedVideoEmbedScripts.readyChannel,
|
||||||
|
callback: (args) {
|
||||||
|
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
|
||||||
|
if (status == 'error') {
|
||||||
|
_markError();
|
||||||
|
} else {
|
||||||
|
_markReady();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||||
|
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
|
||||||
|
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
|
||||||
|
if (_isReady || _hasError || !mounted) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||||
|
if (mounted && !_isReady && !_hasError) _markReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (request.isForMainFrame != true) return;
|
||||||
|
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||||
|
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||||
|
if (_isReady) return;
|
||||||
|
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||||
|
_markError();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEnterFullscreen(InAppWebViewController _) {
|
||||||
|
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onExitFullscreen(InAppWebViewController _) {
|
||||||
|
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||||
|
SystemChrome.setPreferredOrientations([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final player = ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||||
|
if (_initialData != null)
|
||||||
|
Opacity(
|
||||||
|
opacity: _isReady ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isReady,
|
||||||
|
child: InAppWebView(
|
||||||
|
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||||
|
initialData: _initialData,
|
||||||
|
initialSettings: _settings,
|
||||||
|
onWebViewCreated: _onWebViewCreated,
|
||||||
|
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||||
|
onLoadStop: _onLoadStop,
|
||||||
|
onReceivedError: _onReceivedError,
|
||||||
|
onEnterFullscreen: _onEnterFullscreen,
|
||||||
|
onExitFullscreen: _onExitFullscreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isReady)
|
||||||
|
FeedVideoPreviewPlaceholder(
|
||||||
|
previewUrl: widget.videoUrlThumb,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
hasError: _hasError,
|
||||||
|
onPlay: _onPlayPressed,
|
||||||
|
onRetry: _onPlayPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
|
||||||
|
if (_isReady) return player;
|
||||||
|
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FeedVideoPreviewPlaceholder extends StatelessWidget {
|
||||||
|
const FeedVideoPreviewPlaceholder({
|
||||||
|
super.key,
|
||||||
|
required this.previewUrl,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.hasError,
|
||||||
|
required this.onPlay,
|
||||||
|
required this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? previewUrl;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool hasError;
|
||||||
|
final VoidCallback onPlay;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
|
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: previewUrl!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||||
|
if (hasError)
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||||
|
else
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onPlay,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
|
||||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||||
@@ -13,9 +13,12 @@ class FeedRepository {
|
|||||||
|
|
||||||
FeedRepository({required this.feedService});
|
FeedRepository({required this.feedService});
|
||||||
|
|
||||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||||
return (await feedService.getBlogPostList(page: page, query: query));
|
final response = await feedService.getFeed(search: search, cursor: cursor);
|
||||||
|
return (
|
||||||
|
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
|
||||||
|
nextCursor: response.meta.nextCursor,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
|||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
@@ -18,7 +19,13 @@ abstract class FeedService {
|
|||||||
@GET('/api/blogPosts/index')
|
@GET('/api/blogPosts/index')
|
||||||
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
||||||
|
|
||||||
@GET('/api/blogPosts/show/{postId}')
|
@GET('/api/feed')
|
||||||
|
Future<PostFeedPaginationModel> getFeed({
|
||||||
|
@Query('search') String? search,
|
||||||
|
@Query('cursor') String? cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/v2/blogPosts/show/{postId}')
|
||||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||||
|
|
||||||
@GET('/api/recipes/index')
|
@GET('/api/recipes/index')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:eatvio_app/core/const.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/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:eatvio_app/core/utils/image_constant.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/consultant_finances_screen.dart';
|
||||||
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_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/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/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_diets/presentation/my_diets_screen.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/presentation/my_plans_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) {
|
} catch (e, s) {
|
||||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||||
} finally {
|
} 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 sl<WsManager>().clear();
|
||||||
await TokenStorage.removeTokenInStorage();
|
await TokenStorage.removeTokenInStorage();
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class OrderInfoCubit extends Cubit<OrderInfoState> {
|
|||||||
try {
|
try {
|
||||||
final plan = state.chosenPlan;
|
final plan = state.chosenPlan;
|
||||||
if (plan == null) return null;
|
if (plan == null) return null;
|
||||||
final paymentModel = await chatRepository.getPaymentLink(id: plan.id);
|
final paymentModel = await chatRepository.getPaymentLink(id: plan.id, paymentProvider: 'alfa-bank');
|
||||||
return paymentModel.paymentUrl;
|
return paymentModel.paymentUrl;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:sbp/models/c2bmembers_model.dart';
|
||||||
|
|
||||||
|
/// Cubit оплаты консультации через СБП: старт оплаты, список банков, редирект в банк.
|
||||||
|
///
|
||||||
|
/// Не хранит [planId] как поле — он передаётся в [startPayment].
|
||||||
|
/// Polling статуса оплаты остаётся в ChatRoomCubit.
|
||||||
|
class ConsultationPaymentCubit extends Cubit<ConsultationPaymentState> {
|
||||||
|
final PaymentRepository paymentRepository;
|
||||||
|
final SbpRepository sbpRepository;
|
||||||
|
|
||||||
|
ConsultationPaymentCubit({
|
||||||
|
required this.paymentRepository,
|
||||||
|
required this.sbpRepository,
|
||||||
|
}) : super(ConsultationPaymentState());
|
||||||
|
|
||||||
|
/// Запускает оплату для [planId], сохраняет checkout_url и загружает банки СБП.
|
||||||
|
Future<void> startPayment({required int planId}) async {
|
||||||
|
try {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isLoading: true,
|
||||||
|
errorMessage: null,
|
||||||
|
banks: const [],
|
||||||
|
checkoutUrl: null,
|
||||||
|
orderId: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final payment = await paymentRepository.startPaymentProcess(planId: planId);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
checkoutUrl: payment.paymentUrl,
|
||||||
|
orderId: payment.orderId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final banks = await sbpRepository.getInstalledBanks();
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
banks: banks,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
banks: const [],
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
if (isClosed) return;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isLoading: false,
|
||||||
|
banks: const [],
|
||||||
|
checkoutUrl: null,
|
||||||
|
orderId: null,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает банковское приложение [bank] с текущим [ConsultationPaymentState.checkoutUrl].
|
||||||
|
Future<bool> openBank({required C2bmemberModel bank}) async {
|
||||||
|
final checkoutUrl = state.checkoutUrl;
|
||||||
|
if (checkoutUrl == null || checkoutUrl.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await sbpRepository.openBank(url: checkoutUrl, bank: bank);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:sbp/models/c2bmembers_model.dart';
|
||||||
|
|
||||||
|
part 'consultation_payment_state.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ConsultationPaymentState with _$ConsultationPaymentState {
|
||||||
|
factory ConsultationPaymentState({
|
||||||
|
@Default(false) bool isLoading,
|
||||||
|
@Default([]) List<C2bmemberModel> banks,
|
||||||
|
String? checkoutUrl,
|
||||||
|
String? orderId,
|
||||||
|
String? errorMessage,
|
||||||
|
}) = _ConsultationPaymentState;
|
||||||
|
}
|
||||||
49
lib/features/payment/presentation/payment_flow.dart
Normal file
49
lib/features/payment/presentation/payment_flow.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Оркестрирует флоу оплаты консультации:
|
||||||
|
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
|
||||||
|
Future<void> showConsultationPaymentFlow(
|
||||||
|
BuildContext context, {
|
||||||
|
required int planId,
|
||||||
|
Future<bool?> Function(BuildContext)? showGate,
|
||||||
|
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
|
||||||
|
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||||
|
}) async {
|
||||||
|
final gate = showGate ?? _showGate;
|
||||||
|
final form = showForm ?? _showForm;
|
||||||
|
final sbp = showSbp ?? _showSbp;
|
||||||
|
final s = S.of(context);
|
||||||
|
|
||||||
|
final gateChoice = await gate(context);
|
||||||
|
if (gateChoice == null || !context.mounted) return;
|
||||||
|
|
||||||
|
if (gateChoice) {
|
||||||
|
final formResult = await form(
|
||||||
|
context,
|
||||||
|
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
|
||||||
|
);
|
||||||
|
if (formResult == null || !context.mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await sbp(context, planId: planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _showGate(BuildContext context) {
|
||||||
|
return showPromoCodeGateBottomSheet(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoCodeResult?> _showForm(
|
||||||
|
BuildContext context, {
|
||||||
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
}) {
|
||||||
|
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||||
|
return showSbpBanksBottomSheet(context, planId: planId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Показывает bottom sheet «У вас есть промокод?» с кнопками Да/Нет.
|
||||||
|
///
|
||||||
|
/// Возвращает `true` если пользователь нажал «Да», `false` если «Нет»,
|
||||||
|
/// `null` если закрыл шторку (бекдроп/крестик).
|
||||||
|
Future<bool?> showPromoCodeGateBottomSheet(BuildContext context) {
|
||||||
|
return showModalBottomSheet<bool?>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
barrierColor: appTheme.transparentBlack,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (_) => Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: _PromoCodeGateSheet(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Виджет-гейт выбора промокода.
|
||||||
|
class PromoCodeGateBottomSheet extends StatelessWidget {
|
||||||
|
const PromoCodeGateBottomSheet({super.key, required this.onResult});
|
||||||
|
|
||||||
|
final ValueChanged<bool?> onResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Center(child: BottomSheetHandlerBar()),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
s.have_promo_code,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => onResult(true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.yes,
|
||||||
|
style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: () => onResult(false),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: appTheme.blueA400,
|
||||||
|
side: BorderSide(color: appTheme.blueA400),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
s.no,
|
||||||
|
style: TextStyles.textMSemibold.copyWith(color: appTheme.blueA400),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Внутренняя реализация модалки — обёртка над [PromoCodeGateBottomSheet].
|
||||||
|
class _PromoCodeGateSheet extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PromoCodeGateBottomSheet(
|
||||||
|
onResult: (result) => Navigator.of(context).pop(result),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
281
lib/features/payment/presentation/widgets/promo_code_modal.dart
Normal file
281
lib/features/payment/presentation/widgets/promo_code_modal.dart
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Результат работы модалки промокода.
|
||||||
|
class PromoCodeResult {
|
||||||
|
const PromoCodeResult({this.code});
|
||||||
|
|
||||||
|
/// Применённый промокод или null, если пользователь продолжил без него.
|
||||||
|
final String? code;
|
||||||
|
|
||||||
|
bool get hasPromoCode => code != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ответ проверки промокода.
|
||||||
|
class PromoValidation {
|
||||||
|
const PromoValidation.success() : error = null;
|
||||||
|
const PromoValidation.failure(this.error);
|
||||||
|
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
bool get isValid => error == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Модальное окно ввода промокода.
|
||||||
|
class ItvioPromoCodeModal extends StatefulWidget {
|
||||||
|
const ItvioPromoCodeModal({super.key, this.onValidate});
|
||||||
|
|
||||||
|
final Future<PromoValidation> Function(String code)? onValidate;
|
||||||
|
|
||||||
|
static Future<PromoCodeResult?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
Future<PromoValidation> Function(String code)? onValidate,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<PromoCodeResult>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ModalState { input, loading }
|
||||||
|
|
||||||
|
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||||
|
final _controller = TextEditingController();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
|
|
||||||
|
_ModalState _state = _ModalState.input;
|
||||||
|
String? _errorText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _apply() async {
|
||||||
|
final code = _controller.text.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (code.isEmpty) {
|
||||||
|
setState(() => _errorText = S.of(context).enter_promo_code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.loading;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
PromoValidation validation;
|
||||||
|
try {
|
||||||
|
validation = widget.onValidate != null
|
||||||
|
? await widget.onValidate!(code)
|
||||||
|
: const PromoValidation.success();
|
||||||
|
} catch (_) {
|
||||||
|
validation = const PromoValidation.failure(
|
||||||
|
'Не удалось проверить промокод. Попробуйте позже',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.input;
|
||||||
|
_errorText = validation.error;
|
||||||
|
});
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop(PromoCodeResult(code: code));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSkip() {
|
||||||
|
Navigator.of(context).pop(const PromoCodeResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isLoading => _state == _ModalState.loading;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
final s = S.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: bottomInset),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Center(child: BottomSheetHandlerBar()),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildHeader(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'Промокод',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFF6B7684),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildField(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildHint(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildApplyButton(s),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildSkipButton(s),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
final s = S.of(context);
|
||||||
|
return Text(
|
||||||
|
s.have_promo_code_form,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildField() {
|
||||||
|
final hasError = _errorText != null;
|
||||||
|
final s = S.of(context);
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.gray100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
width: 2,
|
||||||
|
color: hasError
|
||||||
|
? appTheme.red
|
||||||
|
: (_focusNode.hasFocus
|
||||||
|
? appTheme.blueA400
|
||||||
|
: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
enabled: !_isLoading,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_errorText != null) {
|
||||||
|
setState(() => _errorText = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSubmitted: (_) => _apply(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: s.enter_promo_code,
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
color: Color(0xFF9AA3AF),
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFF24303F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHint() {
|
||||||
|
final String text;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
if (_errorText != null) {
|
||||||
|
text = _errorText!;
|
||||||
|
color = appTheme.red;
|
||||||
|
} else {
|
||||||
|
text = '';
|
||||||
|
color = Colors.transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 19,
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(fontSize: 13, color: color),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildApplyButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _apply,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(s.apply),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSkipButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _isLoading ? null : _onSkip,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: appTheme.blueA400,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
child: Text(s.skip),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_state.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:sbp/models/c2bmembers_model.dart';
|
||||||
|
|
||||||
|
/// Показывает bottom sheet выбора банка СБП для оплаты консультации.
|
||||||
|
Future<void> showSbpBanksBottomSheet(BuildContext context, {required int planId}) {
|
||||||
|
final height = MediaQuery.sizeOf(context).height * 0.55;
|
||||||
|
return showBottomSheetWithBlur(
|
||||||
|
context: context,
|
||||||
|
height: height,
|
||||||
|
child: SbpBanksBottomSheet(planId: planId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bottom sheet со списком установленных банков СБП.
|
||||||
|
///
|
||||||
|
/// При открытии вызывает [ConsultationPaymentCubit.startPayment] с [planId]
|
||||||
|
/// и показывает [Loader], пока загружаются checkout_url и список банков.
|
||||||
|
class SbpBanksBottomSheet extends StatefulWidget {
|
||||||
|
const SbpBanksBottomSheet({super.key, required this.planId, this.cubit});
|
||||||
|
|
||||||
|
final int planId;
|
||||||
|
final ConsultationPaymentCubit? cubit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SbpBanksBottomSheet> createState() => _SbpBanksBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SbpBanksBottomSheetState extends State<SbpBanksBottomSheet> {
|
||||||
|
late final ConsultationPaymentCubit _cubit;
|
||||||
|
late final bool _ownsCubit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ownsCubit = widget.cubit == null;
|
||||||
|
_cubit = widget.cubit ?? sl<ConsultationPaymentCubit>();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_cubit.startPayment(planId: widget.planId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_ownsCubit) {
|
||||||
|
_cubit.close();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return BlocProvider<ConsultationPaymentCubit>.value(
|
||||||
|
value: _cubit,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const BottomSheetHandlerBar(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
s.choose_bank_for_payment,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<ConsultationPaymentCubit, ConsultationPaymentState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
return const Loader();
|
||||||
|
}
|
||||||
|
if (state.errorMessage != null) {
|
||||||
|
return _ErrorContent(
|
||||||
|
message: s.sbp_payment_error,
|
||||||
|
onOk: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.banks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
s.no_installed_banks,
|
||||||
|
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: state.banks.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final bank = state.banks[index];
|
||||||
|
return _BankTile(bank: bank, onTap: () => _onBankTap(bank));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onBankTap(C2bmemberModel bank) async {
|
||||||
|
final opened = await _cubit.openBank(bank: bank);
|
||||||
|
if (opened && mounted) {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
navigator.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankTile extends StatelessWidget {
|
||||||
|
const _BankTile({required this.bank, required this.onTap});
|
||||||
|
|
||||||
|
final C2bmemberModel bank;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = bank.localName ?? bank.bankName;
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_BankIcon(bank: bank),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(name, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BankIcon extends StatelessWidget {
|
||||||
|
const _BankIcon({required this.bank});
|
||||||
|
|
||||||
|
final C2bmemberModel bank;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (bank.icon.isNotEmpty) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: bank.logoURL,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
placeholder: (context, url) => _placeholder(),
|
||||||
|
errorWidget: (context, url, error) => _placeholder(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _placeholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _placeholder() {
|
||||||
|
return Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8)),
|
||||||
|
child: Icon(Icons.account_balance, color: appTheme.blueA400, size: 22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorContent extends StatelessWidget {
|
||||||
|
const _ErrorContent({required this.message, required this.onOk});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onOk;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final s = S.of(context);
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
CustomOutlinedButton(
|
||||||
|
text: s.ok,
|
||||||
|
height: 52,
|
||||||
|
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||||
|
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||||
|
onPressed: onOk,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/features/payment/repositories/payment_repository.dart
Normal file
18
lib/features/payment/repositories/payment_repository.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||||
|
|
||||||
|
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
||||||
|
class PaymentRepository {
|
||||||
|
final ChatService chatService;
|
||||||
|
|
||||||
|
PaymentRepository({required this.chatService});
|
||||||
|
|
||||||
|
/// Запускает процесс оплаты: POST `/api/chosen_plans/{id}/start_payment_process`
|
||||||
|
/// с `payment_provider: alfa-bank`.
|
||||||
|
Future<PaymentModel> startPaymentProcess({required int planId}) async {
|
||||||
|
return chatService.getPaymentLink(
|
||||||
|
id: planId,
|
||||||
|
body: const {'payment_provider': 'alfa-bank'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/features/payment/repositories/sbp_repository.dart
Normal file
27
lib/features/payment/repositories/sbp_repository.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||||
|
import 'package:sbp/models/c2bmembers_model.dart';
|
||||||
|
|
||||||
|
/// Репозиторий оплаты по СБП: обёртка над [SbpService] с graceful-обработкой
|
||||||
|
/// ошибок нативного плагина (отсутствие установленных банков, недоступность
|
||||||
|
/// сервисов на устройстве, в т.ч. на Huawei HMS и т.п.).
|
||||||
|
///
|
||||||
|
/// Возвращает управляемые значения вместо проброса исключений, чтобы UI мог
|
||||||
|
/// показать fallback (например, открыть checkout_url в браузере/WebView).
|
||||||
|
class SbpRepository {
|
||||||
|
final SbpService sbpService;
|
||||||
|
|
||||||
|
SbpRepository({required this.sbpService});
|
||||||
|
|
||||||
|
/// Возвращает установленные банки СБП либо пустой список при ошибке плагина.
|
||||||
|
Future<List<C2bmemberModel>> getInstalledBanks() async {
|
||||||
|
return await sbpService.getInstalledBanks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает банк для оплаты. Возвращает `false` при ошибке плагина.
|
||||||
|
Future<bool> openBank({
|
||||||
|
required String url,
|
||||||
|
required C2bmemberModel bank,
|
||||||
|
}) async {
|
||||||
|
return await sbpService.openBank(url: url, bank: bank);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
lib/features/payment/services/sbp_service.dart
Normal file
43
lib/features/payment/services/sbp_service.dart
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:sbp/data/c2bmembers_data.dart';
|
||||||
|
import 'package:sbp/models/c2bmembers_model.dart';
|
||||||
|
import 'package:sbp/sbp.dart' as sbp;
|
||||||
|
|
||||||
|
/// Контракт обёртки над нативным плагином СБП (Система Быстрых Платежей).
|
||||||
|
///
|
||||||
|
/// Статичные методы класса [Sbp] из пакета `sbp` работают через MethodChannel,
|
||||||
|
/// поэтому не поддаются прямому мокированию в тестах. Этот интерфейс вводится
|
||||||
|
/// для тестируемости и возможности подмены реализации (см. [SbpServiceImpl]).
|
||||||
|
abstract class SbpService {
|
||||||
|
/// Возвращает список банков, установленных на устройстве пользователя
|
||||||
|
/// и поддерживающих оплату по СБП.
|
||||||
|
Future<List<C2bmemberModel>> getInstalledBanks();
|
||||||
|
|
||||||
|
/// Открывает банковское приложение [bank] для оплаты по ссылке [url]
|
||||||
|
/// (checkout_url вида `https://qr.nspk.ru/...`).
|
||||||
|
///
|
||||||
|
/// Возвращает `true`, если приложение банка успешно запущено.
|
||||||
|
Future<bool> openBank({required String url, required C2bmemberModel bank});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Реализация [SbpService], делегирующая вызовы в статичные методы пакета `sbp`.
|
||||||
|
class SbpServiceImpl implements SbpService {
|
||||||
|
const SbpServiceImpl();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<C2bmemberModel>> getInstalledBanks() {
|
||||||
|
// Используем встроенный справочник банков c2bmembersData (получаемый с
|
||||||
|
// https://qr.nspk.ru/proxyapp/c2bmembers.json) и фильтруем только те, что
|
||||||
|
// установлены на устройстве. Локальные иконки/названия включены, чтобы UI
|
||||||
|
// не зависел от сетевой загрузки логотипов.
|
||||||
|
return sbp.Sbp.getInstalledBanks(
|
||||||
|
C2bmembersModel.fromJson(c2bmembersData),
|
||||||
|
useAndroidLocalIcons: true,
|
||||||
|
useAndroidLocalNames: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> openBank({required String url, required C2bmemberModel bank}) {
|
||||||
|
return sbp.Sbp.openBank(url, bank);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,9 @@ enum ChosenPlanStateEnum {
|
|||||||
@JsonValue('personal_diet_setted')
|
@JsonValue('personal_diet_setted')
|
||||||
personalDietSet,
|
personalDietSet,
|
||||||
@JsonValue('charge_was_collected')
|
@JsonValue('charge_was_collected')
|
||||||
stateChargeWasCollected;
|
stateChargeWasCollected,
|
||||||
|
@JsonValue('payment_failed')
|
||||||
|
statePaymentFailed;
|
||||||
|
|
||||||
static List<ChosenPlanStateEnum> getShowableChatBadgeStates() {
|
static List<ChosenPlanStateEnum> getShowableChatBadgeStates() {
|
||||||
return [
|
return [
|
||||||
@@ -53,6 +55,7 @@ enum ChosenPlanStateEnum {
|
|||||||
case ChosenPlanStateEnum.stateInCoordination:
|
case ChosenPlanStateEnum.stateInCoordination:
|
||||||
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
|
case ChosenPlanStateEnum.stateStartDateHasBeenSet:
|
||||||
case ChosenPlanStateEnum.statePendingPayment:
|
case ChosenPlanStateEnum.statePendingPayment:
|
||||||
|
case ChosenPlanStateEnum.statePaymentFailed:
|
||||||
return false;
|
return false;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
@@ -68,7 +71,7 @@ enum ChosenPlanStateEnum {
|
|||||||
ChosenPlanStateEnum.stateLastDayFinished,
|
ChosenPlanStateEnum.stateLastDayFinished,
|
||||||
ChosenPlanStateEnum.stateComplained,
|
ChosenPlanStateEnum.stateComplained,
|
||||||
ChosenPlanStateEnum.stateDone,
|
ChosenPlanStateEnum.stateDone,
|
||||||
ChosenPlanStateEnum.personalDietSet
|
ChosenPlanStateEnum.personalDietSet,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||||
|
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -31,221 +33,213 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||||
late bool isLiked;
|
late bool isLiked;
|
||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
bloc: widget.profileCubit,
|
bloc: widget.profileCubit,
|
||||||
builder: (BuildContext context, ProfileState state) {
|
builder: (BuildContext context, ProfileState state) {
|
||||||
if (state is ProfileStateLoaded) {
|
if (state is! ProfileStateLoaded) {
|
||||||
isLiked = state.blogPosts[widget.index].wasLikedByMe;
|
return const SizedBox.shrink();
|
||||||
totalLikes = state.blogPosts[widget.index].totalLikes;
|
}
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
final FeedBlogPostModel post = state.blogPosts[widget.index];
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
isLiked = post.wasLikedByMe;
|
||||||
child: Ink(
|
totalLikes = post.totalLikes;
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
child: Column(
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return InkWell(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
foregroundImage: NetworkImage(
|
||||||
|
post.author.authorProfileIcon.contains('https')
|
||||||
|
? post.author.authorProfileIcon
|
||||||
|
: sl<AppConfig>().baseUrl + post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.author.authorName,
|
||||||
|
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(height: 1, color: Colors.black45),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
CustomPopup(
|
||||||
|
isAuthor: post.author.authorIsMe,
|
||||||
|
complainedContentType: ComplainedContent.posts,
|
||||||
|
path: 'blog_post/${post.id}',
|
||||||
|
id: post.id,
|
||||||
|
feedBlogPostModel: post,
|
||||||
|
userSlug: post.author.authorSlug,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
post.title,
|
||||||
CircleAvatar(
|
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
||||||
radius: 20,
|
|
||||||
backgroundColor: Colors.black12,
|
|
||||||
foregroundImage: NetworkImage(
|
|
||||||
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
|
|
||||||
? state.blogPosts[widget.index].author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
|
|
||||||
),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].author.authorName,
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black45),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
CustomPopup(
|
|
||||||
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
|
|
||||||
complainedContentType: ComplainedContent.posts,
|
|
||||||
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
|
|
||||||
id: state.blogPosts[widget.index].id,
|
|
||||||
feedBlogPostModel: state.blogPosts[widget.index],
|
|
||||||
userSlug: state.blogPosts[widget.index].author.authorSlug,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
|
],
|
||||||
const SizedBox(height: 12),
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
Padding(
|
if (post.title.isNotEmpty && post.description.isNotEmpty) const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (post.description.isNotEmpty)
|
||||||
child: Text(
|
Padding(
|
||||||
state.blogPosts[widget.index].title,
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
child: MarkdownHtmlWidget(
|
||||||
),
|
markdownText: _truncateDescription(post.description),
|
||||||
|
fontSize: 16,
|
||||||
|
textColor: Colors.black87,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
|
if (post.description.length > 200)
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
const Padding(
|
||||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
'Показать ещё',
|
||||||
child: Builder(
|
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||||
builder: (context) {
|
),
|
||||||
String description = state.blogPosts[widget.index].description;
|
),
|
||||||
final isLong = description.length > 200;
|
if (post.title.isNotEmpty || post.description.isNotEmpty) const SizedBox(height: 8),
|
||||||
|
BlogPostMediaContent(
|
||||||
if (isLong) {
|
galleryImages: post.images,
|
||||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
imageUrl: imageUrl,
|
||||||
int cutPoint = 200;
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||||
// Проверяем, есть ли незакрытые markdown теги
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
String preview = description.substring(0, cutPoint);
|
onOpenGallery: _openGallery,
|
||||||
|
onGalleryChanged: (index) {
|
||||||
// Считаем открывающие и закрывающие теги
|
setState(() {
|
||||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
currentGalleryIndex = index;
|
||||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
});
|
||||||
bool hasUnclosedBold = boldOpen > boldClose;
|
},
|
||||||
|
),
|
||||||
// Если есть незакрытый bold, ищем закрытие
|
const SizedBox(height: 12),
|
||||||
if (hasUnclosedBold) {
|
Padding(
|
||||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
int boldClosePos = description.indexOf('**', cutPoint);
|
child: Row(
|
||||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
children: [
|
||||||
cutPoint = boldClosePos + 2;
|
FeedIconButton(
|
||||||
} else {
|
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||||
// Если не нашли, обрезаем до последнего пробела
|
text: totalLikes.toString(),
|
||||||
cutPoint = description.lastIndexOf(' ', 200);
|
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||||
}
|
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
} else {
|
onTap: () async {
|
||||||
// Обрезаем до последнего пробела для красоты
|
setState(() {
|
||||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
isLiked = !isLiked;
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
isLiked ? totalLikes++ : totalLikes--;
|
||||||
}
|
});
|
||||||
|
isLiked ? await context.read<FeedCubit>().likePost(post.id) : await context.read<FeedCubit>().unlikedPost(post.id);
|
||||||
description = description.substring(0, cutPoint);
|
widget.profileCubit.updateFeedBlogPostList(post, isLiked ? false : true);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
return MarkdownHtmlWidget(
|
const SizedBox(width: 8),
|
||||||
markdownText: description,
|
FeedIconButton(
|
||||||
fontSize: 16,
|
svgIcon: SvgPicture.asset(
|
||||||
textColor: Colors.black87,
|
'assets/icons/comment.svg',
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
|
text: post.totalComments.toString(),
|
||||||
|
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
'assets/icons/share.svg',
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}'),
|
||||||
|
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const Spacer(),
|
||||||
if (state.blogPosts[widget.index].description.length > 200)
|
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||||
const Padding(
|
const SizedBox(width: 4),
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
Text(
|
||||||
child: Text(
|
post.totalViews.toString(),
|
||||||
'Показать ещё',
|
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
context.pushTransparentRoute(
|
|
||||||
rootNavigator: true,
|
|
||||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Hero(
|
|
||||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
],
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FeedIconButton(
|
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
text: totalLikes.toString(),
|
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
onTap: () async {
|
|
||||||
setState(() {
|
|
||||||
isLiked = !isLiked;
|
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
|
||||||
});
|
|
||||||
isLiked
|
|
||||||
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
|
|
||||||
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
|
|
||||||
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
'assets/icons/comment.svg',
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
text: state.blogPosts[widget.index].totalComments.toString(),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
'assets/icons/share.svg',
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
|
||||||
SharePlus.instance.share(
|
|
||||||
ShareParams(
|
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
|
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].totalViews.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _truncateDescription(String description) {
|
||||||
|
final isLong = description.length > 200;
|
||||||
|
if (!isLong) return description;
|
||||||
|
|
||||||
|
var cutPoint = 200;
|
||||||
|
final preview = description.substring(0, cutPoint);
|
||||||
|
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||||
|
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||||
|
final hasUnclosedBold = boldOpen > boldClose;
|
||||||
|
|
||||||
|
if (hasUnclosedBold) {
|
||||||
|
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||||
|
final boldClosePos = description.indexOf('**', cutPoint);
|
||||||
|
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||||
|
cutPoint = boldClosePos + 2;
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', 200);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||||
|
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.substring(0, cutPoint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,17 +15,20 @@ class ExpandableBouble extends StatelessWidget {
|
|||||||
child: MainBackgroundBouble(
|
child: MainBackgroundBouble(
|
||||||
child: Theme(
|
child: Theme(
|
||||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
child: ExpansionTile(
|
child: Material(
|
||||||
initiallyExpanded: initiallyExpanded,
|
color: Colors.transparent,
|
||||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
child: ExpansionTile(
|
||||||
expandedAlignment: Alignment.bottomLeft,
|
initiallyExpanded: initiallyExpanded,
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
expandedAlignment: Alignment.bottomLeft,
|
||||||
title: Text(
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
title,
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
children: children,
|
||||||
),
|
),
|
||||||
children: children,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
1139
lib/features/video_calls/managers/video_call_manager.dart
Normal file
1139
lib/features/video_calls/managers/video_call_manager.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
|||||||
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
||||||
_$VideoCallEndedResponseFromJson(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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,24 +2,35 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:eatvio_app/core/locator_service.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/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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.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 {
|
class VideoCallScreen extends StatefulWidget {
|
||||||
const VideoCallScreen({
|
const VideoCallScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.conversationUuid,
|
required this.conversationUuid,
|
||||||
this.counterpartName,
|
this.counterpartName,
|
||||||
|
this.counterpartAvatarUrl,
|
||||||
|
this.counterpartUserId,
|
||||||
|
this.counterpartIsOnline,
|
||||||
|
this.counterpartLastSeenText,
|
||||||
|
this.counterpartIsConsultant,
|
||||||
this.localParticipantName,
|
this.localParticipantName,
|
||||||
|
this.entryMode = VideoCallEntryMode.outgoing,
|
||||||
this.onBackToChat,
|
this.onBackToChat,
|
||||||
this.onHangUp,
|
this.onHangUp,
|
||||||
});
|
});
|
||||||
@@ -29,7 +40,13 @@ class VideoCallScreen extends StatefulWidget {
|
|||||||
|
|
||||||
final String conversationUuid;
|
final String conversationUuid;
|
||||||
final String? counterpartName;
|
final String? counterpartName;
|
||||||
|
final String? counterpartAvatarUrl;
|
||||||
|
final int? counterpartUserId;
|
||||||
|
final bool? counterpartIsOnline;
|
||||||
|
final String? counterpartLastSeenText;
|
||||||
|
final bool? counterpartIsConsultant;
|
||||||
final String? localParticipantName;
|
final String? localParticipantName;
|
||||||
|
final VideoCallEntryMode entryMode;
|
||||||
final VoidCallback? onBackToChat;
|
final VoidCallback? onBackToChat;
|
||||||
final VoidCallback? onHangUp;
|
final VoidCallback? onHangUp;
|
||||||
|
|
||||||
@@ -38,437 +55,151 @@ class VideoCallScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _VideoCallScreenState extends State<VideoCallScreen> {
|
class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||||
static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
|
static const SystemUiOverlayStyle _videoCallOverlayStyle =
|
||||||
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
|
SystemUiOverlayStyle(
|
||||||
static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600);
|
statusBarColor: Colors.transparent,
|
||||||
static const double _mutedMicAudioLevelThreshold = 0.008;
|
statusBarIconBrightness: Brightness.light,
|
||||||
static const double _mutedMicEnergyDelta = 1e-6;
|
statusBarBrightness: Brightness.dark,
|
||||||
|
systemNavigationBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
|
systemNavigationBarContrastEnforced: false,
|
||||||
|
);
|
||||||
|
|
||||||
final lk.Room _room = lk.Room(
|
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
|
||||||
roomOptions: const lk.RoomOptions(
|
WsManager get _wsManager => sl<WsManager>();
|
||||||
adaptiveStream: true,
|
|
||||||
defaultCameraCaptureOptions: lk.CameraCaptureOptions(params: lk.VideoParametersPresets.h1080_169),
|
|
||||||
defaultAudioCaptureOptions: _micCaptureOptions,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final VideoCallRepository _videoCallRepository = sl();
|
|
||||||
|
|
||||||
bool _micEnabled = true;
|
bool _counterpartIsOnline = false;
|
||||||
bool _videoEnabled = true;
|
String _counterpartLastSeenText = '';
|
||||||
bool _splitParticipantView = true;
|
void Function(bool isOnline)? _presenceOnlineListener;
|
||||||
bool _mainStageShowsLocalVideo = false;
|
|
||||||
bool _useFrontCamera = true;
|
|
||||||
String? _connectError;
|
|
||||||
bool _liveKitDisposed = false;
|
|
||||||
bool _callEndedNotified = false;
|
|
||||||
|
|
||||||
lk.EventsListener<lk.RoomEvent>? _roomEventsListener;
|
|
||||||
OverlayEntry? _poorConnectionEntry;
|
|
||||||
Timer? _mutedMicPollTimer;
|
|
||||||
DateTime? _lastMutedMicOverlayAt;
|
|
||||||
bool _recordingOverlayScheduled = false;
|
|
||||||
bool _mutedMicStatsInFlight = false;
|
|
||||||
num? _lastMutedMicTotalEnergy;
|
|
||||||
|
|
||||||
lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) {
|
|
||||||
return lk.CameraCaptureOptions(
|
|
||||||
cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
|
|
||||||
params: lk.VideoParametersPresets.h1080_169,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_attachRoomEventListeners();
|
_counterpartIsOnline = widget.counterpartIsOnline ?? false;
|
||||||
_mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
|
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
|
||||||
if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return;
|
|
||||||
if (_room.connectionState != lk.ConnectionState.connected) return;
|
|
||||||
_mutedMicStatsInFlight = true;
|
|
||||||
unawaited(
|
|
||||||
_pollMutedMicFromWebRtcStats().whenComplete(() {
|
|
||||||
_mutedMicStatsInFlight = false;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
unawaited(_applyVideoCallSystemUi());
|
unawaited(_initCounterpartMetadata());
|
||||||
_scheduleRecordingNoticeOnce();
|
unawaited(
|
||||||
});
|
_cubit.onCallScreenVisible(
|
||||||
unawaited(_connectLiveKit());
|
conversationUuid: widget.conversationUuid,
|
||||||
}
|
counterpartName: widget.counterpartName,
|
||||||
|
localParticipantName: widget.localParticipantName,
|
||||||
void _attachRoomEventListeners() {
|
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||||
_roomEventsListener = _room.createListener()
|
entryMode: widget.entryMode,
|
||||||
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
),
|
||||||
if (e.participant != _room.localParticipant) return;
|
|
||||||
if (!mounted) return;
|
|
||||||
_syncPoorConnectionOverlay(e.connectionQuality);
|
|
||||||
})
|
|
||||||
..on<lk.ActiveSpeakersChangedEvent>((e) {
|
|
||||||
if (!mounted || _micEnabled) return;
|
|
||||||
final lp = _room.localParticipant;
|
|
||||||
if (lp == null) return;
|
|
||||||
if (e.speakers.any((p) => p.sid == lp.sid)) {
|
|
||||||
_maybeShowMutedMicOverlay();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _scheduleRecordingNoticeOnce() {
|
|
||||||
if (_recordingOverlayScheduled || !mounted) return;
|
|
||||||
_recordingOverlayScheduled = true;
|
|
||||||
VideoCallTopOverlay.insertBanner(
|
|
||||||
context,
|
|
||||||
message: S.of(context).video_call_recording_notice,
|
|
||||||
backgroundColor: appTheme.videoCallNoticeBannerBackground,
|
|
||||||
svgAsset: VideoCallTopOverlay.warningSvg,
|
|
||||||
autoRemoveAfter: const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncPoorConnectionOverlay(lk.ConnectionQuality q) {
|
|
||||||
final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
|
|
||||||
if (bad) {
|
|
||||||
if (_poorConnectionEntry != null) return;
|
|
||||||
if (!mounted) return;
|
|
||||||
_poorConnectionEntry = VideoCallTopOverlay.insertBanner(
|
|
||||||
context,
|
|
||||||
message: S.of(context).video_call_poor_connection,
|
|
||||||
backgroundColor: appTheme.strongRed,
|
|
||||||
svgAsset: VideoCallTopOverlay.poorConnectionSvg,
|
|
||||||
);
|
);
|
||||||
} else {
|
});
|
||||||
final e = _poorConnectionEntry;
|
|
||||||
if (e != null && e.mounted) {
|
|
||||||
e.remove();
|
|
||||||
}
|
|
||||||
_poorConnectionEntry = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _maybeShowMutedMicOverlay() {
|
Future<void> _initCounterpartMetadata() async {
|
||||||
if (!mounted || _micEnabled) return;
|
var isOnline = widget.counterpartIsOnline ?? false;
|
||||||
final now = DateTime.now();
|
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||||
if (_lastMutedMicOverlayAt != null && now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) {
|
var counterpartUserId = widget.counterpartUserId;
|
||||||
return;
|
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(
|
if (!mounted) return;
|
||||||
context,
|
setState(() {
|
||||||
message: S.of(context).video_call_muted_mic_warning,
|
_counterpartIsOnline = isOnline;
|
||||||
backgroundColor: appTheme.videoCallNoticeBannerBackground,
|
_counterpartLastSeenText = lastSeenText;
|
||||||
svgAsset: VideoCallTopOverlay.warningSvg,
|
});
|
||||||
autoRemoveAfter: const Duration(seconds: 2),
|
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 {
|
Future<void> _refreshCounterpartOnlineStatus() async {
|
||||||
if (!mounted || _liveKitDisposed || _micEnabled) return;
|
|
||||||
if (_room.connectionState != lk.ConnectionState.connected) return;
|
|
||||||
final lp = _room.localParticipant;
|
|
||||||
if (lp == null) return;
|
|
||||||
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
|
|
||||||
final track = pub?.track;
|
|
||||||
if (track is! lk.LocalAudioTrack) return;
|
|
||||||
if (!track.muted) return;
|
|
||||||
try {
|
try {
|
||||||
final stats = await track.getSenderStats();
|
final chatRoom = await sl<ChatRepository>().getChatRoom(
|
||||||
if (!mounted || _liveKitDisposed || _micEnabled) return;
|
widget.conversationUuid,
|
||||||
final src = stats?.audioSourceStats;
|
);
|
||||||
if (src == null) return;
|
final counterpart = resolveChatCounterpart(
|
||||||
|
users: chatRoom.users,
|
||||||
final level = src.audioLevel;
|
currentUserId: _wsManager.userId,
|
||||||
if (level != null && level >= _mutedMicAudioLevelThreshold) {
|
);
|
||||||
_maybeShowMutedMicOverlay();
|
|
||||||
_lastMutedMicTotalEnergy = src.totalAudioEnergy;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final energy = src.totalAudioEnergy;
|
|
||||||
if (energy != null) {
|
|
||||||
final prev = _lastMutedMicTotalEnergy;
|
|
||||||
if (prev != null && energy > prev + _mutedMicEnergyDelta) {
|
|
||||||
_maybeShowMutedMicOverlay();
|
|
||||||
}
|
|
||||||
_lastMutedMicTotalEnergy = energy;
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _applyVideoCallSystemUi() async {
|
|
||||||
try {
|
|
||||||
await SystemChrome.setPreferredOrientations(const [
|
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
DeviceOrientation.portraitDown,
|
|
||||||
DeviceOrientation.landscapeLeft,
|
|
||||||
DeviceOrientation.landscapeRight,
|
|
||||||
]);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
setState(() {
|
||||||
} on PlatformException catch (_) {
|
_counterpartIsOnline = counterpart.isOnline;
|
||||||
if (!mounted) return;
|
_counterpartLastSeenText = counterpart.lastSeenText;
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _restoreSystemUiAfterCall() async {
|
|
||||||
try {
|
|
||||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
|
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
|
||||||
} on PlatformException catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connectLiveKit() async {
|
|
||||||
setState(() => _connectError = null);
|
|
||||||
try {
|
|
||||||
final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
var micOn = _micEnabled;
|
|
||||||
var videoOn = _videoEnabled;
|
|
||||||
if (micOn) {
|
|
||||||
final granted = await _ensureMicrophonePermission();
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!granted) micOn = false;
|
|
||||||
}
|
|
||||||
if (videoOn) {
|
|
||||||
final granted = await _ensureCameraPermission();
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!granted) videoOn = false;
|
|
||||||
}
|
|
||||||
if (micOn != _micEnabled || videoOn != _videoEnabled) {
|
|
||||||
setState(() {
|
|
||||||
_micEnabled = micOn;
|
|
||||||
_videoEnabled = videoOn;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await _room.connect(tokenResponse.wsUrl, tokenResponse.token);
|
|
||||||
if (!mounted || _liveKitDisposed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveMic = micOn;
|
|
||||||
var effectiveVideo = videoOn;
|
|
||||||
final lp = _room.localParticipant;
|
|
||||||
|
|
||||||
if (micOn) {
|
|
||||||
try {
|
|
||||||
await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions);
|
|
||||||
} catch (_) {
|
|
||||||
effectiveMic = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted || _liveKitDisposed) return;
|
|
||||||
|
|
||||||
if (videoOn) {
|
|
||||||
try {
|
|
||||||
await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions());
|
|
||||||
} catch (_) {
|
|
||||||
effectiveVideo = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) {
|
|
||||||
setState(() {
|
|
||||||
_micEnabled = effectiveMic;
|
|
||||||
_videoEnabled = effectiveVideo;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
_syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) setState(() => _connectError = e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _disconnectLiveKitOnly() async {
|
|
||||||
if (_liveKitDisposed) return;
|
|
||||||
_liveKitDisposed = true;
|
|
||||||
try {
|
|
||||||
if (_room.connectionState != lk.ConnectionState.disconnected) {
|
|
||||||
await _room.disconnect();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
} 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_mutedMicPollTimer?.cancel();
|
if (_presenceOnlineListener != null) {
|
||||||
final listener = _roomEventsListener;
|
_wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
_roomEventsListener = null;
|
_presenceOnlineListener = null;
|
||||||
if (listener != null) {
|
|
||||||
unawaited(listener.dispose());
|
|
||||||
}
|
}
|
||||||
final poor = _poorConnectionEntry;
|
_cubit.onCallScreenDisposed();
|
||||||
_poorConnectionEntry = null;
|
|
||||||
if (poor != null && poor.mounted) {
|
|
||||||
poor.remove();
|
|
||||||
}
|
|
||||||
unawaited(_cleanupLiveKitAndNotifyBackend());
|
|
||||||
unawaited(_restoreSystemUiAfterCall());
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleBackToChat() async {
|
Future<void> _handleBackToChat() async {
|
||||||
widget.onBackToChat?.call();
|
widget.onBackToChat?.call();
|
||||||
await _cleanupLiveKitAndNotifyBackend();
|
await _cubit.leaveCallRoute();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (context.canPop()) context.pop();
|
if (context.canPop()) context.pop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleHangUp() async {
|
Future<void> _handleHangUp() async {
|
||||||
widget.onHangUp?.call();
|
widget.onHangUp?.call();
|
||||||
await _cleanupLiveKitAndNotifyBackend();
|
await _cubit.endCall();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (context.canPop()) context.pop();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(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 topInset = viewPadding.top > 0 ? viewPadding.top : 16.0;
|
||||||
final bottomInset = viewPadding.bottom;
|
final bottomInset = viewPadding.bottom;
|
||||||
|
|
||||||
final counterpartFallback = s.video_call_counterpart_default;
|
final controlLabelStyle = TextStyles.textSMedium.copyWith(
|
||||||
final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14);
|
color: appTheme.whiteA700,
|
||||||
|
height: 20 / 14,
|
||||||
|
);
|
||||||
|
|
||||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
return PopScope(
|
||||||
value: const SystemUiOverlayStyle(
|
canPop: false,
|
||||||
statusBarColor: Colors.transparent,
|
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
||||||
statusBarIconBrightness: Brightness.light,
|
if (didPop) return;
|
||||||
statusBarBrightness: Brightness.dark,
|
await _cubit.leaveCallRoute();
|
||||||
systemNavigationBarColor: Colors.transparent,
|
if (!context.mounted) return;
|
||||||
systemNavigationBarIconBrightness: Brightness.light,
|
if (context.canPop()) context.pop();
|
||||||
systemNavigationBarContrastEnforced: false,
|
},
|
||||||
),
|
child: BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||||
child: Scaffold(
|
bloc: _cubit,
|
||||||
backgroundColor: appTheme.whiteA700,
|
buildWhen: (a, b) =>
|
||||||
body: ListenableBuilder(
|
a.connectPhase != b.connectPhase ||
|
||||||
listenable: _room,
|
a.connectError != b.connectError ||
|
||||||
builder: (context, _) {
|
a.linkIssue != b.linkIssue ||
|
||||||
return VideoCallStageLayout(
|
a.counterpartName != b.counterpartName ||
|
||||||
room: _room,
|
a.counterpartIsConsultant != b.counterpartIsConsultant ||
|
||||||
connectError: _connectError,
|
a.localParticipantName != b.localParticipantName ||
|
||||||
localDisplayName: _resolvedLocalDisplayName(context),
|
a.callStageEntered != b.callStageEntered ||
|
||||||
remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback),
|
a.micEnabled != b.micEnabled ||
|
||||||
micEnabled: _micEnabled,
|
a.videoEnabled != b.videoEnabled ||
|
||||||
videoEnabled: _videoEnabled,
|
a.splitParticipantView != b.splitParticipantView ||
|
||||||
splitParticipantView: _splitParticipantView,
|
a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
|
||||||
mainStageShowsLocalVideo: _mainStageShowsLocalVideo,
|
a.useFrontCamera != b.useFrontCamera ||
|
||||||
useFrontCamera: _useFrontCamera,
|
a.entryMode != b.entryMode,
|
||||||
onExpandLocal: () => setState(() {
|
builder: (context, sessionState) {
|
||||||
_splitParticipantView = false;
|
final room = _cubit.liveKitRoom;
|
||||||
_mainStageShowsLocalVideo = true;
|
|
||||||
}),
|
final showLost =
|
||||||
onExpandRemote: () => setState(() {
|
sessionState.linkIssue.needsManualReconnect ||
|
||||||
_splitParticipantView = false;
|
(sessionState.connectPhase == VideoCallConnectPhase.idle &&
|
||||||
_mainStageShowsLocalVideo = false;
|
sessionState.connectError != null);
|
||||||
}),
|
|
||||||
onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true),
|
if (showLost) {
|
||||||
onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo),
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
onChat: () => unawaited(_handleBackToChat()),
|
value: _videoCallOverlayStyle,
|
||||||
onToggleMic: () => unawaited(_toggleMic()),
|
child: Scaffold(
|
||||||
onToggleVideo: () => unawaited(_toggleVideo()),
|
backgroundColor: appTheme.whiteA700,
|
||||||
onHangUp: () => unawaited(_handleHangUp()),
|
body: VideoCallConnectionLostScreen(
|
||||||
onSwitchCamera: () => unawaited(_switchCameraFacing()),
|
detailMessage: sessionState.connectError,
|
||||||
controlLabelStyle: controlLabelStyle,
|
onRetry: () => unawaited(_cubit.retryAfterConnectionLoss()),
|
||||||
labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave),
|
onLeave: () => unawaited(_handleHangUp()),
|
||||||
topInset: topInset,
|
),
|
||||||
bottomInset: bottomInset,
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
|
||||||
|
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() ??
|
||||||
|
'';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ abstract final class VideoCallLiveKitTrackUtils {
|
|||||||
|
|
||||||
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
|
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
|
||||||
if (pub == null || pub.muted) return null;
|
if (pub == null || pub.muted) return null;
|
||||||
|
if (pub is lk.RemoteTrackPublication && !pub.subscribed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
final v = asVideoTrack(pub.track);
|
final v = asVideoTrack(pub.track);
|
||||||
if (v == null || v.muted) return null;
|
if (v == null || v.muted) return null;
|
||||||
return v;
|
return v;
|
||||||
@@ -37,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils {
|
|||||||
room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera),
|
room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,40 +2,74 @@ import 'dart:ui';
|
|||||||
|
|
||||||
import 'package:eatvio_app/core/theme/custom_text_style.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/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class VideoCallParticipantNameLabel extends StatelessWidget {
|
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 String name;
|
||||||
|
final bool isConsultant;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null;
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: 12,
|
left: 12,
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.bottomLeft,
|
alignment: Alignment.bottomLeft,
|
||||||
child: ClipRRect(
|
child: Column(
|
||||||
borderRadius: BorderRadius.circular(60),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: BackdropFilter(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
children: [
|
||||||
child: Container(
|
if (roleLabel != null) ...[
|
||||||
constraints: const BoxConstraints(maxWidth: 240),
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
color: appTheme.transparentBlack,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(60),
|
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(
|
const SizedBox(height: 16),
|
||||||
name,
|
],
|
||||||
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
ClipRRect(
|
||||||
maxLines: 1,
|
borderRadius: BorderRadius.circular(60),
|
||||||
overflow: TextOverflow.ellipsis,
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: appTheme.transparentBlack,
|
||||||
|
borderRadius: BorderRadius.circular(60),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/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_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_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:flutter/material.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
|
|
||||||
@@ -10,25 +11,34 @@ class VideoCallRemoteVideoStage extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
required this.room,
|
required this.room,
|
||||||
required this.connectError,
|
required this.connectError,
|
||||||
|
this.noVideoLogoSize = 96,
|
||||||
});
|
});
|
||||||
|
|
||||||
final lk.Room room;
|
final lk.Room room;
|
||||||
final String? connectError;
|
final String? connectError;
|
||||||
|
final double noVideoLogoSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: room,
|
listenable: videoCallRoomTracksListenable(room),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
|
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
|
||||||
if (remote != null) {
|
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(
|
return Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
const VideoCallNoVideoBackground(),
|
VideoCallNoVideoBackground(logoSize: noVideoLogoSize),
|
||||||
if (connectError != null)
|
if (showConnectError)
|
||||||
Center(
|
Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|||||||
@@ -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!,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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_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_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_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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||||
@@ -24,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
|||||||
required this.connectError,
|
required this.connectError,
|
||||||
required this.localDisplayName,
|
required this.localDisplayName,
|
||||||
required this.remoteDisplayName,
|
required this.remoteDisplayName,
|
||||||
|
this.remoteIsConsultant = false,
|
||||||
required this.micEnabled,
|
required this.micEnabled,
|
||||||
required this.videoEnabled,
|
required this.videoEnabled,
|
||||||
required this.splitParticipantView,
|
required this.splitParticipantView,
|
||||||
@@ -48,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
|||||||
final String? connectError;
|
final String? connectError;
|
||||||
final String localDisplayName;
|
final String localDisplayName;
|
||||||
final String remoteDisplayName;
|
final String remoteDisplayName;
|
||||||
|
final bool remoteIsConsultant;
|
||||||
final bool micEnabled;
|
final bool micEnabled;
|
||||||
final bool videoEnabled;
|
final bool videoEnabled;
|
||||||
final bool splitParticipantView;
|
final bool splitParticipantView;
|
||||||
@@ -83,6 +86,8 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
|||||||
static const double _pipInitialExtraDown = 16;
|
static const double _pipInitialExtraDown = 16;
|
||||||
static const double _bottomGradientHeight = 140;
|
static const double _bottomGradientHeight = 140;
|
||||||
static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop;
|
static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop;
|
||||||
|
static const int _splitRemoteFlex = 7;
|
||||||
|
static const int _splitLocalFlex = 3;
|
||||||
|
|
||||||
double? _pipLeft;
|
double? _pipLeft;
|
||||||
double? _pipTop;
|
double? _pipTop;
|
||||||
@@ -90,13 +95,6 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
|||||||
double? _pipPanOriginLeft;
|
double? _pipPanOriginLeft;
|
||||||
double? _pipPanOriginTop;
|
double? _pipPanOriginTop;
|
||||||
|
|
||||||
bool _pipHasVisibleVideo() {
|
|
||||||
if (widget.mainStageShowsLocalVideo) {
|
|
||||||
return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null;
|
|
||||||
}
|
|
||||||
return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
@@ -110,15 +108,17 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSplitLayout(BoxConstraints constraints) {
|
Widget _buildSplitLayout(BoxConstraints constraints) {
|
||||||
return Stack(
|
return ListenableBuilder(
|
||||||
fit: StackFit.expand,
|
listenable: videoCallRoomTracksListenable(widget.room),
|
||||||
children: [
|
builder: (context, _) {
|
||||||
Positioned.fill(
|
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
|
||||||
child: Padding(
|
if (soloInRoom) {
|
||||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
return Stack(
|
||||||
child: Column(
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Positioned.fill(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||||
child: _SplitParticipantPane(
|
child: _SplitParticipantPane(
|
||||||
room: widget.room,
|
room: widget.room,
|
||||||
displayName: widget.localDisplayName,
|
displayName: widget.localDisplayName,
|
||||||
@@ -128,40 +128,73 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
|||||||
useFrontCamera: widget.useFrontCamera,
|
useFrontCamera: widget.useFrontCamera,
|
||||||
onExpand: widget.onExpandLocal,
|
onExpand: widget.onExpandLocal,
|
||||||
onSwitchCamera: widget.onSwitchCamera,
|
onSwitchCamera: widget.onSwitchCamera,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: BorderRadius.circular(_mainStageCornerRadius),
|
||||||
topLeft: Radius.circular(_mainStageCornerRadius),
|
|
||||||
topRight: Radius.circular(_mainStageCornerRadius),
|
|
||||||
bottomLeft: Radius.zero,
|
|
||||||
bottomRight: Radius.zero,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: _edgeInset),
|
),
|
||||||
Expanded(
|
..._bottomBarGradient(),
|
||||||
child: _SplitParticipantPane(
|
..._bottomControlsRow(),
|
||||||
room: widget.room,
|
],
|
||||||
displayName: widget.remoteDisplayName,
|
);
|
||||||
isLocal: false,
|
}
|
||||||
connectError: widget.connectError,
|
|
||||||
videoEnabled: widget.videoEnabled,
|
return Stack(
|
||||||
useFrontCamera: widget.useFrontCamera,
|
fit: StackFit.expand,
|
||||||
onExpand: widget.onExpandRemote,
|
children: [
|
||||||
onSwitchCamera: widget.onSwitchCamera,
|
Positioned.fill(
|
||||||
borderRadius: const BorderRadius.only(
|
child: Padding(
|
||||||
topLeft: Radius.zero,
|
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||||
topRight: Radius.zero,
|
child: Column(
|
||||||
bottomLeft: Radius.circular(_mainStageCornerRadius),
|
children: [
|
||||||
bottomRight: Radius.circular(_mainStageCornerRadius),
|
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 pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble();
|
||||||
final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble();
|
final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble();
|
||||||
|
|
||||||
return Stack(
|
return ListenableBuilder(
|
||||||
fit: StackFit.expand,
|
listenable: videoCallRoomTracksListenable(widget.room),
|
||||||
children: [
|
builder: (context, _) {
|
||||||
Positioned.fill(
|
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
|
||||||
child: Padding(
|
final mainShowsLocal =
|
||||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
soloInRoom || widget.mainStageShowsLocalVideo;
|
||||||
child: Material(
|
|
||||||
type: MaterialType.transparency,
|
return Stack(
|
||||||
clipBehavior: Clip.antiAlias,
|
fit: StackFit.expand,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)),
|
children: [
|
||||||
child: Stack(
|
Positioned.fill(
|
||||||
fit: StackFit.expand,
|
child: Padding(
|
||||||
children: [
|
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||||
_VideoCallMainStage(
|
child: Material(
|
||||||
room: widget.room,
|
type: MaterialType.transparency,
|
||||||
connectError: widget.connectError,
|
clipBehavior: Clip.antiAlias,
|
||||||
mainShowsLocal: widget.mainStageShowsLocalVideo,
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)),
|
||||||
videoEnabled: widget.videoEnabled,
|
child: Stack(
|
||||||
),
|
fit: StackFit.expand,
|
||||||
VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName),
|
children: [
|
||||||
],
|
_VideoCallMainStage(
|
||||||
),
|
room: widget.room,
|
||||||
),
|
connectError: widget.connectError,
|
||||||
),
|
mainShowsLocal: mainShowsLocal,
|
||||||
),
|
videoEnabled: widget.videoEnabled,
|
||||||
..._bottomBarGradient(),
|
),
|
||||||
..._bottomControlsRow(),
|
VideoCallParticipantNameLabel(
|
||||||
Positioned(
|
name: mainShowsLocal
|
||||||
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
|
? widget.localDisplayName
|
||||||
left: _controlsHorizontalInset,
|
: widget.remoteDisplayName,
|
||||||
child: Padding(
|
isConsultant: !mainShowsLocal && widget.remoteIsConsultant,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
..._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({
|
const _SplitParticipantPane({
|
||||||
required this.room,
|
required this.room,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
|
this.isConsultant = false,
|
||||||
required this.isLocal,
|
required this.isLocal,
|
||||||
required this.connectError,
|
required this.connectError,
|
||||||
required this.videoEnabled,
|
required this.videoEnabled,
|
||||||
@@ -343,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
|||||||
|
|
||||||
final lk.Room room;
|
final lk.Room room;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
final bool isConsultant;
|
||||||
final bool isLocal;
|
final bool isLocal;
|
||||||
final String? connectError;
|
final String? connectError;
|
||||||
final bool videoEnabled;
|
final bool videoEnabled;
|
||||||
@@ -362,7 +404,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: room,
|
listenable: videoCallRoomTracksListenable(room),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (isLocal) {
|
if (isLocal) {
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
@@ -371,7 +413,12 @@ class _SplitParticipantPane extends StatelessWidget {
|
|||||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
||||||
if (local != null) {
|
if (local != null) {
|
||||||
return IgnorePointer(
|
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();
|
return const VideoCallNoVideoBackground();
|
||||||
@@ -380,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
VideoCallParticipantNameLabel(name: displayName),
|
VideoCallParticipantNameLabel(
|
||||||
|
name: displayName,
|
||||||
|
isConsultant: !isLocal && isConsultant,
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
top: _VideoCallStageLayoutState._splitExpandButtonTop,
|
top: _VideoCallStageLayoutState._splitExpandButtonTop,
|
||||||
@@ -421,7 +471,7 @@ class _VideoCallMainStage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: room,
|
listenable: videoCallRoomTracksListenable(room),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (mainShowsLocal) {
|
if (mainShowsLocal) {
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
@@ -430,7 +480,12 @@ class _VideoCallMainStage extends StatelessWidget {
|
|||||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
||||||
if (local != null) {
|
if (local != null) {
|
||||||
return IgnorePointer(
|
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();
|
return const VideoCallNoVideoBackground();
|
||||||
@@ -448,7 +503,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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)
|
const VideoCallNoVideoBackground(logoSize: 56)
|
||||||
else if (local != null)
|
else if (local != null)
|
||||||
IgnorePointer(
|
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
|
else
|
||||||
const VideoCallNoVideoBackground(logoSize: 56),
|
const VideoCallNoVideoBackground(logoSize: 56),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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/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/material.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
|
||||||
abstract final class VideoCallTopOverlay {
|
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 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(
|
static OverlayEntry insertBanner(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
@@ -15,7 +78,7 @@ abstract final class VideoCallTopOverlay {
|
|||||||
required String svgAsset,
|
required String svgAsset,
|
||||||
Duration? autoRemoveAfter,
|
Duration? autoRemoveAfter,
|
||||||
}) {
|
}) {
|
||||||
final overlayState = Overlay.of(context, rootOverlay: true);
|
final overlayState = _resolveOverlayState(context);
|
||||||
late OverlayEntry entry;
|
late OverlayEntry entry;
|
||||||
entry = OverlayEntry(
|
entry = OverlayEntry(
|
||||||
builder: (ctx) => Positioned(
|
builder: (ctx) => Positioned(
|
||||||
@@ -50,6 +113,9 @@ abstract final class VideoCallTopOverlay {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (overlayState == null) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
overlayState.insert(entry);
|
overlayState.insert(entry);
|
||||||
if (autoRemoveAfter != null) {
|
if (autoRemoveAfter != null) {
|
||||||
Future<void>.delayed(autoRemoveAfter, () {
|
Future<void>.delayed(autoRemoveAfter, () {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user