forked from eatvio/eatvio_app
Compare commits
233 Commits
issue-974
...
4c38026748
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c38026748 | ||
|
|
355ec2df48 | ||
| 04195dd95b | |||
|
|
d3336b1263 | ||
| 82f5c9a7fe | |||
|
|
39a512193e | ||
| 4dc5839317 | |||
|
|
da6158625f | ||
|
|
5a8f8aee3e | ||
|
|
86e9d0cc31 | ||
| 60080c7407 | |||
|
|
6d2935400a | ||
| 293601f1e1 | |||
|
|
ff17b813ac | ||
| 6a259e5452 | |||
|
|
9288a5c849 | ||
|
|
4ef174ebde | ||
|
|
1901248539 | ||
| b2e700a4eb | |||
|
|
1bbb08f2a2 | ||
| 61f1b76f99 | |||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
| 572a6f1943 | |||
|
|
a6ae9abb57 | ||
| 431354170e | |||
|
|
c38e82f558 | ||
| afd6f9a93a | |||
|
|
641eb4d2e2 | ||
| 74c66c9600 | |||
|
|
73e89ff7a9 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
603a158cf2 | ||
| a1bae68307 | |||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
|
|
0d588bcd57 | ||
| b679e90ba2 | |||
|
|
6b5442eb87 | ||
| 2474c5bea3 | |||
|
|
478a53674a | ||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
|
|
04de956769 | ||
| 191bb74aa4 | |||
|
|
3210f38d4a | ||
| 27a98a496d | |||
| d8ecc7f3b1 | |||
| 063da03d7e | |||
| 6b5927a99e | |||
| afab72f7bc | |||
| 2770ff0e80 | |||
| 1200021bb6 | |||
|
|
9e8b8b89fc | ||
| a24d052193 | |||
|
|
9de0efa28e | ||
| 37504aa292 | |||
| c8588a2e1f | |||
|
|
e0cab89126 | ||
|
|
954d4955da | ||
| e5086d7fbc | |||
| 0d93768dc3 | |||
|
|
8c5fc8d827 | ||
|
|
2d6b042d45 | ||
| d56aa45c92 | |||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
|
|
037f153273 | ||
| 6a17bf3a95 | |||
|
|
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 |
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
62
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
@@ -0,0 +1,62 @@
|
||||
name: "🐞 Bug Report"
|
||||
description: "Report a bug to help us improve"
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: stand
|
||||
attributes:
|
||||
label: "Stand"
|
||||
description: "Select the environment where the bug occurs"
|
||||
options:
|
||||
- "prod"
|
||||
- "stage-dev2"
|
||||
- "local"
|
||||
- "all"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: "Platform"
|
||||
description: "Select the platform where the bug occurs"
|
||||
options:
|
||||
- "Android"
|
||||
- "iPhone"
|
||||
- "Huawei"
|
||||
- "All"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device_model
|
||||
attributes:
|
||||
label: "Device Model"
|
||||
description: "Optional. Specify the device where the bug occurs"
|
||||
placeholder: "e.g., Samsung Galaxy S23, iPhone 15 Pro, Huawei P60"
|
||||
|
||||
- type: textarea
|
||||
id: reproduction_steps
|
||||
attributes:
|
||||
label: "Steps to Reproduce"
|
||||
description: "Clear numbered steps"
|
||||
placeholder: "1. Click X\n2. Navigate to Y..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected_actual
|
||||
attributes:
|
||||
label: "Expected vs Actual Behavior"
|
||||
description: "What should happen vs what happens"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "Дополнительная информация описывающая проблему"
|
||||
validations:
|
||||
required: true
|
||||
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
5
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "💬 Community Support"
|
||||
url: "https://pepe.eatv.io/channel/general"
|
||||
about: "Ask questions here"
|
||||
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
26
.gitea/ISSUE_TEMPLATE/feature_request.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
name: "✨ Feature Request"
|
||||
description: "Suggest an idea for this project"
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: use_case
|
||||
attributes:
|
||||
label: "Use Case"
|
||||
description: "Problem solved or value added"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Description"
|
||||
description: "Дополнительная информация описывающая функциональность"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: "Priority Level"
|
||||
options: ["Low", "Medium", "High"]
|
||||
@@ -8,18 +8,27 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||
- name: Add Homebrew to PATH
|
||||
run: |
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
run: |
|
||||
git --version
|
||||
fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
- name: Checkout env repository
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
uses: https://github.com/actions/checkout@v3
|
||||
with:
|
||||
repository: mso7nueh/eatvio_app_env
|
||||
repository: eatvio/eatvio_app_env
|
||||
path: env_repo
|
||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
||||
# Здесь может быть токен любого юзера с доступом в gitea
|
||||
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||
ref: main
|
||||
|
||||
- name: Copy .env file
|
||||
@@ -57,23 +66,24 @@ jobs:
|
||||
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter upgrade
|
||||
flutter --version
|
||||
flutter config --no-enable-swift-package-manager
|
||||
rm -rf ios/Podfile.lock
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub upgrade
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
# Configure Flutter for release mode
|
||||
pod repo update
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod update
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
||||
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
|
||||
FASTLANE_USER: eatvio2023@icloud.com
|
||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: Eatvio App Android Test
|
||||
|
||||
on: [pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
flutter_version: '3.41.2'
|
||||
@@ -152,9 +155,10 @@ jobs:
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
flutter clean
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
flutter clean
|
||||
flutter pub cache repair
|
||||
flutter pub get
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
@@ -162,66 +166,66 @@ jobs:
|
||||
- name: Test
|
||||
run: flutter test --coverage
|
||||
|
||||
- name: Test coverage
|
||||
run: |
|
||||
echo "📊 Analyzing test coverage..."
|
||||
echo ""
|
||||
awk -F: '
|
||||
/^SF:/ {
|
||||
file = $0
|
||||
sub(/^SF:/, "", file)
|
||||
sub(/^[[:space:]]+/, "", file)
|
||||
is_generated = 0
|
||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else {
|
||||
source_files++
|
||||
}
|
||||
}
|
||||
/^LF:/ {
|
||||
if (!is_generated) {
|
||||
total += $2
|
||||
} else {
|
||||
generated_lines += $2
|
||||
}
|
||||
}
|
||||
/^LH:/ {
|
||||
if (!is_generated) {
|
||||
covered += $2
|
||||
} else {
|
||||
generated_covered += $2
|
||||
}
|
||||
}
|
||||
END {
|
||||
print "📁 Files:"
|
||||
printf " Source files: %d\n", source_files
|
||||
printf " Generated files: %d (excluded)\n", generated_files
|
||||
print ""
|
||||
print "📝 Lines:"
|
||||
printf " Total lines: %d\n", total
|
||||
printf " Covered lines: %d\n", covered
|
||||
printf " Uncovered lines: %d\n", total - covered
|
||||
print ""
|
||||
if (total == 0) {
|
||||
print "⚠️ Coverage: 0%"
|
||||
exit 1
|
||||
} else {
|
||||
percentage = (covered / total) * 100
|
||||
printf "✅ Coverage: %.2f%%\n", percentage
|
||||
print ""
|
||||
if (percentage < 70) {
|
||||
printf "❌ FAIL: Coverage is below 70%%\n"
|
||||
exit 1
|
||||
} else {
|
||||
printf "💚 Excellent: Coverage is above 70%%\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
' coverage/lcov.info
|
||||
# - name: Test coverage
|
||||
# run: |
|
||||
# echo "📊 Analyzing test coverage..."
|
||||
# echo ""
|
||||
# awk -F: '
|
||||
# /^SF:/ {
|
||||
# file = $0
|
||||
# sub(/^SF:/, "", file)
|
||||
# sub(/^[[:space:]]+/, "", file)
|
||||
# is_generated = 0
|
||||
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||
# is_generated = 1
|
||||
# generated_files++
|
||||
# } else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||
# is_generated = 1
|
||||
# generated_files++
|
||||
# } else {
|
||||
# source_files++
|
||||
# }
|
||||
# }
|
||||
# /^LF:/ {
|
||||
# if (!is_generated) {
|
||||
# total += $2
|
||||
# } else {
|
||||
# generated_lines += $2
|
||||
# }
|
||||
# }
|
||||
# /^LH:/ {
|
||||
# if (!is_generated) {
|
||||
# covered += $2
|
||||
# } else {
|
||||
# generated_covered += $2
|
||||
# }
|
||||
# }
|
||||
# END {
|
||||
# print "📁 Files:"
|
||||
# printf " Source files: %d\n", source_files
|
||||
# printf " Generated files: %d (excluded)\n", generated_files
|
||||
# print ""
|
||||
# print "📝 Lines:"
|
||||
# printf " Total lines: %d\n", total
|
||||
# printf " Covered lines: %d\n", covered
|
||||
# printf " Uncovered lines: %d\n", total - covered
|
||||
# print ""
|
||||
# if (total == 0) {
|
||||
# print "⚠️ Coverage: 0%"
|
||||
# exit 1
|
||||
# } else {
|
||||
# percentage = (covered / total) * 100
|
||||
# printf "✅ Coverage: %.2f%%\n", percentage
|
||||
# print ""
|
||||
# if (percentage < 70) {
|
||||
# printf "❌ FAIL: Coverage is below 70%%\n"
|
||||
# exit 1
|
||||
# } else {
|
||||
# printf "💚 Excellent: Coverage is above 70%%\n"
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ' coverage/lcov.info
|
||||
|
||||
- name: Build apk
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
||||
backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
/eatvio
|
||||
*.code-workspace
|
||||
@@ -1,7 +1,7 @@
|
||||
# Current Context
|
||||
|
||||
## Project Status
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||
|
||||
## Recent Activity
|
||||
- Memory Bank initialization completed
|
||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
||||
- Architecture and technology stack catalogued
|
||||
|
||||
## Current Focus
|
||||
Ready for development tasks. No active work in progress.
|
||||
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||
|
||||
## Known Issues
|
||||
None documented at initialization.
|
||||
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||
связаны с fixtures/маппингом, не с видеоплеером.
|
||||
|
||||
## Next Steps
|
||||
Awaiting user direction for:
|
||||
|
||||
2
Makefile
2
Makefile
@@ -10,4 +10,4 @@ build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
|
||||
@@ -69,6 +69,9 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
implementation platform('com.google.firebase:firebase-bom:34.4.0')
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||
|
||||
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@@ -4,6 +4,12 @@
|
||||
-keep class org.bouncycastle.** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
|
||||
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
|
||||
-dontwarn com.hiennv.flutter_callkit_incoming.**
|
||||
|
||||
-keep class com.pravera.flutter_foreground_task.** { *; }
|
||||
-dontwarn com.pravera.flutter_foreground_task.**
|
||||
|
||||
-keep class io.flutter.** { *; }
|
||||
-dontwarn io.flutter.**
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
@@ -13,6 +17,16 @@
|
||||
android:usesCleartextTraffic="true"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<service
|
||||
android:name=".EatvioFcmMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||
tools:node="remove" />
|
||||
<service
|
||||
android:name=".HmsPushService"
|
||||
android:exported="false">
|
||||
@@ -33,7 +47,7 @@
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:supportsPictureInPicture="true"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
@@ -64,12 +78,7 @@
|
||||
<data android:host="eatvio.com" />
|
||||
<data android:host="www.eatvio.com" />
|
||||
|
||||
<!-- Development/Subdomains -->
|
||||
<data android:host="*.eatvio.ru" />
|
||||
<data android:host="*.eatvio.com" />
|
||||
|
||||
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
|
||||
<data android:pathPattern="/.*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
@@ -77,6 +86,77 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<!-- The incoming notification's content intent and full-screen intent both open
|
||||
CallkitIncomingActivity. Replace it with a bridge: a tap on an unlocked phone
|
||||
routes to the same accept flow as Answer; on a locked/dark screen it shows a
|
||||
ringing screen that wakes the device. Own task affinity keeps the plugin's
|
||||
CLEAR_TASK flag from killing MainActivity. -->
|
||||
<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:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation" />
|
||||
|
||||
<activity-alias
|
||||
android:name="com.hiennv.flutter_callkit_incoming.CallkitIncomingActivity"
|
||||
android:targetActivity=".EatvioCallkitIncomingBridgeActivity"
|
||||
android:exported="true"
|
||||
android:taskAffinity=""
|
||||
android:allowTaskReparenting="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="orientation"
|
||||
android:permission="${applicationId}.PERMISSION_CALL">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||
tools:node="remove" />
|
||||
|
||||
<activity
|
||||
android:name=".EatvioCallkitTransparentBridgeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/TranslucentTheme"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<activity-alias
|
||||
android:name="com.hiennv.flutter_callkit_incoming.TransparentActivity"
|
||||
android:targetActivity=".EatvioCallkitTransparentBridgeActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/TranslucentTheme"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:noHistory="true"
|
||||
android:launchMode="singleTop" />
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false"
|
||||
android:stopWithTask="true" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
@@ -88,5 +168,168 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- SBP bank packages for СБП payment detection -->
|
||||
<package android:name="ru.sberbankmobile" />
|
||||
<package android:name="com.idamob.tinkoff.android" />
|
||||
<package android:name="ru.vtb24.mobilebanking.android" />
|
||||
<package android:name="ru.alfabank.mobile.android" />
|
||||
<package android:name="ru.raiffeisennews" />
|
||||
<package android:name="com.openbank" />
|
||||
<package android:name="ru.gazprombank.android.mobilebank.app" />
|
||||
<package android:name="com.logo.mbanking" />
|
||||
<package android:name="ru.sovcomcard.halva.v1" />
|
||||
<package android:name="ru.rosbank.android" />
|
||||
<package android:name="ru.rshb.dbo" />
|
||||
<package android:name="ru.mkb.mobile" />
|
||||
<package android:name="ru.ucb.android" />
|
||||
<package android:name="ru.unicredit.android" />
|
||||
<package android:name="ru.skbbank.ib" />
|
||||
<package android:name="ru.gebank.ib" />
|
||||
<package android:name="ru.avangard.sbpent" />
|
||||
<package android:name="ru.elplat.elplat2" />
|
||||
<package android:name="com.bifit.rncbbeta" />
|
||||
<package android:name="ru.ftc.faktura.expobank" />
|
||||
<package android:name="com.bssys.vbrrretail" />
|
||||
<package android:name="ru.wildberries.razz" />
|
||||
<package android:name="ru.artsofte.russiafl" />
|
||||
<package android:name="ru.ftc.faktura.multibank" />
|
||||
<package android:name="ru.bank_hlynov.xbank" />
|
||||
<package android:name="ru.ftc.faktura.vesta" />
|
||||
<package android:name="com.isimplelab.ibank.solidarnost" />
|
||||
<package android:name="com.bssys.roscapretail" />
|
||||
<package android:name="ru.ftc.faktura.kbhmb" />
|
||||
<package android:name="ru.mts.money" />
|
||||
<package android:name="ru.ftc.faktura.pskb" />
|
||||
<package android:name="ru.ftc.faktura.nskbl" />
|
||||
<package android:name="ru.akbars.mobile" />
|
||||
<package android:name="ru.ftc.faktura.rostfinance" />
|
||||
<package android:name="ru.ilb.bbmobile" />
|
||||
<package android:name="ru.mcbankrus.MCBankRus" />
|
||||
<package android:name="ru.albank.online.aebit" />
|
||||
<package android:name="com.bifit.mobile.citizen.icbru" />
|
||||
<package android:name="ru.ftc.faktura.orbank" />
|
||||
<package android:name="ru.ftc.faktura.agropromkredit" />
|
||||
<package android:name="com.energobank.digital" />
|
||||
<package android:name="com.bifit.mobile.citizen.kbnk" />
|
||||
<package android:name="ru.ftc.faktura.sdm" />
|
||||
<package android:name="com.bifit.mobile.citizen.mbb" />
|
||||
<package android:name="ru.ftc.faktura.absolutbank" />
|
||||
<package android:name="ru.ftc.faktura.akcept" />
|
||||
<package android:name="ru.faktura.etbank.person" />
|
||||
<package android:name="ru.ftc.faktura.razvitiestolica" />
|
||||
<package android:name="ru.ftc.faktura.resokreditbank" />
|
||||
<package android:name="ru.yoo.money" />
|
||||
<package android:name="ru.bspb" />
|
||||
<package android:name="com.isimplelab.ibank.kubankredit" />
|
||||
<package android:name="com.tochka.bank" />
|
||||
<package android:name="com.bssys.novikomretail" />
|
||||
<package android:name="ru.rosdorbank.physical" />
|
||||
<package android:name="ru.ftc.faktura.primsoc" />
|
||||
<package android:name="ru.ftc.faktura.banksaratov" />
|
||||
<package android:name="ru.ftc.faktura.tavrich" />
|
||||
<package android:name="com.bifit.mobile.citizen.thbank" />
|
||||
<package android:name="ru.ftc.faktura.kremlevskiy" />
|
||||
<package android:name="ru.ftc.faktura.tkbbank" />
|
||||
<package android:name="ru.chelyabinvestbank.investpay" />
|
||||
<package android:name="ru.ftc.faktura.derzhava" />
|
||||
<package android:name="ru.nbd.android" />
|
||||
<package android:name="ru.sngb.dbo.client.android" />
|
||||
<package android:name="ru.ftc.faktura.united" />
|
||||
<package android:name="ru.ftc.faktura.venetsbank" />
|
||||
<package android:name="ru.letobank.Prometheus" />
|
||||
<package android:name="ru.simpls.brs2.mobbank" />
|
||||
<package android:name="com.bifit.dvbank" />
|
||||
<package android:name="ru.ftc.faktura.intesabank" />
|
||||
<package android:name="com.mmonline.mobile" />
|
||||
<package android:name="cb.ibank.vuz" />
|
||||
<package android:name="ru.uralprombank.mobilebanknew.googleplay" />
|
||||
<package android:name="ru.ftc.faktura.nsbank" />
|
||||
<package android:name="ru.emb.android" />
|
||||
<package android:name="ru.ftc.faktura.maritimebank" />
|
||||
<package android:name="com.credituralbank.CUBmobilenew" />
|
||||
<package android:name="com.isimplelab.isimpleceo.uglemet" />
|
||||
<package android:name="com.bssys.stroylesretail" />
|
||||
<package android:name="ru.ftc.faktura.iturup" />
|
||||
<package android:name="ru.ftc.faktura.finbank" />
|
||||
<package android:name="ru.faktura.gaztransbank.person" />
|
||||
<package android:name="com.isimplelab.ibank.chelind" />
|
||||
<package android:name="ru.ftc.faktura.nokss" />
|
||||
<package android:name="ru.ftc.faktura.vlbb" />
|
||||
<package android:name="ru.ftc.faktura.kbb" />
|
||||
<package android:name="ru.faktura.avtograd.person" />
|
||||
<package android:name="ru.ftc.faktura.forshtadt" />
|
||||
<package android:name="ru.ftc.faktura.solidbank" />
|
||||
<package android:name="ru.ftc.faktura.alexbank" />
|
||||
<package android:name="ru.ftc.faktura.akibank" />
|
||||
<package android:name="ru.ftc.faktura.nipbank" />
|
||||
<package android:name="ru.ftc.faktura.alefbank" />
|
||||
<package android:name="com.bifit.vfbank" />
|
||||
<package android:name="com.bssys.uralfdretail" />
|
||||
<package android:name="com.snb.online" />
|
||||
<package android:name="com.bifit.mobile.citizen.okbank" />
|
||||
<package android:name="com.tatsotsbank.dbomobile" />
|
||||
<package android:name="ru.vtkbank.android" />
|
||||
<package android:name="ru.faktura.jtbank.person" />
|
||||
<package android:name="ru.ftc.faktura.ns" />
|
||||
<package android:name="ru.ftc.faktura.zemskybank" />
|
||||
<package android:name="com.bssys.aversretail" />
|
||||
<package android:name="ru.rusnarbank.correqts.retail" />
|
||||
<package android:name="ru.ftc.faktura.baikalinvestbank" />
|
||||
<package android:name="ru.sbi.android" />
|
||||
<package android:name="com.metkombank.mobile" />
|
||||
<package android:name="com.isimplelab.ionic.kazan.fl" />
|
||||
<package android:name="com.intervale.sbpas" />
|
||||
<package android:name="com.bifit.mobile.citizen.zarech" />
|
||||
<package android:name="ru.centrinvest.mobilebanking2018" />
|
||||
<package android:name="com.mifors.izhcombank" />
|
||||
<package android:name="com.bifit.pmobile.isbank" />
|
||||
<package android:name="ru.zenit.android" />
|
||||
<package android:name="ru.briginvest.sense" />
|
||||
<package android:name="com.bifit.mobile.citizen.cfb" />
|
||||
<package android:name="com.isimplelab.ionic.gorbank.prod" />
|
||||
<package android:name="ru.ftc.faktura.moscombank" />
|
||||
<package android:name="com.timerbank.retail" />
|
||||
<package android:name="com.mob.android" />
|
||||
<package android:name="com.citibank.mobile.ru" />
|
||||
<package android:name="com.bifit.atbbank" />
|
||||
<package android:name="ru.mw" />
|
||||
<package android:name="ru.bankuralsib.mb.android" />
|
||||
<package android:name="com.banksoyuz.artsofte" />
|
||||
<package android:name="ru.rubank.ubsmobile" />
|
||||
<package android:name="cb.ibank" />
|
||||
<package android:name="ru.ipb.ubsmobile" />
|
||||
<package android:name="com.openwaygroup.ic.panda.poidem" />
|
||||
<package android:name="com.bifit.mobile.citizen.tenderbank" />
|
||||
<package android:name="ru.otpbank.mobile" />
|
||||
<package android:name="ru.krk.ubsmobile" />
|
||||
<package android:name="ru.homecredit.mycredit" />
|
||||
<package android:name="cz.bsc.rc" />
|
||||
<package android:name="com.bifit.mobile.citizen.belsocbank" />
|
||||
<package android:name="com.hicebank.android" />
|
||||
<package android:name="com.bpc.crossplatform_trading.bpc_trading" />
|
||||
<package android:name="com.bifit.nrb" />
|
||||
<package android:name="com.bifit.mobile.citizen.moskb" />
|
||||
<package android:name="ru.isfront.android.kt" />
|
||||
<package android:name="ru.ftc.faktura.novobank" />
|
||||
<package android:name="com.smp.android" />
|
||||
<package android:name="com.bifit.mobile.citizen.nkbank" />
|
||||
<package android:name="com.isimplelab.ibank.toyota" />
|
||||
<package android:name="com.bifit.mobile.private.bbr" />
|
||||
<package android:name="ru.ftc.faktura.lanta" />
|
||||
<package android:name="com.yandex.bank" />
|
||||
<package android:name="ru.ftc.faktura.bgfbank" />
|
||||
<package android:name="com.idamobile.android.LockoBank" />
|
||||
<package android:name="com.bifit.mobile.citizen.MCBank" />
|
||||
<package android:name="com.cetelem.cetelem_android" />
|
||||
<package android:name="com.bssys.gutaretail" />
|
||||
<package android:name="ru.ftc.faktura.siab" />
|
||||
<package android:name="com.isimplelab.ionic.round.prod" />
|
||||
<package android:name="com.priobank.prio" />
|
||||
<package android:name="com.inbank.mobilebank" />
|
||||
<package android:name="com.isimplelab.isimplemobile.payjet" />
|
||||
<package android:name="ru.ftc.faktura.agroros" />
|
||||
<package android:name="com.bifit.mobile.citizen.invb" />
|
||||
<package android:name="com.compassplus.mobicash.customer" />
|
||||
<package android:name="ru.finambank.app" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -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,42 @@
|
||||
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 (IncomingCallRingtonePolicy.actionStopsRingtone(action)) {
|
||||
VideoCallPushBridge.silenceIncomingCall(context)
|
||||
}
|
||||
|
||||
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,329 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.hiennv.flutter_callkit_incoming.AppUtils
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||
import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager
|
||||
import com.hiennv.flutter_callkit_incoming.ImageLoaderProvider
|
||||
import com.hiennv.flutter_callkit_incoming.Utils
|
||||
import com.hiennv.flutter_callkit_incoming.widgets.RippleRelativeLayout
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.abs
|
||||
import com.hiennv.flutter_callkit_incoming.R as CallkitR
|
||||
|
||||
/// Target of the incoming notification's content intent and full-screen intent.
|
||||
///
|
||||
/// Unlocked, lit screen: the user tapped the notification body, route it to the same
|
||||
/// accept flow as the Answer button.
|
||||
///
|
||||
/// Locked or dark screen: Android fired the full-screen intent on its own, so show a
|
||||
/// ringing screen that wakes the device and lets the user answer or decline.
|
||||
class EatvioCallkitIncomingBridgeActivity : Activity() {
|
||||
companion object {
|
||||
private const val TAG = "EatvioIncomingBridge"
|
||||
private const val WAKE_LOCK_TAG = "eatvio:incoming_call"
|
||||
private const val DEFAULT_RING_DURATION_MS = 45_000L
|
||||
private const val MIN_RING_SCREEN_MS = 1_000L
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var ringingScreen: WeakReference<EatvioCallkitIncomingBridgeActivity>? = null
|
||||
|
||||
/**
|
||||
* Closes the ringing screen once the call stopped ringing: answered or declined
|
||||
* from the notification, timed out, or ended by the caller.
|
||||
*/
|
||||
fun dismissRingingScreen() {
|
||||
mainHandler.post {
|
||||
ringingScreen?.get()?.closeRingingScreen()
|
||||
ringingScreen = null
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldShowRingingScreen(keyguardLocked: Boolean, screenInteractive: Boolean): Boolean {
|
||||
return keyguardLocked || !screenInteractive
|
||||
}
|
||||
}
|
||||
|
||||
private var callData: Bundle? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var ringTimeout: Runnable? = null
|
||||
private var actionTaken = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA)
|
||||
if (data == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
callData = data
|
||||
|
||||
if (!launchedOnLockedOrDarkScreen()) {
|
||||
Log.i(TAG, "tap on notification body: accept")
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||
finish()
|
||||
@Suppress("DEPRECATION")
|
||||
overridePendingTransition(0, 0)
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "full-screen intent on locked/dark screen: show ringing screen")
|
||||
showRingingScreen(data)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
val data = intent.extras?.getBundle(CallkitConstants.EXTRA_CALLKIT_INCOMING_DATA) ?: return
|
||||
if (ringingScreen?.get() !== this) return
|
||||
callData = data
|
||||
bindCallData(data)
|
||||
scheduleRingTimeout(data)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
cancelRingTimeout()
|
||||
releaseWakeLock()
|
||||
if (ringingScreen?.get() === this) {
|
||||
ringingScreen = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
// The ringing screen is dismissed by Answer / Decline / timeout only.
|
||||
}
|
||||
|
||||
private fun launchedOnLockedOrDarkScreen(): Boolean {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
return shouldShowRingingScreen(
|
||||
keyguardLocked = keyguardManager.isKeyguardLocked,
|
||||
screenInteractive = powerManager.isInteractive,
|
||||
)
|
||||
}
|
||||
|
||||
private fun showRingingScreen(data: Bundle) {
|
||||
ringingScreen = WeakReference(this)
|
||||
|
||||
setTheme(CallkitR.style.CallkitIncomingTheme)
|
||||
requestedOrientation = if (Utils.isTablet(this)) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
} else {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setTurnScreenOn(true)
|
||||
setShowWhenLocked(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED,
|
||||
)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
}
|
||||
|
||||
acquireWakeLock(ringDuration(data))
|
||||
VideoCallPushBridge.keepRingingOnFullScreen()
|
||||
|
||||
setContentView(CallkitR.layout.activity_callkit_incoming)
|
||||
bindCallData(data)
|
||||
scheduleRingTimeout(data)
|
||||
}
|
||||
|
||||
private fun bindCallData(data: Bundle) {
|
||||
val background: ImageView = findViewById(CallkitR.id.ivBackground)
|
||||
val ripple: RippleRelativeLayout = findViewById(CallkitR.id.llBackgroundAnimation)
|
||||
val callerName: TextView = findViewById(CallkitR.id.tvNameCaller)
|
||||
val callerNumber: TextView = findViewById(CallkitR.id.tvNumber)
|
||||
val logo: ImageView = findViewById(CallkitR.id.ivLogo)
|
||||
val avatar: ImageView = findViewById(CallkitR.id.ivAvatar)
|
||||
val actions: LinearLayout = findViewById(CallkitR.id.llAction)
|
||||
val acceptButton: ImageView = findViewById(CallkitR.id.ivAcceptCall)
|
||||
val acceptLabel: TextView = findViewById(CallkitR.id.tvAccept)
|
||||
val declineButton: ImageView = findViewById(CallkitR.id.ivDeclineCall)
|
||||
val declineLabel: TextView = findViewById(CallkitR.id.tvDecline)
|
||||
|
||||
ripple.layoutParams.height = Utils.getScreenWidth() + Utils.getStatusBarHeight(this)
|
||||
ripple.startRippleAnimation()
|
||||
(actions.layoutParams as? MarginLayoutParams)?.let { params ->
|
||||
params.setMargins(0, 0, 0, Utils.getNavigationBarHeight(this))
|
||||
actions.layoutParams = params
|
||||
}
|
||||
|
||||
callerName.text = data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, "")
|
||||
callerNumber.visibility = View.INVISIBLE
|
||||
logo.visibility = View.INVISIBLE
|
||||
|
||||
val textColor = parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR), Color.WHITE)
|
||||
callerName.setTextColor(textColor)
|
||||
acceptLabel.setTextColor(textColor)
|
||||
declineLabel.setTextColor(textColor)
|
||||
background.setBackgroundColor(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR), Color.parseColor("#0955fa")),
|
||||
)
|
||||
|
||||
val avatarUrl = data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, "")
|
||||
if (!avatarUrl.isNullOrEmpty()) {
|
||||
avatar.visibility = View.VISIBLE
|
||||
@Suppress("DEPRECATION", "UNCHECKED_CAST")
|
||||
val headers = data.getSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS) as? HashMap<String, Any?>
|
||||
try {
|
||||
ImageLoaderProvider.loadImage(this, avatarUrl, headers, CallkitR.drawable.ic_default_avatar, avatar)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to load caller avatar", error)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.getInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) > 0) {
|
||||
acceptButton.setImageResource(CallkitR.drawable.ic_video)
|
||||
}
|
||||
acceptButton.background = AppUtils.createCircleDrawable(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_ACCEPT_COLOR), Color.parseColor("#4CAF50")),
|
||||
)
|
||||
declineButton.background = AppUtils.createCircleDrawable(
|
||||
parseColor(data.getString(CallkitConstants.EXTRA_CALLKIT_DECLINE_COLOR), Color.parseColor("#F44336")),
|
||||
)
|
||||
|
||||
val acceptText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "")
|
||||
acceptLabel.text = if (acceptText.isNullOrEmpty()) getString(CallkitR.string.text_accept) else acceptText
|
||||
val declineText = data.getString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "")
|
||||
declineLabel.text = if (declineText.isNullOrEmpty()) getString(CallkitR.string.text_decline) else declineText
|
||||
|
||||
acceptButton.setOnClickListener { onAcceptClick() }
|
||||
declineButton.setOnClickListener { onDeclineClick() }
|
||||
}
|
||||
|
||||
private fun onAcceptClick() {
|
||||
val data = callData ?: return
|
||||
if (actionTaken) return
|
||||
actionTaken = true
|
||||
cancelRingTimeout()
|
||||
Log.i(TAG, "ringing screen: accept")
|
||||
|
||||
// Same order as the plugin's own incoming activity: dispatch the accept, then ask
|
||||
// the keyguard to unlock so MainActivity (which cannot show over the keyguard)
|
||||
// becomes visible as soon as the user unlocks.
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_ACCEPT, data)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
if (keyguardManager.isKeyguardLocked) {
|
||||
keyguardManager.requestDismissKeyguard(this, null)
|
||||
}
|
||||
}
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun onDeclineClick() {
|
||||
val data = callData ?: return
|
||||
if (actionTaken) return
|
||||
actionTaken = true
|
||||
cancelRingTimeout()
|
||||
Log.i(TAG, "ringing screen: decline")
|
||||
|
||||
CallKitIntentBridge.dispatchCallKitAction(this, CallkitConstants.ACTION_CALL_DECLINE, data)
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun closeRingingScreen() {
|
||||
if (isFinishing) return
|
||||
cancelRingTimeout()
|
||||
finishRingingTask()
|
||||
}
|
||||
|
||||
private fun finishRingingTask() {
|
||||
if (isFinishing) return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
finishAndRemoveTask()
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ringDuration(data: Bundle): Long {
|
||||
val duration = data.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, DEFAULT_RING_DURATION_MS)
|
||||
return if (duration > 0L) duration else DEFAULT_RING_DURATION_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* The notification was posted before this screen appeared, so ring only for the
|
||||
* remaining part of the call duration.
|
||||
*/
|
||||
private fun scheduleRingTimeout(data: Bundle) {
|
||||
cancelRingTimeout()
|
||||
val now = System.currentTimeMillis()
|
||||
val startedAt = data.getLong(CallkitNotificationManager.EXTRA_TIME_START_CALL, now)
|
||||
val remaining = (ringDuration(data) - abs(now - startedAt)).coerceAtLeast(MIN_RING_SCREEN_MS)
|
||||
val timeout = Runnable {
|
||||
Log.i(TAG, "ringing screen: timed out")
|
||||
closeRingingScreen()
|
||||
}
|
||||
ringTimeout = timeout
|
||||
mainHandler.postDelayed(timeout, remaining)
|
||||
}
|
||||
|
||||
private fun cancelRingTimeout() {
|
||||
ringTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
ringTimeout = null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun acquireWakeLock(durationMs: Long) {
|
||||
try {
|
||||
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val lock = powerManager.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
PowerManager.ON_AFTER_RELEASE,
|
||||
WAKE_LOCK_TAG,
|
||||
)
|
||||
lock.acquire(durationMs)
|
||||
wakeLock = lock
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to acquire wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
val lock = wakeLock ?: return
|
||||
wakeLock = null
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "failed to release wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseColor(value: String?, fallback: Int): Int {
|
||||
if (value.isNullOrBlank()) return fallback
|
||||
return try {
|
||||
Color.parseColor(value)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,36 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Data-only call pushes are delivered only to this service.
|
||||
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||
// not see them. Show the incoming UI here for both foreground and background.
|
||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EatvioFCM"
|
||||
}
|
||||
}
|
||||
@@ -20,17 +20,23 @@ class HmsPushService : HmsMessageService() {
|
||||
|
||||
if (message == null) return
|
||||
|
||||
// Берём action_url из data-поля, если оно есть.
|
||||
val actionUrl = message.dataOfMap["action_url"]
|
||||
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||
VideoCallPushBridge.dismissAllIncomingCalls(this)
|
||||
return
|
||||
}
|
||||
|
||||
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
|
||||
if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
|
||||
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
|
||||
return
|
||||
}
|
||||
|
||||
val actionUrl = message.dataOfMap["action_url"]
|
||||
showNotification(message, actionUrl)
|
||||
}
|
||||
|
||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||
val context = applicationContext
|
||||
|
||||
// Интент для запуска Flutter-активити.
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
if (!actionUrl.isNullOrBlank()) {
|
||||
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
|
||||
val channelId = "hms_push_channel"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
||||
.setContentText(message.notification?.body ?: "")
|
||||
.setAutoCancel(true)
|
||||
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
|
||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
/**
|
||||
* Call actions that must stop the incoming ringtone.
|
||||
* Accept is included: answering the call is not the same as ending it,
|
||||
* but the ringing player is a separate object from the in-call audio.
|
||||
*/
|
||||
internal object IncomingCallRingtonePolicy {
|
||||
fun actionStopsRingtone(action: String): Boolean {
|
||||
return action.contains("ACTION_CALL_ACCEPT") ||
|
||||
action.contains("ACTION_CALL_DECLINE") ||
|
||||
action.contains("ACTION_CALL_ENDED") ||
|
||||
action.contains("ACTION_CALL_TIMEOUT")
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,119 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Rational
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import com.huawei.hms.api.ConnectionResult
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
import com.huawei.hms.common.ApiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
var isInForeground: Boolean = false
|
||||
private set
|
||||
}
|
||||
|
||||
private val channelName = "com.eatvioapp/hms_push"
|
||||
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
|
||||
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var videoCallPipChannel: MethodChannel? = null
|
||||
private var videoCallPushChannel: MethodChannel? = null
|
||||
private var videoCallPipEnabled: Boolean = false
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
isInForeground = true
|
||||
captureCallKitLaunchIntent(intent)
|
||||
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||
scheduleDeliverPendingCallKitAccept()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
isInForeground = false
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"dismissIncomingCalls" -> {
|
||||
try {
|
||||
VideoCallPushBridge.dismissAllIncomingCalls(applicationContext)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
"showIncomingCall" -> {
|
||||
try {
|
||||
val args = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
val data = HashMap<String, String>()
|
||||
for ((key, value) in args) {
|
||||
val name = key as? String ?: continue
|
||||
if (value == null) continue
|
||||
data[name] = value.toString()
|
||||
}
|
||||
VideoCallPushBridge.showIncomingCall(applicationContext, data)
|
||||
result.success(null)
|
||||
} catch (e: Exception) {
|
||||
result.error("SHOW_INCOMING_CALL_FAILED", e.message, null)
|
||||
}
|
||||
}
|
||||
"drainPendingCallKitAccept" -> {
|
||||
result.success(PendingCallKitAcceptBridge.drain())
|
||||
}
|
||||
"peekPendingCallKitAccept" -> {
|
||||
result.success(PendingCallKitAcceptBridge.peekPending())
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
videoCallPipChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setPipEnabled" -> {
|
||||
videoCallPipEnabled = call.arguments as? Boolean ?: false
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isHmsAvailable" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val available = try {
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
runOnUiThread {
|
||||
result.success(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
@@ -61,10 +153,30 @@ class MainActivity: FlutterActivity() {
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == 907135003) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
val message = e.message.orEmpty()
|
||||
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,17 +187,67 @@ class MainActivity: FlutterActivity() {
|
||||
|
||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||
handleIntentForActionUrl(intent)
|
||||
captureCallKitLaunchIntent(intent)
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
enterVideoCallPictureInPictureIfNeeded()
|
||||
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
captureCallKitLaunchIntent(intent)
|
||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||
handleIntentForActionUrl(intent)
|
||||
}
|
||||
|
||||
private fun captureCallKitLaunchIntent(intent: Intent?) {
|
||||
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
|
||||
|
||||
android.util.Log.i(
|
||||
"VideoCallPush",
|
||||
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
|
||||
)
|
||||
|
||||
PendingCallKitAcceptBridge.store(payload)
|
||||
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
|
||||
scheduleDeliverPendingCallKitAccept()
|
||||
}
|
||||
|
||||
private fun scheduleDeliverPendingCallKitAccept() {
|
||||
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
|
||||
deliver = { deliverPendingCallKitAcceptToFlutter() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun deliverPendingCallKitAcceptToFlutter() {
|
||||
val payload = PendingCallKitAcceptBridge.peek() ?: return
|
||||
val channel = videoCallPushChannel
|
||||
if (channel == null) {
|
||||
android.util.Log.w(
|
||||
"VideoCallPush",
|
||||
"deliverPendingCallKitAcceptToFlutter: channel not ready",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
android.util.Log.i(
|
||||
"VideoCallPush",
|
||||
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
|
||||
)
|
||||
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||
}
|
||||
|
||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Вариант 1: action_url лежит в extras.
|
||||
val fromExtras = intent.extras?.getString("action_url")
|
||||
|
||||
@@ -101,4 +263,19 @@ class MainActivity: FlutterActivity() {
|
||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||
}
|
||||
|
||||
private fun enterVideoCallPictureInPictureIfNeeded() {
|
||||
if (!videoCallPipEnabled) return
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (isInPictureInPictureMode) return
|
||||
|
||||
try {
|
||||
val params = PictureInPictureParams.Builder()
|
||||
.setAspectRatio(Rational(2, 3))
|
||||
.build()
|
||||
enterPictureInPictureMode(params)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,499 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
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.CallkitEventCallback
|
||||
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
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
object VideoCallPushBridge {
|
||||
private const val TAG = "VideoCallPushBridge"
|
||||
private const val ANDROID_ACCENT_COLOR = "#2787F5"
|
||||
private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L
|
||||
private const val WAKE_LOCK_TAG = "eatvio:incoming_call_ring"
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val playbackLock = Any()
|
||||
private val playbackGeneration = AtomicInteger(0)
|
||||
|
||||
private var activeSoundManager: CallkitSoundPlayerManager? = null
|
||||
private var activeNotificationManager: CallkitNotificationManager? = null
|
||||
private var activeCallBundle: Bundle? = null
|
||||
private var storedAppContext: Context? = null
|
||||
private var ringtoneTimeout: Runnable? = null
|
||||
private var ringWakeLock: PowerManager.WakeLock? = null
|
||||
private var callbackRegistered = false
|
||||
|
||||
/**
|
||||
* The plugin stops only the sound player owned by [FlutterCallkitIncomingPlugin].
|
||||
* Incoming pushes start a different player, so accept/decline never reached it
|
||||
* and the ringtone kept looping until the process died.
|
||||
*/
|
||||
private val incomingCallEventCallback = object : CallkitEventCallback {
|
||||
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||
val context = storedAppContext ?: return
|
||||
Log.i(TAG, "incoming call event=$event, stopping ringtone")
|
||||
silenceIncomingCall(context)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
storedAppContext = appContext
|
||||
ensureIncomingCallCallbackRegistered()
|
||||
|
||||
val callData = buildCallDataBundle(data)
|
||||
if (callData == null) {
|
||||
Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data")
|
||||
return
|
||||
}
|
||||
|
||||
val callId = callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||
val seenGeneration = playbackGeneration.get()
|
||||
Log.i(TAG, "showIncomingCall: conversation=$callId")
|
||||
|
||||
// Ringtone.play() and stop() must run on the same thread. FCM delivers
|
||||
// pushes on a background thread, while Answer runs on the main thread.
|
||||
// A second show for the same conversation must not bump the generation:
|
||||
// that would cancel the timeout of the call that is already ringing.
|
||||
mainHandler.post {
|
||||
synchronized(playbackLock) {
|
||||
if (seenGeneration != playbackGeneration.get()) {
|
||||
Log.i(TAG, "showIncomingCall: skipped after silence")
|
||||
return@post
|
||||
}
|
||||
val alreadyRinging = activeCallBundle?.getString(CallkitConstants.EXTRA_CALLKIT_ID)
|
||||
if (!callId.isNullOrBlank() && callId == alreadyRinging) {
|
||||
Log.i(TAG, "showIncomingCall: already ringing $callId")
|
||||
return@post
|
||||
}
|
||||
val generation = playbackGeneration.incrementAndGet()
|
||||
startIncomingCallLocked(appContext, callData, generation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the ringtone that [showIncomingCall] started and removes the incoming
|
||||
* notification. Safe to call more than once.
|
||||
*/
|
||||
fun silenceIncomingCall(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
storedAppContext = appContext
|
||||
Log.i(TAG, "silenceIncomingCall")
|
||||
playbackGeneration.incrementAndGet()
|
||||
val stop = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
stopPlaybackLocked(appContext)
|
||||
}
|
||||
}
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
stop.run()
|
||||
} else {
|
||||
mainHandler.post(stop)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin's sound player stops on ACTION_SCREEN_OFF. The ringing screen turns
|
||||
* the display on itself, so keep ringing through that transition.
|
||||
*/
|
||||
fun keepRingingOnFullScreen() {
|
||||
synchronized(playbackLock) {
|
||||
activeSoundManager?.keepRingingOnFullScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissAllIncomingCalls(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val calls = getDataActiveCalls(appContext)
|
||||
Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}")
|
||||
playbackGeneration.incrementAndGet()
|
||||
|
||||
val dismiss = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
val notificationManager = resolveNotificationManager(appContext)
|
||||
val bundles = calls.map { it.toBundle() }
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
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()
|
||||
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||
}
|
||||
}
|
||||
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
dismiss.run()
|
||||
} else {
|
||||
mainHandler.post(dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureIncomingCallCallbackRegistered() {
|
||||
if (callbackRegistered) return
|
||||
FlutterCallkitIncomingPlugin.registerEventCallback(incomingCallEventCallback)
|
||||
callbackRegistered = true
|
||||
}
|
||||
|
||||
private fun startIncomingCallLocked(appContext: Context, callData: Bundle, generation: Int) {
|
||||
stopPlaybackLocked(appContext)
|
||||
|
||||
val soundManager = CallkitSoundPlayerManager(appContext)
|
||||
val notificationManager = CallkitNotificationManager(appContext, soundManager)
|
||||
activeSoundManager = soundManager
|
||||
activeNotificationManager = notificationManager
|
||||
activeCallBundle = callData
|
||||
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)
|
||||
}
|
||||
|
||||
val durationMs = callData.getLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45_000L)
|
||||
wakeScreenLocked(appContext, if (durationMs > 0L) durationMs else 45_000L)
|
||||
if (durationMs > 0L) {
|
||||
val timeout = Runnable {
|
||||
synchronized(playbackLock) {
|
||||
if (generation != playbackGeneration.get()) return@Runnable
|
||||
Log.i(TAG, "incoming ringtone timed out")
|
||||
stopPlaybackLocked(appContext)
|
||||
}
|
||||
}
|
||||
ringtoneTimeout = timeout
|
||||
mainHandler.postDelayed(timeout, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the screen on while the call rings. The full-screen intent of the
|
||||
* notification does the same only when the system grants it; the wake lock
|
||||
* works without any grant, so the lock screen with the call card lights up.
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun wakeScreenLocked(appContext: Context, durationMs: Long) {
|
||||
releaseScreenWakeLockLocked()
|
||||
try {
|
||||
val powerManager = appContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val lock = powerManager.newWakeLock(
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or
|
||||
PowerManager.ACQUIRE_CAUSES_WAKEUP or
|
||||
PowerManager.ON_AFTER_RELEASE,
|
||||
WAKE_LOCK_TAG,
|
||||
)
|
||||
lock.acquire(durationMs)
|
||||
ringWakeLock = lock
|
||||
Log.i(TAG, "wakeScreen: screen on for ${durationMs}ms, interactive=${powerManager.isInteractive}")
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "wakeScreen: failed to acquire wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseScreenWakeLockLocked() {
|
||||
val lock = ringWakeLock ?: return
|
||||
ringWakeLock = null
|
||||
try {
|
||||
if (lock.isHeld) lock.release()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "wakeScreen: failed to release wake lock", error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopPlaybackLocked(appContext: Context) {
|
||||
cancelRingtoneTimeoutLocked()
|
||||
releaseScreenWakeLockLocked()
|
||||
val notificationManager = activeNotificationManager
|
||||
val bundle = activeCallBundle
|
||||
stopIncomingRingtone()
|
||||
if (notificationManager != null && bundle != null) {
|
||||
try {
|
||||
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopPlayback: failed to dismiss incoming notification", error)
|
||||
}
|
||||
}
|
||||
activeSoundManager = null
|
||||
activeNotificationManager = null
|
||||
activeCallBundle = null
|
||||
EatvioCallkitIncomingBridgeActivity.dismissRingingScreen()
|
||||
}
|
||||
|
||||
private fun cancelRingtoneTimeoutLocked() {
|
||||
ringtoneTimeout?.let { mainHandler.removeCallbacks(it) }
|
||||
ringtoneTimeout = 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() {
|
||||
try {
|
||||
activeSoundManager?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: active player", error)
|
||||
}
|
||||
try {
|
||||
FlutterCallkitIncomingPlugin.getInstance()
|
||||
?.getCallkitSoundPlayerManager()
|
||||
?.stop()
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "stopIncomingRingtone: plugin player", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 1)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IncomingCallRingtonePolicyTest {
|
||||
@Test
|
||||
fun answerDeclineEndAndTimeoutStopTheRingtone() {
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ACCEPT",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_DECLINE",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_ENDED",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_TIMEOUT",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incomingAndStartActionsKeepTheRingtonePlaying() {
|
||||
assertFalse(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_INCOMING",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
IncomingCallRingtonePolicy.actionStopsRingtone(
|
||||
"com.hiennv.flutter_callkit_incoming.ACTION_CALL_START",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
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 |
@@ -44,11 +44,11 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
## dart: PermissionGroup.camera
|
||||
'PERMISSION_CAMERA=1',
|
||||
## dart: PermissionGroup.microphone
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
## dart: PermissionGroup.mediaLibrary
|
||||
'PERMISSION_MEDIA_LIBRARY=1',
|
||||
]
|
||||
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -37,6 +38,7 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
938EE8FB5BAA359AAEDEFFF8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -115,6 +117,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||
);
|
||||
@@ -300,6 +303,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -397,7 +401,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -548,7 +552,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -587,7 +591,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,20 +31,43 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</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>
|
||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string></string>
|
||||
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>voip</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
@@ -74,6 +97,57 @@
|
||||
<string>yandexauth</string>
|
||||
<string>yandexauth2</string>
|
||||
<string>yandexauth4</string>
|
||||
<!-- SBP bank schemes (top ~50 by popularity, iOS 15+ caps at 50) -->
|
||||
<string>bank100000000111</string>
|
||||
<string>bank100000000004</string>
|
||||
<string>bank100000000005</string>
|
||||
<string>bank100000000008</string>
|
||||
<string>bank100000000007</string>
|
||||
<string>bank100000000015</string>
|
||||
<string>bank100000000001</string>
|
||||
<string>bank100000000010</string>
|
||||
<string>bank100000000013</string>
|
||||
<string>bank100000000012</string>
|
||||
<string>bank100000000020</string>
|
||||
<string>bank100000000025</string>
|
||||
<string>bank100000000030</string>
|
||||
<string>bank100000000030_new</string>
|
||||
<string>bank100000000003</string>
|
||||
<string>bank100000000043</string>
|
||||
<string>bank100000000028</string>
|
||||
<string>bank100000000086</string>
|
||||
<string>bank100000000011</string>
|
||||
<string>bank100000000044</string>
|
||||
<string>bank100000000049</string>
|
||||
<string>bank100000000500</string>
|
||||
<string>bank100000000095</string>
|
||||
<string>bank100000000900</string>
|
||||
<string>bank100000000056</string>
|
||||
<string>bank100000000053</string>
|
||||
<string>bank100000000121</string>
|
||||
<string>bank100000000082</string>
|
||||
<string>bank100000000127</string>
|
||||
<string>bank100000000017</string>
|
||||
<string>bank100000000087</string>
|
||||
<string>bank100000000052</string>
|
||||
<string>bank100000000006</string>
|
||||
<string>bank100000000098</string>
|
||||
<string>bank100000000092</string>
|
||||
<string>bank100000000229</string>
|
||||
<string>bank100000000027</string>
|
||||
<string>bank100000000080</string>
|
||||
<string>bank100000000122</string>
|
||||
<string>bank100000000124</string>
|
||||
<string>bank100000000118</string>
|
||||
<string>bank100000000159</string>
|
||||
<string>bank100000000146</string>
|
||||
<string>bank100000000069</string>
|
||||
<string>bank100000000140</string>
|
||||
<string>bank100000000047</string>
|
||||
<string>bank100000000099</string>
|
||||
<string>bank100000000135</string>
|
||||
<string>bank100000000139</string>
|
||||
<string>bank100000000022</string>
|
||||
</array>
|
||||
<key>YAClientId</key>
|
||||
<string>5399b03636104c6d920b87c1ad329254</string>
|
||||
@@ -87,6 +161,14 @@
|
||||
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Eatvio</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>eatvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<array>
|
||||
<string>applinks:eatvio.ru</string>
|
||||
<string>applinks:www.eatvio.ru</string>
|
||||
<string>applinks:eatvio.com</string>
|
||||
<string>applinks:www.eatvio.com</string>
|
||||
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
86
ios/Runner/SceneDelegate.swift
Normal file
86
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import YandexLoginSDK
|
||||
|
||||
/// iOS 27 requires a UIScene lifecycle. Flutter 3.38 does not yet have
|
||||
/// `FlutterImplicitEngineDelegate`, so channels and URL handling are wired
|
||||
/// here after the scene creates the Flutter view.
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
override func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
super.scene(scene, willConnectTo: session, options: connectionOptions)
|
||||
registerCallChannels()
|
||||
handleConnectionOptions(connectionOptions)
|
||||
}
|
||||
|
||||
override func sceneDidBecomeActive(_ scene: UIScene) {
|
||||
super.sceneDidBecomeActive(scene)
|
||||
registerCallChannels()
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
var handledCustomURL = false
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
for context in URLContexts {
|
||||
if appDelegate.handleOpenURL(
|
||||
context.url,
|
||||
sourceApplication: context.options.sourceApplication
|
||||
) {
|
||||
handledCustomURL = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !handledCustomURL {
|
||||
super.scene(scene, openURLContexts: URLContexts)
|
||||
}
|
||||
}
|
||||
|
||||
override func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
if YXLSdk.shared.processUserActivity(userActivity) {
|
||||
return
|
||||
}
|
||||
super.scene(scene, continue: userActivity)
|
||||
}
|
||||
|
||||
private func registerCallChannels() {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
appDelegate.registerCallChannels(from: flutterViewController)
|
||||
}
|
||||
|
||||
private var flutterViewController: FlutterViewController? {
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene else { continue }
|
||||
for sceneWindow in windowScene.windows {
|
||||
if let controller = sceneWindow.rootViewController as? FlutterViewController {
|
||||
return controller
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func handleConnectionOptions(_ options: UIScene.ConnectionOptions) {
|
||||
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
for context in options.urlContexts {
|
||||
_ = appDelegate.handleOpenURL(
|
||||
context.url,
|
||||
sourceApplication: context.options.sourceApplication
|
||||
)
|
||||
}
|
||||
for activity in options.userActivities {
|
||||
if YXLSdk.shared.processUserActivity(activity) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,16 +75,24 @@ platform :ios do
|
||||
xcodeproj: "Runner.xcodeproj",
|
||||
build_number: flutter_build_number
|
||||
)
|
||||
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "Runner.xcodeproj",
|
||||
team_id: "VL68C36HYS",
|
||||
targets: ["Runner"],
|
||||
code_sign_identity: "Apple Distribution",
|
||||
profile_name: "match AppStore com.eatvioapp.app 1781098260"
|
||||
)
|
||||
|
||||
build_app(
|
||||
workspace: "Runner.xcworkspace",
|
||||
scheme: "Runner",
|
||||
export_method: "app-store",
|
||||
codesigning_identity: "Apple Distribution",
|
||||
# codesigning_identity: "Apple Distribution",
|
||||
export_team_id: "VL68C36HYS"
|
||||
)
|
||||
|
||||
|
||||
|
||||
app_store_connect_api_key(
|
||||
key_id: "325JS9JCZH",
|
||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||
@@ -92,6 +100,7 @@ platform :ios do
|
||||
duration: 1200,
|
||||
in_house: false
|
||||
)
|
||||
|
||||
|
||||
begin
|
||||
upload_to_testflight(
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/views/cubit/views_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_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/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -30,19 +35,50 @@ class AppConfig {
|
||||
|
||||
void setBaseUrl(String baseUrls) {
|
||||
_baseUrl = baseUrls;
|
||||
_persistBaseUrl(baseUrls);
|
||||
}
|
||||
|
||||
void _persistBaseUrl(String baseUrl) {
|
||||
if (!sl.isRegistered<Prefs>()) {
|
||||
return;
|
||||
}
|
||||
final normalized = baseUrl.trim();
|
||||
if (normalized.isEmpty) {
|
||||
unawaited(sl<Prefs>().remove(PrefsKeys.customBaseUrl));
|
||||
return;
|
||||
}
|
||||
unawaited(sl<Prefs>().setString(PrefsKeys.customBaseUrl, normalized));
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<VideoCallSessionCubit>.value(
|
||||
value: sl<VideoCallSessionCubit>(),
|
||||
),
|
||||
BlocProvider<NewMessagesCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NewMessagesCubit>(),
|
||||
),
|
||||
BlocProvider<NotificationsCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<NotificationsCubit>(),
|
||||
),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<ProfileAvatarCubit>(),
|
||||
),
|
||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(
|
||||
lazy: false,
|
||||
create: (context) => sl<InternetConnectionCubit>(),
|
||||
),
|
||||
BlocProvider<MarketingCubit>(
|
||||
lazy: true,
|
||||
create: (context) => sl<MarketingCubit>(),
|
||||
),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
@@ -54,5 +90,7 @@ class AppConfig {
|
||||
];
|
||||
|
||||
/// Поддерживаемые локали
|
||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
||||
List<Locale> get supportedLocales => [
|
||||
const Locale.fromSubtags(languageCode: 'ru'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -17,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Класс для инициализации приложения
|
||||
class AppInitializer {
|
||||
|
||||
/// Инициализация всех сервисов приложения
|
||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||
await _initializeEnvironment();
|
||||
@@ -41,24 +37,21 @@ class AppInitializer {
|
||||
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем работу
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
VideoCallFcmHandler.registerBackgroundHandler();
|
||||
}
|
||||
|
||||
/// Инициализация системы внедрения зависимостей
|
||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
||||
static Future<void> _initializeDependencyInjection({
|
||||
required AppBuildType buildType,
|
||||
}) async {
|
||||
await di.init(buildType: buildType);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,21 @@ class AppRouter {
|
||||
return '${InfoByOrderScreen.path}?planId=$consultationId';
|
||||
}
|
||||
|
||||
static String? _resolveDeepLinkUri(Uri uri) {
|
||||
/// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
|
||||
static String? _resolveFinanceAlfaReturnDeepLink(List<String> effectiveSegments) {
|
||||
if (effectiveSegments.length != 4 ||
|
||||
effectiveSegments[1] != 'alfa' ||
|
||||
effectiveSegments[2] != 'return') {
|
||||
return null;
|
||||
}
|
||||
final orderId = effectiveSegments[3];
|
||||
if (orderId.isEmpty) return null;
|
||||
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
|
||||
return '${RouteConstants.chatScreen}?orderId=$orderId';
|
||||
}
|
||||
|
||||
/// Разбирает deep link URI в внутренний route target (или null, если host не наш).
|
||||
static String? resolveDeepLinkTarget(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
@@ -189,6 +203,10 @@ class AppRouter {
|
||||
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
||||
if (target != null) return target;
|
||||
break;
|
||||
case 'finance':
|
||||
final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
|
||||
if (target != null) return target;
|
||||
break;
|
||||
}
|
||||
|
||||
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
|
||||
@@ -196,7 +214,7 @@ class AppRouter {
|
||||
}
|
||||
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
return _resolveDeepLinkUri(state.uri);
|
||||
return resolveDeepLinkTarget(state.uri);
|
||||
}
|
||||
|
||||
static void navigateByDeepLinkUrl(String url) {
|
||||
@@ -230,7 +248,7 @@ class AppRouter {
|
||||
);
|
||||
}
|
||||
|
||||
final target = _resolveDeepLinkUri(uri);
|
||||
final target = resolveDeepLinkTarget(uri);
|
||||
FlutterNativeSplash.remove();
|
||||
if (target != null) {
|
||||
appRouter.go(target);
|
||||
|
||||
16
lib/core/cached_base_url.dart
Normal file
16
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
|
||||
/// Подставляет сохранённый base URL до создания HTTP-клиента.
|
||||
void applyCachedBaseUrl() {
|
||||
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||
return;
|
||||
}
|
||||
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||
if (cached == null || cached.isEmpty) {
|
||||
return;
|
||||
}
|
||||
sl<AppConfig>().setBaseUrl(cached);
|
||||
}
|
||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
||||
const newMessagesChannelName = 'private-new-chat-messages';
|
||||
const newNotificationsChannelName = 'private-message-events';
|
||||
const directChatChannelName = 'private-direct-chat';
|
||||
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||
const thresholdForPagination = 150;
|
||||
|
||||
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/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
||||
@@ -51,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
|
||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
@@ -71,6 +74,18 @@ class CubitRegistrations {
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(
|
||||
videoCallRepository: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(
|
||||
videoCallManager: sl(),
|
||||
systemPipCoordinator: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
@@ -107,7 +122,7 @@ class CubitRegistrations {
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
@@ -135,6 +150,9 @@ class CubitRegistrations {
|
||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<ConsultationPaymentCubit>(
|
||||
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
||||
|
||||
@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
||||
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
||||
@@ -74,6 +76,8 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
|
||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
||||
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
||||
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
||||
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
||||
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
||||
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
||||
@@ -35,7 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
|
||||
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
||||
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// Регистрация всех Service в системе внедрения зависимостей
|
||||
@@ -75,6 +78,12 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||
() => VideoCallSystemPipCoordinator(),
|
||||
);
|
||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||
@@ -82,6 +91,7 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Rules for which errors are sent to crash reporting.
|
||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
||||
if (error == null) return false;
|
||||
if (error is String) return false;
|
||||
if (error is SocketException) return false;
|
||||
if (isHmsClientUnavailable(error)) return false;
|
||||
|
||||
if (error is DioException) {
|
||||
return _shouldReportDio(error);
|
||||
@@ -44,6 +46,7 @@ class ErrorReportPolicy {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.transformTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.unknown:
|
||||
|
||||
@@ -3,58 +3,57 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||
|
||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
||||
FirebaseApi({
|
||||
required ErrorReporter errorReporter,
|
||||
NotificationPermissionRequester? notificationPermissionRequester,
|
||||
}) : _errorReporter = errorReporter,
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
return credentials.token;
|
||||
}
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
try {
|
||||
await _firebaseMessaging.requestPermission().timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||
try {
|
||||
await _notificationPermissionRequester.requestOnce(
|
||||
() => _firebaseMessaging.requestPermission(),
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -72,14 +71,17 @@ class FirebaseApi {
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -87,6 +89,9 @@ class FirebaseApi {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
@@ -95,30 +100,156 @@ 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 credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token ?? '';
|
||||
if (token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': credentials.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<({String? token, String platform})> _resolvePushCredentials() async {
|
||||
if (Platform.isIOS) {
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
final huaweiFamily = await _isHuaweiDevice();
|
||||
if (huaweiFamily && await _isHmsAvailable()) {
|
||||
final token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
return (
|
||||
token: token,
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getFcmToken() {
|
||||
return _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final brand = info.brand.toLowerCase();
|
||||
final manufacturer = info.manufacturer.toLowerCase();
|
||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
||||
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHmsAvailable() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||
return available ?? false;
|
||||
} catch (e, st) {
|
||||
log('HMS availability check failed: $e', stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHuaweiPushTokenNative() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||
return token;
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
if (!isHmsClientUnavailable(e)) {
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/cached_base_url.dart';
|
||||
import 'package:eatvio_app/core/di/app_config_registration.dart';
|
||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||
@@ -21,6 +22,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
|
||||
// Ожидание инициализации асинхронных зависимостей
|
||||
await sl.getAsync<Prefs>();
|
||||
// До создания Dio и любых запросов к бэкенду.
|
||||
applyCachedBaseUrl();
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
@@ -32,5 +35,7 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
CubitRegistrations.registerAll();
|
||||
|
||||
// Регистрация WebSocket менеджера
|
||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl(), errorReporter: sl()));
|
||||
sl.registerLazySingleton<WsManager>(
|
||||
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class NotificationPermissionRequester {
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||
final inFlight = _inFlight;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
final future = requestPermission();
|
||||
_inFlight = future;
|
||||
try {
|
||||
await future;
|
||||
} finally {
|
||||
if (identical(_inFlight, future)) {
|
||||
_inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -50,7 +57,41 @@ class PushNavigationHandler {
|
||||
}
|
||||
|
||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||
final data = message.data;
|
||||
final data = Map<String, dynamic>.from(message.data);
|
||||
|
||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||
if (endedPayload != null) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: endedPayload.conversationUuid,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final videoCallPayload = VideoCallPushPayload.tryParse(data);
|
||||
if (videoCallPayload != null) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(
|
||||
VideoCallNotificationNavigator.openIncomingCall(
|
||||
conversationUuid: videoCallPayload.conversationUuid,
|
||||
counterpartName: videoCallPayload.counterpartName,
|
||||
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
|
||||
localParticipantName: videoCallPayload.localParticipantName,
|
||||
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
|
||||
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final actionUrl = data['action_url'] as String?;
|
||||
if (actionUrl == null || actionUrl.isEmpty) {
|
||||
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
||||
enum PrefsKeys {
|
||||
hasAnonymousTokenBeenSent,
|
||||
welcomeScreenWasShowed,
|
||||
registrationDate,
|
||||
marketingBottomSheetHasBeenShown,
|
||||
isEmailVerificationSkipped,
|
||||
customBaseUrl,
|
||||
}
|
||||
|
||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
bool isHuaweiFamilyBrand({
|
||||
required String brand,
|
||||
required String manufacturer,
|
||||
}) {
|
||||
final normalizedBrand = brand.toLowerCase();
|
||||
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||
return normalizedBrand.contains('huawei') ||
|
||||
normalizedBrand.contains('honor') ||
|
||||
normalizedManufacturer.contains('huawei') ||
|
||||
normalizedManufacturer.contains('honor');
|
||||
}
|
||||
|
||||
/// HMS Core is missing or rejects this app.
|
||||
///
|
||||
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||
bool isHmsClientUnavailable(Object error) {
|
||||
if (error is! PlatformException) return false;
|
||||
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||
return message.contains('907135003') ||
|
||||
message.contains('client api invalid');
|
||||
}
|
||||
|
||||
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||
String pushPlatform({
|
||||
required bool isIos,
|
||||
required bool huaweiFamily,
|
||||
required bool hmsAvailable,
|
||||
required bool hasHmsToken,
|
||||
}) {
|
||||
if (isIos) return 'ios';
|
||||
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||
return 'android';
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
@@ -23,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||
@@ -182,7 +184,14 @@ class OtherRoutes {
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.createPlanScreen,
|
||||
name: 'createPlanScreen',
|
||||
builder: (context, state) {
|
||||
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||
return CreatePlanScreen(plan: plan);
|
||||
},
|
||||
),
|
||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||
@@ -211,13 +220,23 @@ class OtherRoutes {
|
||||
name: 'chatScreen',
|
||||
builder: (context, state) {
|
||||
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
||||
if (uuidFromQuery != null) {
|
||||
return ChatScreen(uuid: uuidFromQuery, sendInitialMessage: false);
|
||||
final orderIdFromQuery = state.uri.queryParameters['orderId'];
|
||||
if (uuidFromQuery != null || orderIdFromQuery != null) {
|
||||
return ChatScreen(
|
||||
uuid: uuidFromQuery,
|
||||
orderId: orderIdFromQuery,
|
||||
sendInitialMessage: false,
|
||||
);
|
||||
}
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final uuid = data['uuid'] as String;
|
||||
final uuid = data['uuid'] as String?;
|
||||
final orderId = data['orderId'] as String?;
|
||||
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
||||
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
|
||||
return ChatScreen(
|
||||
uuid: uuid,
|
||||
orderId: orderId,
|
||||
sendInitialMessage: sendInitialMessage,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -234,7 +253,13 @@ class OtherRoutes {
|
||||
return VideoCallScreen(
|
||||
conversationUuid: extra['conversationUuid'] as String,
|
||||
counterpartName: extra['counterpartName'] as String?,
|
||||
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
|
||||
counterpartUserId: extra['counterpartUserId'] as int?,
|
||||
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
|
||||
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
|
||||
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
|
||||
localParticipantName: extra['localParticipantName'] as String?,
|
||||
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||
onHangUp: extra['onHangUp'] as void Function()?,
|
||||
);
|
||||
@@ -248,7 +273,15 @@ class OtherRoutes {
|
||||
),
|
||||
|
||||
// Consultant routes
|
||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.becomeConsultantScreen,
|
||||
name: 'becomeConsultantScreen',
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, dynamic>?;
|
||||
final slug = data?['slug'] as String?;
|
||||
return BecomeConsultantScreen(slug: slug);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||
name: 'stepperBecomeConsultantScreen',
|
||||
@@ -256,7 +289,12 @@ class OtherRoutes {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final slug = data['slug'] as String?;
|
||||
final isCanceled = data['isCanceled'] as bool?;
|
||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
||||
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
|
||||
return StepperBecomeConsultantScreen(
|
||||
slug: slug,
|
||||
isCanceled: isCanceled,
|
||||
isNewRegistration: isNewRegistration,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||
@@ -329,3 +367,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 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 {
|
||||
|
||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||
String inlineSvgCss(String svg) {
|
||||
final styleBlockPattern = RegExp(
|
||||
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final classProps = <String, Map<String, String>>{};
|
||||
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||
final css = match.group(1) ?? '';
|
||||
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||
for (final rule in rulePattern.allMatches(css)) {
|
||||
final className = rule.group(1)!;
|
||||
final body = rule.group(2)!;
|
||||
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||
for (final declaration in body.split(';')) {
|
||||
final colon = declaration.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = declaration.substring(0, colon).trim();
|
||||
final value = declaration.substring(colon + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = svg.replaceAll(styleBlockPattern, '');
|
||||
if (classProps.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||
Match match,
|
||||
) {
|
||||
final classes = match
|
||||
.group(2)!
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((name) => name.isNotEmpty);
|
||||
final attrs = <String, String>{};
|
||||
for (final className in classes) {
|
||||
final props = classProps[className];
|
||||
if (props != null) {
|
||||
attrs.addAll(props);
|
||||
}
|
||||
}
|
||||
if (attrs.isEmpty) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final attrString = attrs.entries
|
||||
.map((entry) => '${entry.key}="${entry.value}"')
|
||||
.join(' ');
|
||||
return ' $attrString';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Joins an API media path with [baseUrl].
|
||||
///
|
||||
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||
String joinBaseUrl(String baseUrl, String path) {
|
||||
final normalized = path.toLowerCase();
|
||||
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||
return path;
|
||||
}
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
@@ -26,20 +26,47 @@ class WsManager {
|
||||
PrivateChannel? _privateNotificationChannel;
|
||||
PrivateChannel? _privateMessagesChannel;
|
||||
PrivateChannel? _privateDirectChatChannel;
|
||||
PresenceChannel? _presenceDirectChatChannel;
|
||||
|
||||
StreamSubscription? _presenceMemberAddedSubscription;
|
||||
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||
bool _presenceChannelEventsBound = false;
|
||||
|
||||
StreamSubscription? _lifecycleSubscription;
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||
///
|
||||
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||
StreamController<void> _reconnectController = StreamController<void>.broadcast();
|
||||
|
||||
Stream<ChannelReadEvent>? _directChatStream;
|
||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
|
||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||
|
||||
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||
///
|
||||
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||
/// сообщения (S5).
|
||||
Stream<void> get reconnected => _reconnectController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_reconnectController.isClosed) {
|
||||
_reconnectController = StreamController<void>.broadcast();
|
||||
}
|
||||
|
||||
if (client == null) {
|
||||
try {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
@@ -98,8 +125,7 @@ class WsManager {
|
||||
|
||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
handleConnectionEstablished();
|
||||
}
|
||||
});
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
@@ -163,6 +189,7 @@ class WsManager {
|
||||
}
|
||||
|
||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateDirectChatChannel = client?.privateChannel(
|
||||
'$directChatChannelName.$chatId',
|
||||
@@ -173,6 +200,109 @@ class WsManager {
|
||||
},
|
||||
),
|
||||
);
|
||||
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -185,12 +315,40 @@ class WsManager {
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
}
|
||||
|
||||
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||
///
|
||||
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||
/// Pusher-клиента.
|
||||
void handleConnectionEstablished() {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
_presenceDirectChatChannel?.subscribe();
|
||||
_reconnectController.add(null);
|
||||
}
|
||||
|
||||
void subscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
}
|
||||
|
||||
void unsubscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -204,10 +362,23 @@ class WsManager {
|
||||
client = null;
|
||||
userId = null;
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
unsubscribePresenceDirectChatChannel();
|
||||
_privateMessagesChannel?.unsubscribe();
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
_privateDirectChatChannel = null;
|
||||
_privateMessagesChannel = null;
|
||||
_privateNotificationChannel = null;
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _PresenceDirectChatListener {
|
||||
const _PresenceDirectChatListener({
|
||||
required this.counterpartUserId,
|
||||
required this.onCounterpartOnlineChanged,
|
||||
});
|
||||
|
||||
final int counterpartUserId;
|
||||
final void Function(bool isOnline) onCounterpartOnlineChanged;
|
||||
}
|
||||
|
||||
@@ -6,22 +6,34 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.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/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class CustomPopup extends StatelessWidget {
|
||||
final bool isAuthor;
|
||||
final bool isAuthorAdmin;
|
||||
final ComplainedContent? complainedContentType;
|
||||
final String path;
|
||||
final String? slug;
|
||||
@@ -29,11 +41,14 @@ class CustomPopup extends StatelessWidget {
|
||||
final FeedBlogPostModel? feedBlogPostModel;
|
||||
final Recipe? recipe;
|
||||
final Diet? diet;
|
||||
final PlanModel? plan;
|
||||
final String userSlug;
|
||||
final VoidCallback? onPostsChanged;
|
||||
|
||||
const CustomPopup({
|
||||
super.key,
|
||||
required this.isAuthor,
|
||||
this.isAuthorAdmin = false,
|
||||
this.complainedContentType,
|
||||
required this.path,
|
||||
this.id,
|
||||
@@ -41,9 +56,14 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
required this.userSlug
|
||||
this.plan,
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
|
||||
bool get _isAdminAuthor =>
|
||||
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -76,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
||||
Future<void> _openEditor(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||
if (result != null && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _reloadEditedContent(BuildContext context) {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<RecipesCubit>().loadRecipesList();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<DietsCubit>().loadDiets();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<MyPlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _showsEditAction() {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
return feedBlogPostModel != null || id != null;
|
||||
case ComplainedContent.recipe:
|
||||
return recipe != null;
|
||||
case ComplainedContent.diet:
|
||||
return diet != null;
|
||||
case ComplainedContent.plan:
|
||||
return plan != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||
final entries = <PopupMenuEntry>[];
|
||||
|
||||
void addItem(String icon, String text, Function() onTap) {
|
||||
@@ -100,11 +216,9 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
}
|
||||
if (_showsEditAction()) {
|
||||
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||
_openEditor(context);
|
||||
});
|
||||
}
|
||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||
@@ -114,7 +228,14 @@ class CustomPopup extends StatelessWidget {
|
||||
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||
return;
|
||||
}
|
||||
showSuccessToast('Пост удален, обновите ленту');
|
||||
onPostsChanged?.call();
|
||||
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||
if (context.mounted) {
|
||||
try {
|
||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||
} catch (_) {}
|
||||
}
|
||||
showSuccessToast('Пост удален');
|
||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||
if (!result && context.mounted) {
|
||||
@@ -154,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!_isAdminAuthor) {
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
addItem(
|
||||
'assets/icons/complain.svg',
|
||||
'Заблокировать автора',
|
||||
|
||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||
|
||||
class NetworkSvgPicture extends StatefulWidget {
|
||||
const NetworkSvgPicture({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||
}
|
||||
|
||||
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||
late final Future<String> _svgFuture = _load(widget.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _svgFuture,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(width: widget.width, height: widget.height);
|
||||
}
|
||||
return SvgPicture.string(
|
||||
snapshot.data!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _load(String url) async {
|
||||
final cached = _inlinedSvgCache[url];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to load SVG from $url');
|
||||
}
|
||||
final processed = inlineSvgCss(response.body);
|
||||
_inlinedSvgCache[url] = processed;
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
@@ -9,7 +10,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/core/comments/comments_cubit/comments_cubit.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/widgets/blog_post_media_content.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/theme/theme_helper.dart';
|
||||
@@ -23,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
const BlogPostScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.highlightedCommentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -33,6 +41,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||
final textEditingController = TextEditingController();
|
||||
int? replyId;
|
||||
int currentGalleryIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,12 +49,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||
child: BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
create: (BuildContext context) => commentsCubit
|
||||
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||
builder: (context, blogPostState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
@@ -73,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
body: const Loader(),
|
||||
);
|
||||
}
|
||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
final bool isLiked =
|
||||
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes =
|
||||
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -99,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
@@ -110,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -118,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
||||
if (isSent) {
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.incrementCommentsState(widget.id);
|
||||
}
|
||||
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
@@ -166,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2
|
||||
? appTheme.blueA400
|
||||
: Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -189,23 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthor:
|
||||
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||
false,
|
||||
isAuthorAdmin:
|
||||
blogPostState.blogPostModel?.author.isAdmin ??
|
||||
false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
feedBlogPostModel: blogPostState.blogPostModel,
|
||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
||||
userSlug:
|
||||
blogPostState.blogPostModel?.author.authorSlug ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra:
|
||||
blogPostState.blogPostModel?.author.authorSlug,
|
||||
),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -214,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorProfileIcon ??
|
||||
'',
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorName ??
|
||||
'',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
||||
(blogPostState.blogPostModel?.createdAt ??
|
||||
DateTime.now())
|
||||
.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -236,31 +344,99 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
if (blogPostState
|
||||
.blogPostModel
|
||||
?.description
|
||||
.isNotEmpty ??
|
||||
false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText:
|
||||
blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
||||
Builder(
|
||||
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,
|
||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'blog-post-screen-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
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,
|
||||
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 {
|
||||
await context.read<BlogPostCubit>().toggleLike();
|
||||
await context
|
||||
.read<BlogPostCubit>()
|
||||
.toggleLike();
|
||||
// ignore: use_build_context_synchronously
|
||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
||||
final updated = context
|
||||
.read<BlogPostCubit>()
|
||||
.state
|
||||
.blogPostModel;
|
||||
if (updated != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.updateFeedBlogPostList(
|
||||
updated,
|
||||
updated.wasLikedByMe,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -268,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
_iconButton(
|
||||
icon: Icons.reply_rounded,
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) &
|
||||
box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
blogPostState.blogPostModel?.totalViews
|
||||
.toString() ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -290,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 0.4,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -298,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -323,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
||||
InkWell _iconButton({
|
||||
required IconData icon,
|
||||
String? text,
|
||||
Function()? onTap,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap ?? () {},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? appTheme.gray100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -339,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (text != null)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: textColor ?? appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -3,38 +3,195 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||
final BlogPostRepository blogPostRepository;
|
||||
|
||||
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState());
|
||||
BlogPostCreateCubit({required this.blogPostRepository})
|
||||
: super(const BlogPostCreateState());
|
||||
|
||||
void clearTitleError() {
|
||||
if (state.titleError != null || state.formError != null) {
|
||||
emit(state.copyWith(titleError: null, formError: null));
|
||||
}
|
||||
}
|
||||
|
||||
void clearDescriptionError() {
|
||||
if (state.descriptionError != null || state.formError != null) {
|
||||
emit(state.copyWith(descriptionError: null, formError: null));
|
||||
}
|
||||
}
|
||||
|
||||
void clearVideoUrlError() {
|
||||
if (state.videoUrlError != null || state.formError != null) {
|
||||
emit(state.copyWith(videoUrlError: null, formError: null));
|
||||
}
|
||||
}
|
||||
|
||||
void clearImagesError() {
|
||||
if (state.imagesError != null || state.formError != null) {
|
||||
emit(state.copyWith(imagesError: null, formError: null));
|
||||
}
|
||||
}
|
||||
|
||||
void setImagesError(String? error) {
|
||||
if (state.imagesError == error &&
|
||||
state.formError == null &&
|
||||
state.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||
);
|
||||
}
|
||||
|
||||
void resetFormErrors() {
|
||||
if (!state.hasValidationErrors &&
|
||||
!state.isSuccess &&
|
||||
state.errorMessage == null &&
|
||||
!state.isLoading) {
|
||||
return;
|
||||
}
|
||||
emit(const BlogPostCreateState());
|
||||
}
|
||||
|
||||
/// Validates form fields and emits error messages on [BlogPostCreateState].
|
||||
/// Returns `true` when the form is valid.
|
||||
bool validateForm({
|
||||
required String title,
|
||||
required String description,
|
||||
required String videoUrl,
|
||||
required int imageCount,
|
||||
Iterable<String> imagePaths = const [],
|
||||
}) {
|
||||
final titleError = BlogPostFormValidator.title(title);
|
||||
final descriptionError = BlogPostFormValidator.description(description);
|
||||
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
|
||||
final imagesError =
|
||||
BlogPostFormValidator.galleryCount(imageCount) ??
|
||||
BlogPostFormValidator.imagePaths(imagePaths);
|
||||
final formError = BlogPostFormValidator.contentRequiredMessage(
|
||||
title: title,
|
||||
description: description,
|
||||
videoUrl: videoUrl,
|
||||
imageCount: imageCount,
|
||||
);
|
||||
|
||||
final hasFieldErrors =
|
||||
titleError != null ||
|
||||
descriptionError != null ||
|
||||
videoUrlError != null ||
|
||||
imagesError != null;
|
||||
final resolvedFormError = hasFieldErrors ? null : formError;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
titleError: titleError,
|
||||
descriptionError: descriptionError,
|
||||
videoUrlError: videoUrlError,
|
||||
imagesError: imagesError,
|
||||
// Form-level message only when individual fields are fine but content is empty.
|
||||
formError: resolvedFormError,
|
||||
isSuccess: false,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
|
||||
return titleError == null &&
|
||||
descriptionError == null &&
|
||||
videoUrlError == null &&
|
||||
imagesError == null &&
|
||||
resolvedFormError == null;
|
||||
}
|
||||
|
||||
Future<bool> createBlogPost({
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
List<XFile> images = const [],
|
||||
}) async {
|
||||
final trimmedTitle = title?.trim() ?? '';
|
||||
final trimmedDescription = description?.trim() ?? '';
|
||||
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||
|
||||
final isValid = validateForm(
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
videoUrl: trimmedVideoUrl,
|
||||
imageCount: images.length,
|
||||
imagePaths: images.map((image) => image.path),
|
||||
);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
|
||||
try {
|
||||
emit(const BlogPostCreateState.loading());
|
||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(
|
||||
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||
);
|
||||
await blogPostRepository.createBlogPost(
|
||||
title: trimmedTitle.isEmpty ? null : trimmedTitle,
|
||||
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
|
||||
description: trimmedDescription.isEmpty ? null : trimmedDescription,
|
||||
images: images,
|
||||
);
|
||||
emit(const BlogPostCreateState(isSuccess: true));
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||
await reportFrom(error, stackTrace, this);
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
|
||||
Future<bool> updateBlogPost({
|
||||
required int id,
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
List<int> existingMediaIds = const [],
|
||||
List<XFile> newImages = const [],
|
||||
List<int> mediaIdsToDelete = const [],
|
||||
}) async {
|
||||
final trimmedTitle = title?.trim() ?? '';
|
||||
final trimmedDescription = description?.trim() ?? '';
|
||||
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||
final imageCount = existingMediaIds.length + newImages.length;
|
||||
|
||||
final isValid = validateForm(
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription,
|
||||
videoUrl: trimmedVideoUrl,
|
||||
imageCount: imageCount,
|
||||
imagePaths: newImages.map((image) => image.path),
|
||||
);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
emit(const BlogPostCreateState.loading());
|
||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
||||
emit(const BlogPostCreateState.loaded());
|
||||
emit(
|
||||
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||
);
|
||||
await blogPostRepository.updateBlogPost(
|
||||
id: id,
|
||||
title: trimmedTitle,
|
||||
videoUrl: trimmedVideoUrl,
|
||||
description: trimmedDescription,
|
||||
existingMediaIds: existingMediaIds,
|
||||
newImages: newImages,
|
||||
mediaIdsToDelete: mediaIdsToDelete,
|
||||
);
|
||||
emit(const BlogPostCreateState(isSuccess: true));
|
||||
return true;
|
||||
} catch (error, stackTrace) {
|
||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||
await reportFrom(error, stackTrace, this);
|
||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
||||
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
||||
const factory BlogPostCreateState() = _BlogPostCreateState;
|
||||
const BlogPostCreateState._();
|
||||
|
||||
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
|
||||
const factory BlogPostCreateState({
|
||||
@Default(false) bool isLoading,
|
||||
@Default(false) bool isSuccess,
|
||||
String? errorMessage,
|
||||
String? titleError,
|
||||
String? descriptionError,
|
||||
String? videoUrlError,
|
||||
String? imagesError,
|
||||
String? formError,
|
||||
}) = _BlogPostCreateState;
|
||||
|
||||
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
|
||||
|
||||
const factory BlogPostCreateState.error({
|
||||
required String errorMessage,
|
||||
}) = BlogPostCreateStateError;
|
||||
bool get hasValidationErrors =>
|
||||
titleError != null ||
|
||||
descriptionError != null ||
|
||||
videoUrlError != null ||
|
||||
imagesError != null ||
|
||||
formError != null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,122 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class BlogPostRepository {
|
||||
static const maxGalleryImages = 10;
|
||||
|
||||
final BlogPostService blogPostService;
|
||||
|
||||
BlogPostRepository({
|
||||
required this.blogPostService,
|
||||
});
|
||||
BlogPostRepository({required this.blogPostService});
|
||||
|
||||
Future<void> createBlogPost(
|
||||
Future<void> createBlogPost({
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
XFile? image,
|
||||
) async {
|
||||
final formData = FormData.fromMap({
|
||||
if (title != null && title.isNotEmpty) 'title': title,
|
||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
||||
});
|
||||
|
||||
return blogPostService.createBlogPost(
|
||||
formData: formData,
|
||||
List<XFile> images = const [],
|
||||
}) async {
|
||||
final formData = await _buildFormData(
|
||||
title: title,
|
||||
videoUrl: videoUrl,
|
||||
description: description,
|
||||
newImages: images,
|
||||
);
|
||||
|
||||
return blogPostService.createBlogPost(formData: formData);
|
||||
}
|
||||
|
||||
Future<XFile?> downloadImage(String imageUrl) async {
|
||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final directory = await getTemporaryDirectory();
|
||||
final filePath = '${directory.path}/image.jpg';
|
||||
final file = File(filePath);
|
||||
await file.writeAsBytes(response.bodyBytes);
|
||||
|
||||
return XFile(file.path);
|
||||
Future<void> updateBlogPost({
|
||||
required int id,
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
List<int> existingMediaIds = const [],
|
||||
List<XFile> newImages = const [],
|
||||
List<int> mediaIdsToDelete = const [],
|
||||
}) async {
|
||||
if (mediaIdsToDelete.isNotEmpty) {
|
||||
await Future.wait(
|
||||
mediaIdsToDelete.map(
|
||||
(mediaId) =>
|
||||
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
final formData = await _buildFormData(
|
||||
title: title ?? '',
|
||||
videoUrl: videoUrl ?? '',
|
||||
description: description ?? '',
|
||||
existingMediaIds: existingMediaIds,
|
||||
newImages: newImages,
|
||||
includeExistingMediaIds: true,
|
||||
includeEmptyTextFields: true,
|
||||
);
|
||||
|
||||
return blogPostService.updateBlogPost(id: id, formData: formData);
|
||||
}
|
||||
|
||||
Future<void> updateBlogPost(
|
||||
int id,
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
XFile? image,
|
||||
String? oldImageUrl,
|
||||
) async {
|
||||
final formData = FormData.fromMap({
|
||||
'title': title,
|
||||
'description_md': description,
|
||||
'video_url': videoUrl,
|
||||
'image': image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
|
||||
: oldImageUrl != '' && oldImageUrl != null
|
||||
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
|
||||
: null,
|
||||
});
|
||||
|
||||
return blogPostService.updateBlogPost(
|
||||
id: id,
|
||||
formData: formData,
|
||||
);
|
||||
Future<void> deleteBlogPostMedia({
|
||||
required int postId,
|
||||
required int mediaId,
|
||||
}) async {
|
||||
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||
}
|
||||
|
||||
Future<void> deleteBlogPost(int id) async {
|
||||
await blogPostService.deleteBlogPost(id: id);
|
||||
}
|
||||
|
||||
Future<FormData> _buildFormData({
|
||||
String? title,
|
||||
String? videoUrl,
|
||||
String? description,
|
||||
List<int> existingMediaIds = const [],
|
||||
List<XFile> newImages = const [],
|
||||
bool includeExistingMediaIds = false,
|
||||
bool includeEmptyTextFields = false,
|
||||
}) async {
|
||||
final formData = FormData();
|
||||
|
||||
if (includeEmptyTextFields) {
|
||||
formData.fields.add(MapEntry('title', title ?? ''));
|
||||
formData.fields.add(MapEntry('description_md', description ?? ''));
|
||||
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
|
||||
} else {
|
||||
if (title != null && title.isNotEmpty) {
|
||||
formData.fields.add(MapEntry('title', title));
|
||||
}
|
||||
if (description != null && description.isNotEmpty) {
|
||||
formData.fields.add(MapEntry('description_md', description));
|
||||
}
|
||||
if (videoUrl != null && videoUrl.isNotEmpty) {
|
||||
formData.fields.add(MapEntry('video_url', videoUrl));
|
||||
}
|
||||
}
|
||||
|
||||
if (includeExistingMediaIds) {
|
||||
for (final mediaId in existingMediaIds) {
|
||||
formData.fields.add(
|
||||
MapEntry('existing_media_ids[]', mediaId.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (final image in newImages) {
|
||||
final compressed = await ImageCompressUtility.compressFile(
|
||||
File(image.path),
|
||||
);
|
||||
formData.files.add(
|
||||
MapEntry(
|
||||
'images[]',
|
||||
await MultipartFile.fromFile(compressed.path, filename: image.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
|
||||
abstract class BlogPostService {
|
||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||
|
||||
@POST('/api/blogPosts/create/published')
|
||||
@POST('/api/v2/blogPosts/create/published')
|
||||
@MultiPart()
|
||||
Future<void> createBlogPost({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
Future<void> createBlogPost({@Body() required FormData formData});
|
||||
|
||||
@POST('/api/blogPosts/update/published/{id}')
|
||||
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||
@MultiPart()
|
||||
Future<void> updateBlogPost({
|
||||
@Path('id') required int id,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
@DELETE('/api/blogPosts/delete/{id}')
|
||||
Future<void> deleteBlogPost({
|
||||
@Path('id') required int id,
|
||||
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||
Future<void> deleteBlogPostMedia({
|
||||
@Path('postId') required int postId,
|
||||
@Path('mediaId') required int mediaId,
|
||||
});
|
||||
|
||||
@DELETE('/api/blogPosts/delete/{id}')
|
||||
Future<void> deleteBlogPost({@Path('id') required int id});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
|
||||
abstract final class BlogPostFormValidator {
|
||||
static const minTextLength = 3;
|
||||
static const maxVideoUrlLength = 2048;
|
||||
static const maxGalleryImages = 10;
|
||||
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
|
||||
|
||||
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
|
||||
static const descriptionTooShort =
|
||||
'Описание должно содержать минимум 3 символа';
|
||||
static const contentRequired =
|
||||
'Добавьте заголовок, описание, изображение или ссылку на видео';
|
||||
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
|
||||
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
|
||||
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
|
||||
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
|
||||
static const videoUrlUnsupportedHost =
|
||||
'Поддерживаются только Rutube, VK и OK';
|
||||
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
|
||||
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
|
||||
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
|
||||
|
||||
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||
static String? title(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (text.length < minTextLength) {
|
||||
return titleTooShort;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||
static String? description(String? value) {
|
||||
final text = value?.trim() ?? '';
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (text.length < minTextLength) {
|
||||
return descriptionTooShort;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Empty is allowed; non-empty must match allowed video hosts/paths.
|
||||
static String? videoUrl(String? value) {
|
||||
final url = value?.trim() ?? '';
|
||||
if (url.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (url.length > maxVideoUrlLength) {
|
||||
return videoUrlTooLong;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
return videoUrlInvalid;
|
||||
}
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return videoUrlInvalid;
|
||||
}
|
||||
|
||||
final host = uri.host.toLowerCase();
|
||||
if (!_hostMatchesRootAllowlist(host, const [
|
||||
'vk.com',
|
||||
'vkvideo.ru',
|
||||
'ok.ru',
|
||||
'rutube.ru',
|
||||
])) {
|
||||
return videoUrlUnsupportedHost;
|
||||
}
|
||||
|
||||
final path = uri.path;
|
||||
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
|
||||
if (!_rutubePath.hasMatch(path)) {
|
||||
return videoUrlRutubeFormat;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
|
||||
if (!_vkPath.hasMatch(path)) {
|
||||
return videoUrlVkFormat;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
|
||||
if (!_okPath.hasMatch(path)) {
|
||||
return videoUrlOkFormat;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// At least one of title / description / images / video must be meaningfully filled.
|
||||
static String? contentRequiredMessage({
|
||||
required String title,
|
||||
required String description,
|
||||
required String videoUrl,
|
||||
required int imageCount,
|
||||
}) {
|
||||
final hasTitle = title.trim().length >= minTextLength;
|
||||
final hasDescription = description.trim().length >= minTextLength;
|
||||
final trimmedVideo = videoUrl.trim();
|
||||
final hasVideo =
|
||||
trimmedVideo.isNotEmpty &&
|
||||
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
|
||||
final hasImages = imageCount > 0;
|
||||
|
||||
if (hasTitle || hasDescription || hasVideo || hasImages) {
|
||||
return null;
|
||||
}
|
||||
return contentRequired;
|
||||
}
|
||||
|
||||
static String? galleryCount(int count) {
|
||||
if (count > maxGalleryImages) {
|
||||
return galleryTooLarge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
|
||||
static String? imagePaths(Iterable<String> paths) {
|
||||
for (final path in paths) {
|
||||
final name = path.split(RegExp(r'[\\/]')).last;
|
||||
final dot = name.lastIndexOf('.');
|
||||
if (dot < 0 || dot == name.length - 1) {
|
||||
return imageTypeInvalid;
|
||||
}
|
||||
final ext = name.substring(dot + 1).toLowerCase();
|
||||
if (!allowedImageExtensions.contains(ext)) {
|
||||
return imageTypeInvalid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static final _rutubePath = RegExp(
|
||||
r'^/(video|shorts)/[a-z0-9]+/?$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
|
||||
static final _okPath = RegExp(r'^/video/\d+/?$');
|
||||
|
||||
static bool _hostMatchesRootAllowlist(
|
||||
String host,
|
||||
List<String> allowedRootHosts,
|
||||
) {
|
||||
for (final allowed in allowedRootHosts) {
|
||||
if (host == allowed || host.endsWith('.$allowed')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
State<BottomNavigationBarWidget> createState() =>
|
||||
_BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
colorFilter: isProduction
|
||||
? null
|
||||
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
border: Border.all(
|
||||
color: _devAccentColor.withValues(alpha: 0.6),
|
||||
width: 1.5,
|
||||
),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _devAccentColor.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
final effectiveIndex = indexFromRoute >= 0
|
||||
? indexFromRoute
|
||||
: currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 &&
|
||||
indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
if (mounted) {
|
||||
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
profileModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
final hasUnreadMessages =
|
||||
state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
hasUnreadMessages
|
||||
? state.chatsWithUnreadMessagesCount.toString()
|
||||
: '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
showBadge:
|
||||
state is NotificationsStateLoaded &&
|
||||
state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] ==
|
||||
'/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
|
||||
@@ -31,9 +31,14 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||
// recipient-relative и для второго устройства отправителя всегда false,
|
||||
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||
// только для обратной совместимости парсинга.
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type') String? type,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||
}) = _ChatMessageModel;
|
||||
@@ -42,3 +47,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
}
|
||||
|
||||
enum MessageState { loading, sent, error }
|
||||
|
||||
enum ChatMessageType {
|
||||
@JsonValue('text')
|
||||
text,
|
||||
@JsonValue('system')
|
||||
system,
|
||||
@JsonValue('attachment')
|
||||
attachment,
|
||||
@JsonValue('image')
|
||||
image,
|
||||
@JsonValue('plan_order')
|
||||
planOrder,
|
||||
@JsonValue('video_call_started')
|
||||
videoCallStarted,
|
||||
@JsonValue('video_call_ended')
|
||||
videoCallEnded,
|
||||
@JsonValue('unknown')
|
||||
unknown;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
|
||||
@JsonKey(name: "name") required String name,
|
||||
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
||||
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
||||
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||
}) = _ChatUser;
|
||||
|
||||
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
||||
|
||||
@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
|
||||
required String name,
|
||||
required String slug,
|
||||
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
||||
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||
}) = _ChatUserModel;
|
||||
|
||||
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
||||
|
||||
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
|
||||
abstract class PaymentModel with _$PaymentModel {
|
||||
|
||||
factory PaymentModel({
|
||||
@JsonKey(name: 'checkout_url') required String paymentUrl,
|
||||
@JsonKey(name: 'order_id') String? orderId,
|
||||
@JsonKey(name: 'payment_provider') String? paymentProvider,
|
||||
}) = _PaymentModel;
|
||||
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) => _$PaymentModelFromJson(json);
|
||||
}
|
||||
factory PaymentModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$PaymentModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
@@ -30,17 +31,27 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||
StreamSubscription<void>? _reconnectSubscription;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
Timer? _timer;
|
||||
String? _activeChatUuid;
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
var uuid = const Uuid();
|
||||
|
||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||
ChatRoomCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.cardsRepository,
|
||||
}) : super(const ChatRoomState.loading());
|
||||
|
||||
void setTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
(_) => updateChosenPlanState(),
|
||||
);
|
||||
}
|
||||
|
||||
void cancelTimer() {
|
||||
@@ -51,16 +62,22 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (currentPage < lastPage && !isLoading) {
|
||||
isLoading = true;
|
||||
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
||||
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||
|
||||
try {
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage + 1,
|
||||
);
|
||||
|
||||
isLoading = false;
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
isLoading: isLoading,
|
||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
||||
chatMessages: [
|
||||
...current.chatMessages,
|
||||
...chatMessages.messagesList,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -83,25 +100,57 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
|
||||
void _setCard({required List<CardModel> cards}) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
emit(currentState.copyWith(hasCard: true));
|
||||
}
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initChat(String uuid, {bool sendInitialMessage = false}) async {
|
||||
/// Инициализирует чат по [uuid] либо по [orderId] (resolve → conversation_uuid).
|
||||
///
|
||||
/// При наличии [orderId] сначала вызывается
|
||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||
///
|
||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||
Future<String?> initChat(
|
||||
String? uuid, {
|
||||
String? orderId,
|
||||
bool sendInitialMessage = false,
|
||||
}) async {
|
||||
try {
|
||||
emit(const ChatRoomState.loading());
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
if (chatRoom.chosenPlanId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: chatRoom.chosenPlanId!);
|
||||
|
||||
String? resolvedUuid = uuid;
|
||||
int? resolvedChosenPlanId;
|
||||
|
||||
if (orderId != null && orderId.isNotEmpty) {
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||
orderId: orderId,
|
||||
);
|
||||
resolvedUuid = resolved.conversationUuid;
|
||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage);
|
||||
|
||||
if (resolvedUuid == null || resolvedUuid.isEmpty) {
|
||||
throw ArgumentError('initChat requires uuid or orderId');
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
_activeChatUuid = resolvedUuid;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
resolvedUuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
await _subscribeToChatChannel(uuid: uuid, chatId: chatRoom.id);
|
||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||
_listenToReconnect();
|
||||
emit(
|
||||
ChatRoomState.loaded(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
@@ -111,30 +160,117 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
hasCard: false,
|
||||
),
|
||||
);
|
||||
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
return resolvedUuid;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
typingSubscription?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = null;
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
|
||||
void _listenToReconnect() {
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(refreshCurrentChat());
|
||||
});
|
||||
}
|
||||
|
||||
/// Догружает первую страницу сообщений после восстановления соединения (S5).
|
||||
///
|
||||
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
|
||||
/// сообщения добавляются, существующие не дублируются. Порядок — по
|
||||
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
|
||||
Future<void> refreshCurrentChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null) return;
|
||||
|
||||
try {
|
||||
final page = await chatRepository.getChatMessages(uuid, 1);
|
||||
lastPage = page.meta.lastPage;
|
||||
_emitLoaded((current) {
|
||||
final byId = {
|
||||
for (final message in current.chatMessages) message.id: message,
|
||||
};
|
||||
for (final message in page.messagesList) {
|
||||
byId.putIfAbsent(message.id, () => message);
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return current.copyWith(chatMessages: merged);
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
|
||||
Future<void> refreshChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId =
|
||||
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
|
||||
_emitLoaded(
|
||||
(latest) => latest.copyWith(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
chatMessages: chatMessages.messagesList,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
typingSubscription?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateChosenPlanState() async {
|
||||
try {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId != null) {
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
}
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId == null) {
|
||||
return;
|
||||
}
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -146,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||
if (planId == null) return false;
|
||||
final stringDate = date.format('yyyy-MM-dd');
|
||||
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
|
||||
await chatRepository.choseStartPlanDate(
|
||||
id: planId,
|
||||
date: {'start_date': stringDate},
|
||||
);
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith.chatRoom(
|
||||
chosenPlan: chosenPlan,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -157,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
||||
Future<void> cancelChosenPlan({
|
||||
required String chatId,
|
||||
required int planId,
|
||||
}) async {
|
||||
try {
|
||||
await chatRepository.cancelChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||
@@ -167,93 +313,294 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
bool _emitLoaded(
|
||||
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||
) {
|
||||
if (isClosed) {
|
||||
return false;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
emit(update(currentState));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({
|
||||
required String uuid,
|
||||
required String chatId,
|
||||
}) async {
|
||||
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||
_activeChatUuid = uuid;
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
messagesSubscription = null;
|
||||
typingSubscription = null;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
final currentState = state as ChatRoomStateLoaded;
|
||||
|
||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
||||
// они обрабатываются через ответ API в sendMessage
|
||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
||||
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
||||
});
|
||||
messagesSubscription = wsManager.directChatStream?.listen(
|
||||
_onDirectChatEvent,
|
||||
onError: (Object e, StackTrace st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
},
|
||||
);
|
||||
|
||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||
);
|
||||
EasyDebounce.debounce(
|
||||
'client-typing',
|
||||
const Duration(seconds: 3),
|
||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
||||
() => _emitLoaded(
|
||||
(current) => current.copyWith(isTyping: false, isLoading: isLoading),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||
try {
|
||||
if (addIncomingMessage(
|
||||
ChatMessageModel.fromJson(
|
||||
jsonDecode(event.data) as Map<String, dynamic>,
|
||||
),
|
||||
)) {
|
||||
unawaited(_playIncomingSound());
|
||||
}
|
||||
} catch (e, st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playIncomingSound() async {
|
||||
try {
|
||||
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||
///
|
||||
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||
///
|
||||
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||
/// сообщение, реально добавленное).
|
||||
bool addIncomingMessage(ChatMessageModel message) {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||
// это эхо уже добавленного оптимистичного сообщения).
|
||||
final alreadyExists = currentState.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
);
|
||||
if (alreadyExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final added = _emitLoaded((current) {
|
||||
if (current.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
)) {
|
||||
return current;
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: [message, ...current.chatMessages],
|
||||
isLoading: isLoading,
|
||||
);
|
||||
});
|
||||
if (!added) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||
if (!isOwn) {
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
}
|
||||
|
||||
return !isOwn;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: currentState.chatRoom.users,
|
||||
currentUserId: wsManager.userId,
|
||||
);
|
||||
|
||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
unawaited(
|
||||
_handleCounterpartOnlineChanged(
|
||||
counterpartField:
|
||||
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpart.id,
|
||||
onCounterpartOnlineChanged: _presenceOnlineListener!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleCounterpartOnlineChanged({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
required bool isOnline,
|
||||
}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = counterpartField == _ChatCounterpartField.receiver
|
||||
? currentState.chatRoom.users.receiver
|
||||
: currentState.chatRoom.users.sender;
|
||||
|
||||
if (counterpart.isOnline == isOnline &&
|
||||
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
|
||||
return;
|
||||
}
|
||||
|
||||
_emitCounterpartOnlineStatus(
|
||||
counterpartField: counterpartField,
|
||||
isOnline: true,
|
||||
lastSeenText: '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshCounterpartOnlineStatus({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
}) async {
|
||||
final chatUuid = _activeChatUuid;
|
||||
if (chatUuid == null) return;
|
||||
|
||||
try {
|
||||
final chatRoom = await chatRepository.getChatRoom(chatUuid);
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: chatRoom.users,
|
||||
currentUserId: wsManager.userId,
|
||||
);
|
||||
final field = counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender;
|
||||
|
||||
if (field != counterpartField) return;
|
||||
|
||||
_emitCounterpartOnlineStatus(
|
||||
counterpartField: counterpartField,
|
||||
isOnline: counterpart.isOnline,
|
||||
lastSeenText: counterpart.lastSeenText,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _emitCounterpartOnlineStatus({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
required bool isOnline,
|
||||
required String lastSeenText,
|
||||
}) {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final users = currentState.chatRoom.users;
|
||||
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
|
||||
? users.copyWith(
|
||||
receiver: users.receiver.copyWith(
|
||||
isOnline: isOnline,
|
||||
lastSeenText: lastSeenText,
|
||||
),
|
||||
)
|
||||
: users.copyWith(
|
||||
sender: users.sender.copyWith(
|
||||
isOnline: isOnline,
|
||||
lastSeenText: lastSeenText,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({
|
||||
String? data,
|
||||
List<File>? files,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
final compressedFiles = files != null
|
||||
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||
: null;
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
} catch (_) {}
|
||||
|
||||
// Создаем временное сообщение с состоянием loading
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -messageUuid.hashCode.abs(),
|
||||
data: data ?? '',
|
||||
mediaData: compressedFiles,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: messageUuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages,
|
||||
...current.chatMessages,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
||||
return serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: data,
|
||||
files: compressedFiles,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: messageUuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
@@ -271,21 +618,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
||||
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||
try {
|
||||
if (state is ChatRoomStateLoaded) {
|
||||
final ids = messagesIds.map((e) => e.id).toList();
|
||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
||||
for (var elem in messagesIds) {
|
||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
||||
}
|
||||
return true;
|
||||
if (state is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||
final idSet = messageIds.toSet();
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages
|
||||
.map(
|
||||
(message) => idSet.contains(message.id)
|
||||
? message.copyWith(isRead: true)
|
||||
: message,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
@@ -293,11 +644,52 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceOptimisticMessage({
|
||||
required String messageUuid,
|
||||
required ChatMessageModel serverMessage,
|
||||
}) {
|
||||
_emitLoaded((current) {
|
||||
final sentMessage = serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
);
|
||||
final withoutDuplicate = current.chatMessages.where(
|
||||
(message) =>
|
||||
message.id != serverMessage.id || message.uuid == messageUuid,
|
||||
);
|
||||
final hasOptimistic = withoutDuplicate.any(
|
||||
(message) => message.uuid == messageUuid,
|
||||
);
|
||||
if (!hasOptimistic) {
|
||||
return current.copyWith(
|
||||
chatMessages: [sentMessage, ...withoutDuplicate],
|
||||
);
|
||||
}
|
||||
return current.copyWith(
|
||||
chatMessages: withoutDuplicate.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return sentMessage.copyWith(
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId?.isNotEmpty == true
|
||||
? elem.roomId
|
||||
: serverMessage.roomId,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> getPaymentLink() async {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId == null) return null;
|
||||
final paymentModel = await chatRepository.getPaymentLink(id: planId);
|
||||
final paymentModel = await chatRepository.getPaymentLink(
|
||||
id: planId,
|
||||
paymentProvider: 'alfa-bank',
|
||||
);
|
||||
return paymentModel.paymentUrl;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -337,87 +729,93 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
EasyDebounce.cancel('client-typing');
|
||||
EasyDebounce.cancel('searchChatRoom');
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
typingSubscription?.cancel();
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
await _reconnectSubscription?.cancel();
|
||||
super.close();
|
||||
}
|
||||
|
||||
void deleteMessage(String uuid) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
chatMessages: currentState.chatMessages
|
||||
.where((message) => message.uuid != uuid)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resendMessage({required String uuid, required String userSlug}) async {
|
||||
Future<void> resendMessage({
|
||||
required String uuid,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||
final message = currentState.chatMessages.firstWhereOrNull(
|
||||
(message) => message.uuid == uuid,
|
||||
);
|
||||
if (message != null) {
|
||||
try {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
try {
|
||||
unawaited(
|
||||
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
List<File>? filesToResend;
|
||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||
// Если mediaData содержит File объекты, используем их
|
||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
||||
final firstItem = message.mediaData!.first;
|
||||
if (firstItem is File) {
|
||||
filesToResend = message.mediaData!.cast<File>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -uuid.hashCode.abs(),
|
||||
data: message.data,
|
||||
mediaData: message.mediaData,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: uuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
||||
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера
|
||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return serverMessage.copyWith(
|
||||
uuid: uuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
),
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: message.data,
|
||||
files: filesToResend,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: uuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
@@ -435,3 +833,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ChatCounterpartField { sender, receiver }
|
||||
|
||||
@@ -5,9 +5,14 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.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_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final WsManager wsManager;
|
||||
StreamSubscription? newIncomingMessagesSubscription;
|
||||
StreamSubscription? newLastMessageSubscription;
|
||||
StreamSubscription<void>? reconnectedSubscription;
|
||||
final Set<int> _presentedVideoCallMessageIds = {};
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
|
||||
|
||||
ChatsCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.profileRepository,
|
||||
this.presentIncomingVideoCall,
|
||||
this.dismissIncomingVideoCall,
|
||||
}) : super(ChatsState()) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||
message,
|
||||
) {
|
||||
_setNewLastMessage(message);
|
||||
});
|
||||
}
|
||||
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||
event,
|
||||
) {
|
||||
_handleNewMessageViaSocket(event.data);
|
||||
});
|
||||
|
||||
await reconnectedSubscription?.cancel();
|
||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(getChatRooms());
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
_query = query;
|
||||
emit(state.copyWith(state: BaseState.loading));
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page++;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page = 1;
|
||||
_query = query;
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
try {
|
||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||
emit(state.copyWith(isLoadingMoreChats: true));
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: _query,
|
||||
page: page,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -134,15 +159,20 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: isChatRead
|
||||
? state.chatsWithUnreadMessagesCount - 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||
try {
|
||||
final json = jsonDecode(data);
|
||||
final message = ChatMessageModel.fromJson(json['message']);
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||
if (chatRoom.id == message.roomId) {
|
||||
wasChanged = true;
|
||||
if (chatRoom.unreadMessagesCount == 0) {
|
||||
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||
final isDuplicate =
|
||||
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||
// Собственное сообщение (второе устройство автора) обновляет
|
||||
// lastMessage, но не создаёт непрочитанное (S3).
|
||||
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||
willIncrementUnreadChats = true;
|
||||
}
|
||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
||||
return chatRoom.copyWith(
|
||||
lastMessage: message,
|
||||
unreadMessagesCount: isOwn || isDuplicate
|
||||
? chatRoom.unreadMessagesCount
|
||||
: chatRoom.unreadMessagesCount + 1,
|
||||
);
|
||||
}
|
||||
return chatRoom;
|
||||
}).toList();
|
||||
@@ -165,15 +206,92 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats
|
||||
? state.chatsWithUnreadMessagesCount + 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_presentVideoCallFromMessage(
|
||||
message,
|
||||
isOwn: isOwn,
|
||||
chatRooms: currentChatRooms,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _presentVideoCallFromMessage(
|
||||
ChatMessageModel message, {
|
||||
required bool isOwn,
|
||||
required List<ChatRoom> chatRooms,
|
||||
}) {
|
||||
final kind = VideoCallChatSignal.kindOf(message);
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi(message.roomId));
|
||||
unawaited(
|
||||
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final caller = _callerFromRooms(message, chatRooms);
|
||||
final payload = VideoCallChatSignal.incomingPayload(
|
||||
message: message,
|
||||
callerName: caller?.name,
|
||||
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||
);
|
||||
if (payload == null) return;
|
||||
unawaited(_showIncomingVideoCall(payload));
|
||||
}
|
||||
|
||||
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
|
||||
try {
|
||||
await (presentIncomingVideoCall ??
|
||||
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
|
||||
} catch (e, st) {
|
||||
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
|
||||
try {
|
||||
final dismiss = dismissIncomingVideoCall;
|
||||
if (dismiss != null) {
|
||||
await dismiss(conversationUuid);
|
||||
return;
|
||||
}
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||
conversationUuid: conversationUuid,
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChatUser? _callerFromRooms(
|
||||
ChatMessageModel message,
|
||||
List<ChatRoom> chatRooms,
|
||||
) {
|
||||
for (final room in chatRooms) {
|
||||
if (room.id != message.roomId) continue;
|
||||
final from = room.users.fromChatUser;
|
||||
final to = room.users.toChatUser;
|
||||
if (from.id == message.authorId) return from;
|
||||
if (to.id == message.authorId) return to;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setNewLastMessage(ChatMessageModel message) {
|
||||
final chats = state.chatRooms.map((chat) {
|
||||
if (chat.id == message.roomId) {
|
||||
@@ -188,6 +306,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
|
||||
final int index;
|
||||
final bool isLocal;
|
||||
|
||||
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||
final Object? heroTag;
|
||||
|
||||
const PhotoViewScreen({
|
||||
super.key,
|
||||
required this.imageUrls,
|
||||
required this.index,
|
||||
this.isLocal = false,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
|
||||
if (widget.heroTag != null) {
|
||||
if (index != widget.index) {
|
||||
return null;
|
||||
}
|
||||
return PhotoViewHeroAttributes(
|
||||
tag: widget.heroTag!,
|
||||
transitionOnUserGestures: true,
|
||||
);
|
||||
}
|
||||
|
||||
return PhotoViewHeroAttributes(
|
||||
tag: widget.imageUrls[index],
|
||||
transitionOnUserGestures: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DismissiblePage(
|
||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
direction: DismissiblePageDismissDirection.vertical,
|
||||
isFullScreen: true,
|
||||
child: PhotoViewGallery.builder(
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||
scrollPhysics: const BouncingScrollPhysics(),
|
||||
builder: (BuildContext context, int index) {
|
||||
final imageProvider = widget.isLocal
|
||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
||||
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||
as ImageProvider<Object>;
|
||||
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: imageProvider,
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: widget.imageUrls[index],
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
heroAttributes: _heroAttributesFor(index),
|
||||
errorBuilder: (context, object, stackTrace) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
onTap: () async {
|
||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
||||
if (!await launchUrl(
|
||||
Uri.parse(widget.imageUrls[index]),
|
||||
)) {
|
||||
throw Exception(
|
||||
'Could not launch ${widget.imageUrls[index]}',
|
||||
);
|
||||
}
|
||||
},
|
||||
leading: const Icon(
|
||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
color: Colors.white,
|
||||
),
|
||||
title: Text(
|
||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
||||
widget.imageUrls[index].replaceAll(
|
||||
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
|
||||
'',
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||
value: event == null
|
||||
? 0
|
||||
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
final MessageState messageState;
|
||||
final String messageUuid;
|
||||
final String userSlug;
|
||||
final String? messageType;
|
||||
final ChatMessageType? messageType;
|
||||
final bool? isConsultant;
|
||||
final List<Map<String, dynamic>>? urlsMediaInfo;
|
||||
@override
|
||||
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
final fileUri = Uri.file(mediaItem.path);
|
||||
if (await canLaunchUrl(fileUri)) {
|
||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
final fileUri = Uri.file(mediaItem.path);
|
||||
if (await canLaunchUrl(fileUri)) {
|
||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} else {
|
||||
// Для URL файлов используем обычный launchUrl
|
||||
final urlString = mediaItem.toString();
|
||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||
? Uri.parse(urlString)
|
||||
: Uri.parse('https://$urlString');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для URL файлов используем обычный launchUrl
|
||||
final urlString = mediaItem.toString();
|
||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||
? Uri.parse(urlString)
|
||||
: Uri.parse('https://$urlString');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||
},
|
||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -255,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _getDisplayText() {
|
||||
if (messageType == 'plan_order') {
|
||||
if (messageType == ChatMessageType.planOrder) {
|
||||
if (isConsultant == true) {
|
||||
// Текст для консультанта
|
||||
return '1. Уточните у подопечного его цели и пожелания\n'
|
||||
|
||||
@@ -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/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chose_plan_date_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/complete_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_complain_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/features/payment/presentation/payment_flow.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -29,7 +28,6 @@ class NotificationBanner extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotificationBannerState extends State<NotificationBanner> {
|
||||
bool isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
@@ -54,13 +52,7 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
padding: EdgeInsets.only(top: 12, right: 8, left: 12),
|
||||
child: _StarIcon(),
|
||||
),
|
||||
if (isLoading)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 40, top: 24),
|
||||
child: Loader(),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -111,21 +103,10 @@ class _NotificationBannerState extends State<NotificationBanner> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
onTap: () {
|
||||
final planId = (context.read<ChatRoomCubit>().state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
final paymentLink = await context.read<ChatRoomCubit>().getPaymentLink();
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
if (paymentLink != null && planId != null) {
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacement(
|
||||
PaymentScreen.path,
|
||||
extra: {'url': paymentLink, 'planId': planId},
|
||||
);
|
||||
if (planId != null) {
|
||||
showConsultationPaymentFlow(context, planId: planId);
|
||||
}
|
||||
},
|
||||
child: Text(s.pay, style: TextStyles.textLSemibold.copyWith(color: appTheme.blueA400)),
|
||||
|
||||
@@ -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_room_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||
@@ -98,8 +99,19 @@ class ChatRepository {
|
||||
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
required int id,
|
||||
required String paymentProvider,
|
||||
}) async {
|
||||
return await chatService.getPaymentLink(id: id);
|
||||
return await chatService.getPaymentLink(
|
||||
id: id,
|
||||
body: {'payment_provider': paymentProvider},
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||
required String orderId,
|
||||
}) async {
|
||||
return await chatService.resolveOrderIdToConversation(orderId: orderId);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@ import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_messages_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
@@ -65,6 +70,32 @@ abstract class ChatService {
|
||||
@POST('/api/chosen_plans/{id}/start_payment_process')
|
||||
Future<PaymentModel> getPaymentLink({
|
||||
@Path() required int id,
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@GET('/api/chosen_plans/applied_promos')
|
||||
Future<PromoAppliedListResponse> listAppliedPromoCodes();
|
||||
|
||||
@POST('/api/chosen_plans/{id}/validate_promo')
|
||||
Future<PromoValidateResponse> validatePromoCode({
|
||||
@Path() required int id,
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@POST('/api/chosen_plans/{id}/apply_promo')
|
||||
Future<PromoApplyResponse> applyPromoCode({
|
||||
@Path() required int id,
|
||||
@Body() required Map<String, String> body,
|
||||
});
|
||||
|
||||
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
|
||||
Future<PromoRemoveResponse> removePromoCode({
|
||||
@Path() required int id,
|
||||
});
|
||||
|
||||
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||
@Path() required String orderId,
|
||||
});
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
final category = state.category;
|
||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.complaint_question,
|
||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.spam,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.explicitImages,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||
]
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_ComplainButton(
|
||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
||||
),
|
||||
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
super.didChangeDependencies();
|
||||
final s = S.of(context);
|
||||
if (tags.isEmpty && descriptions.isEmpty) {
|
||||
tags.addAll([
|
||||
(false, s.misrepresentation),
|
||||
(false, s.fraud),
|
||||
]);
|
||||
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||
|
||||
descriptions.addAll([
|
||||
s.misrepresentation_description,
|
||||
s.fraud_description,
|
||||
]);
|
||||
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
child: Text(
|
||||
descriptions[index],
|
||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
||||
),
|
||||
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint_sent,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.could_not_send_complain,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Loader();
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return CustomOutlinedButton(
|
||||
text: s.send_complain,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () async {
|
||||
if (isDisabled) {
|
||||
context.read<ComplainingCubit>().setDescription('');
|
||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
||||
child: Ink(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
category.getTranslatedText(),
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
const Spacer(),
|
||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||
],
|
||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
||||
explicitImage;
|
||||
|
||||
String getTranslatedText() => switch (this) {
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
|
||||
String getValue() => switch (this) {
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
}
|
||||
|
||||
Future<void> complain() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final category = state.category;
|
||||
final id = state.contentId;
|
||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
case ComplainedContent.diet:
|
||||
if (id != null) {
|
||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
if (slug != null) {
|
||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.recipe:
|
||||
if (slug != null) {
|
||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.profile:
|
||||
if (slug != null) {
|
||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.posts:
|
||||
if (id != null) {
|
||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.comment:
|
||||
if (id != null) {
|
||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
emit(state.copyWith(wasSentSuccessfully: false));
|
||||
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
enum AlfaScoringStatus {
|
||||
@JsonValue('none')
|
||||
none,
|
||||
@JsonValue('beneficiary_creating')
|
||||
beneficiaryCreating,
|
||||
@JsonValue('beneficiary_failed')
|
||||
beneficiaryFailed,
|
||||
@JsonValue('scoring_pending')
|
||||
scoringPending,
|
||||
@JsonValue('scoring_complete')
|
||||
scoringComplete,
|
||||
@JsonValue('scoring_failed')
|
||||
scoringFailed;
|
||||
|
||||
static AlfaScoringStatus fromString(String? value) {
|
||||
switch (value) {
|
||||
case 'beneficiary_creating':
|
||||
return AlfaScoringStatus.beneficiaryCreating;
|
||||
case 'beneficiary_failed':
|
||||
return AlfaScoringStatus.beneficiaryFailed;
|
||||
case 'scoring_pending':
|
||||
return AlfaScoringStatus.scoringPending;
|
||||
case 'scoring_complete':
|
||||
return AlfaScoringStatus.scoringComplete;
|
||||
case 'scoring_failed':
|
||||
return AlfaScoringStatus.scoringFailed;
|
||||
case 'none':
|
||||
case null:
|
||||
case '':
|
||||
return AlfaScoringStatus.none;
|
||||
default:
|
||||
return AlfaScoringStatus.none;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isProcessing =>
|
||||
this == AlfaScoringStatus.beneficiaryCreating ||
|
||||
this == AlfaScoringStatus.scoringPending;
|
||||
|
||||
bool get isFailed =>
|
||||
this == AlfaScoringStatus.beneficiaryFailed ||
|
||||
this == AlfaScoringStatus.scoringFailed;
|
||||
|
||||
bool get canRetry => isFailed;
|
||||
|
||||
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
|
||||
|
||||
bool get isTerminal => isComplete || isFailed;
|
||||
}
|
||||
@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
|
||||
|
||||
const ConsultantCertPhotoModel._();
|
||||
|
||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
|
||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConsultantCertPhotoModelFromJson(json);
|
||||
|
||||
static const String visibleGroup = 'user_certs';
|
||||
static const String hiddenGroup = 'hidden_user_certs';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'consultant_profile_model.freezed.dart';
|
||||
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
const ConsultantProfileModel._();
|
||||
|
||||
const factory ConsultantProfileModel({
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||
bool? hasMedicalEducation,
|
||||
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||
SocialLinksModel? socialLinks,
|
||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||
@JsonKey(name: 'completed_courses', includeIfNull: false)
|
||||
List<CertificatesModel>? certificates,
|
||||
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||
@JsonKey(name: 'passport_series', includeIfNull: false)
|
||||
String? passportSeries,
|
||||
@JsonKey(name: 'passport_number', includeIfNull: false)
|
||||
String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
|
||||
String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer', includeIfNull: false)
|
||||
String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
|
||||
String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||
@JsonKey(name: 'registration_address', includeIfNull: false)
|
||||
String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address', includeIfNull: false)
|
||||
String? residenceAddress,
|
||||
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
|
||||
bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
|
||||
String? sbpBankMemberId,
|
||||
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
|
||||
bool? agreementAccepted,
|
||||
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
|
||||
bool? frdoAgreementAccepted,
|
||||
}) = _ConsultantProfileModel;
|
||||
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||
const factory SocialLinksModel({
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||
String? personalSiteLink,
|
||||
}) = _SocialLinksModel;
|
||||
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||
_$SocialLinksModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
||||
required String date,
|
||||
}) = _CertificatesModel;
|
||||
|
||||
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
|
||||
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
|
||||
_$CertificatesModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
||||
@JsonKey(name: 'canceled_moderation')
|
||||
CanceledModerationModel? canceledModeration,
|
||||
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||
}) = _ConsultationRequestModel;
|
||||
|
||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$ConsultationRequestModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||
const LegalEntityModel._();
|
||||
|
||||
const factory LegalEntityModel({
|
||||
required int id,
|
||||
required String type,
|
||||
String? inn,
|
||||
String? snils,
|
||||
String? state,
|
||||
@JsonKey(
|
||||
name: 'alfa_scoring_status',
|
||||
unknownEnumValue: AlfaScoringStatus.none,
|
||||
defaultValue: AlfaScoringStatus.none,
|
||||
)
|
||||
@Default(AlfaScoringStatus.none)
|
||||
AlfaScoringStatus alfaScoringStatus,
|
||||
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||
@JsonKey(name: 'last_name') String? lastName,
|
||||
@JsonKey(name: 'first_name') String? firstName,
|
||||
@JsonKey(name: 'middle_name') String? middleName,
|
||||
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||
@JsonKey(name: 'birth_date') String? birthDate,
|
||||
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||
String? citizenship,
|
||||
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||
}) = _LegalEntityModel;
|
||||
|
||||
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$LegalEntityModelFromJson(json);
|
||||
|
||||
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||
static String? normalizeApiDate(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||
return value.substring(0, 10);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
String? get normalizedPassportIssueDate =>
|
||||
normalizeApiDate(passportIssueDate);
|
||||
|
||||
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||
const factory CertPhotosModel({
|
||||
required String url,
|
||||
}) = _CertPhotosModel;
|
||||
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
|
||||
|
||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
|
||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CertPhotosModelFromJson(json);
|
||||
}
|
||||
|
||||
enum ModerationStatus {
|
||||
approved,
|
||||
created,
|
||||
canceled
|
||||
}
|
||||
enum ModerationStatus { approved, created, canceled }
|
||||
|
||||
@freezed
|
||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||
required List<String>? additional,
|
||||
}) = _CanceledModerationModel;
|
||||
|
||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
|
||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CanceledModerationModelFromJson(json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'sbp_bank_model.freezed.dart';
|
||||
part 'sbp_bank_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SbpBankModel with _$SbpBankModel {
|
||||
const factory SbpBankModel({
|
||||
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||
@JsonKey(name: 'bank_name') required String bankName,
|
||||
@JsonKey(name: 'short_name') String? shortName,
|
||||
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||
String? bic,
|
||||
}) = _SbpBankModel;
|
||||
|
||||
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$SbpBankModelFromJson(json);
|
||||
}
|
||||
@@ -1,42 +1,118 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
final ConsultantRepository consultantRepository;
|
||||
final Duration scoringPollInterval;
|
||||
bool _update = false;
|
||||
String? _userSlug;
|
||||
Timer? _scoringPollTimer;
|
||||
|
||||
RegisterConsultantCubit({required this.consultantRepository})
|
||||
: super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
static const String individualPersonLegalType = 'individualPerson';
|
||||
|
||||
RegisterConsultantCubit({
|
||||
required this.consultantRepository,
|
||||
this.scoringPollInterval = const Duration(seconds: 3),
|
||||
}) : super(
|
||||
const RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
socialLinks: SocialLinksModel(),
|
||||
hasMedicalEducation: true,
|
||||
legalType: individualPersonLegalType,
|
||||
),
|
||||
loading: true,
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
isUpdating: false,
|
||||
),
|
||||
);
|
||||
|
||||
String? get userSlug => _userSlug;
|
||||
|
||||
void setUserSlug(String? slug) {
|
||||
if (slug != null && slug.isNotEmpty) {
|
||||
_userSlug = slug;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||
try {
|
||||
return await consultantRepository.getSbpBanks();
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
return const <SbpBankModel>[];
|
||||
}
|
||||
}
|
||||
|
||||
List<SbpBankModel> searchSbpBanks(String query) {
|
||||
final normalized = query.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return state.sbpBanks;
|
||||
}
|
||||
return state.sbpBanks.where((bank) {
|
||||
final name = bank.bankName.toLowerCase();
|
||||
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||
final bic = (bank.bic ?? '').toLowerCase();
|
||||
return name.contains(normalized) ||
|
||||
shortName.contains(normalized) ||
|
||||
bic.contains(normalized);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
for (final bank in state.sbpBanks) {
|
||||
if (bank.bankMemberId == bankMemberId) {
|
||||
return bank;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> getMyRequestStatus(String slug) async {
|
||||
try {
|
||||
_update = true;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
||||
_userSlug = slug;
|
||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||
slug,
|
||||
);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
|
||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
List<CertificatesModel>? certificates = myRequestStatus
|
||||
.educationModel
|
||||
.completedCourses
|
||||
?.map((courseMap) {
|
||||
final course = courseMap.values.first;
|
||||
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
}).toList();
|
||||
return CertificatesModel(title: course.title, date: course.date);
|
||||
})
|
||||
.toList();
|
||||
|
||||
String? sanitizeLink(String? link) {
|
||||
return (link != null && link.isNotEmpty) ? link : null;
|
||||
}
|
||||
|
||||
final legal = myRequestStatus.legalEntity;
|
||||
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||
|
||||
emit(
|
||||
RegisterConsultantState(
|
||||
consultant: ConsultantProfileModel(
|
||||
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
||||
personalSiteLink: sanitizeLink(
|
||||
myRequestStatus.educationModel.personalSiteLink,
|
||||
),
|
||||
),
|
||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||
hasMedicalEducation:
|
||||
myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||
legalType: legal?.type ?? individualPersonLegalType,
|
||||
lastName: _emptyToNull(legal?.lastName),
|
||||
firstName: _emptyToNull(legal?.firstName),
|
||||
middleName: _emptyToNull(legal?.middleName),
|
||||
inn: _emptyToNull(legal?.inn),
|
||||
snils: _emptyToNull(legal?.snils),
|
||||
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||
birthDate: legal?.normalizedBirthDate,
|
||||
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||
citizenship: _emptyToNull(legal?.citizenship),
|
||||
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||
),
|
||||
acceptOffer: false,
|
||||
acceptFrdoAgreement: false,
|
||||
loading: false,
|
||||
requestModel: myRequestStatus,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
isUpdating: _update,
|
||||
alfaScoringStatus: scoringStatus,
|
||||
isScoringPolling: scoringStatus.isProcessing,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
fieldErrors: const <String, String>{},
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
|
||||
if (scoringStatus.isProcessing) {
|
||||
startScoringPoll();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
awaitingScoringOutcome: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _emptyToNull(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
Future<void> disableLoading({String? slug}) async {
|
||||
try {
|
||||
setUserSlug(slug);
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
final sbpBanks = await _loadSbpBanks();
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
certPhotos: certPhotos,
|
||||
sbpBanks: sbpBanks,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
awaitingScoringOutcome: false,
|
||||
consultant: state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableLoading() async {
|
||||
try {
|
||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(loading: false, error: e.toString()));
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
awaitingScoringOutcome: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (slug != null) {
|
||||
await getMyRequestStatus(slug);
|
||||
} else {
|
||||
await disableLoading();
|
||||
await disableLoading(slug: _userSlug);
|
||||
}
|
||||
}
|
||||
|
||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
||||
void updateConsultantProfileModel(
|
||||
ConsultantProfileModel consultant, [
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||
? const <String, String>{}
|
||||
: PassportDataValidator.validate(consultant);
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: nextFieldErrors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, String> validatePassportFields() {
|
||||
final errors = PassportDataValidator.validate(state.consultant);
|
||||
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||
return errors;
|
||||
}
|
||||
|
||||
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool get hasAcceptedRequiredAgreements {
|
||||
if (_update) {
|
||||
return state.acceptOffer;
|
||||
}
|
||||
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||
}
|
||||
|
||||
ConsultantProfileModel buildSubmitPayload() {
|
||||
final withLegalType = state.consultant.copyWith(
|
||||
legalType: individualPersonLegalType,
|
||||
citizenship: state.consultant.citizenship,
|
||||
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||
certificates: state.consultant.certificates
|
||||
?.map(
|
||||
(course) => CertificatesModel(
|
||||
title: course.title,
|
||||
date:
|
||||
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
if (_update) {
|
||||
return ConsultantProfileModel(
|
||||
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||
socialLinks: withLegalType.socialLinks,
|
||||
photos: withLegalType.photos,
|
||||
certificates: withLegalType.certificates,
|
||||
legalType: withLegalType.legalType,
|
||||
lastName: withLegalType.lastName,
|
||||
firstName: withLegalType.firstName,
|
||||
middleName: withLegalType.middleName,
|
||||
inn: withLegalType.inn,
|
||||
snils: withLegalType.snils,
|
||||
passportSeries: withLegalType.passportSeries,
|
||||
passportNumber: withLegalType.passportNumber,
|
||||
passportIssueDate: withLegalType.passportIssueDate,
|
||||
passportIssuer: withLegalType.passportIssuer,
|
||||
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||
birthDate: withLegalType.birthDate,
|
||||
birthPlace: withLegalType.birthPlace,
|
||||
registrationAddress: withLegalType.registrationAddress,
|
||||
residenceAddress: withLegalType.residenceAddress,
|
||||
citizenship: withLegalType.citizenship,
|
||||
taxResidentUs: withLegalType.taxResidentUs,
|
||||
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||
sbpPhone: withLegalType.sbpPhone,
|
||||
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||
);
|
||||
}
|
||||
|
||||
return withLegalType.copyWith(
|
||||
agreementAccepted: state.acceptOffer,
|
||||
frdoAgreementAccepted: state.acceptFrdoAgreement,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
emit(state.copyWith(error: null));
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show scoring widget immediately while request / job are in progress.
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
awaitingScoringOutcome: true,
|
||||
scoringRetryInProgress: false,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
if (state.newAddedPhotos.isNotEmpty) {
|
||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
||||
await consultantRepository.storeConsultantCertPhotos(
|
||||
files: state.newAddedPhotos,
|
||||
);
|
||||
}
|
||||
if (state.deletedPhotoIds.isNotEmpty) {
|
||||
for (final int id in state.deletedPhotoIds) {
|
||||
await consultantRepository.deletePhoto(id);
|
||||
}
|
||||
}
|
||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: _update,
|
||||
);
|
||||
_update = true;
|
||||
emit(
|
||||
state.copyWith(
|
||||
isUpdating: true,
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: true,
|
||||
newAddedPhotos: const <File>[],
|
||||
deletedPhotoIds: const <int>[],
|
||||
fieldErrors: const <String, String>{},
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
startScoringPoll();
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
isScoringPolling: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.none,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(error: e.toString()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
error: e.toString(),
|
||||
scoringRetryInProgress: false,
|
||||
awaitingScoringOutcome: false,
|
||||
isScoringPolling: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.none,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> retryScoring() async {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
awaitingScoringOutcome: true,
|
||||
),
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: true,
|
||||
);
|
||||
startScoringPoll();
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isUpdating: true,
|
||||
awaitingScoringOutcome: true,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
final parsed = parseLaravelValidationErrors(e);
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: parsed.summary,
|
||||
fieldErrors: parsed.fieldErrors,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void startScoringPoll() {
|
||||
final slug = _userSlug;
|
||||
if (slug == null || slug.isEmpty) {
|
||||
emit(
|
||||
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_stopScoringPoll();
|
||||
emit(state.copyWith(isScoringPolling: true));
|
||||
_scoringPollTimer = Timer.periodic(
|
||||
scoringPollInterval,
|
||||
(_) => _pollScoringStatus(slug),
|
||||
);
|
||||
unawaited(_pollScoringStatus(slug));
|
||||
}
|
||||
|
||||
Future<void> _pollScoringStatus(String slug) async {
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||
if (isClosed) {
|
||||
return;
|
||||
}
|
||||
final scoringStatus =
|
||||
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||
// API stays on `none` until ProcessConsultantScoringJob starts
|
||||
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
|
||||
// UI and continue polling until a real terminal/processing status arrives.
|
||||
if (scoringStatus == AlfaScoringStatus.none) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
requestModel: status,
|
||||
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||
isScoringPolling: true,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final isTerminal = scoringStatus.isTerminal;
|
||||
emit(
|
||||
state.copyWith(
|
||||
requestModel: status,
|
||||
alfaScoringStatus: scoringStatus,
|
||||
isScoringPolling: scoringStatus.isProcessing,
|
||||
// Clear session flag on terminal so success sheet can fire once
|
||||
// (listenWhen: previous.awaiting && !current.awaiting && complete).
|
||||
awaitingScoringOutcome: isTerminal
|
||||
? false
|
||||
: state.awaitingScoringOutcome,
|
||||
),
|
||||
);
|
||||
if (!scoringStatus.isProcessing) {
|
||||
_stopScoringPoll();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
// Keep polling on transient errors while scoring is in progress.
|
||||
}
|
||||
}
|
||||
|
||||
void _stopScoringPoll() {
|
||||
_scoringPollTimer?.cancel();
|
||||
_scoringPollTimer = null;
|
||||
}
|
||||
|
||||
Future<void> addCertPhotos(List<File> files) async {
|
||||
if (files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
|
||||
final compressedFiles = await ImageCompressUtility.compressFilesList(
|
||||
files,
|
||||
);
|
||||
|
||||
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
|
||||
emit(
|
||||
state.copyWith(
|
||||
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
|
||||
error: null,
|
||||
),
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
return;
|
||||
}
|
||||
if (path != null) {
|
||||
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
|
||||
emit(
|
||||
state.copyWith(
|
||||
newAddedPhotos: state.newAddedPhotos
|
||||
.where((file) => file.uri.path != path)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_stopScoringPoll();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'register_consultant_state.freezed.dart';
|
||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
||||
const factory RegisterConsultantState({
|
||||
required ConsultantProfileModel consultant,
|
||||
required bool acceptOffer,
|
||||
@Default(false) bool acceptFrdoAgreement,
|
||||
required bool loading,
|
||||
required bool isUpdating,
|
||||
ConsultationRequestModel? requestModel,
|
||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<ConsultantCertPhotoModel>[])
|
||||
List<ConsultantCertPhotoModel> certPhotos,
|
||||
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||
@Default(<File>[]) List<File> newAddedPhotos,
|
||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
|
||||
@Default(false) bool isScoringPolling,
|
||||
@Default(false) bool scoringRetryInProgress,
|
||||
|
||||
/// True only after successful register/retry in this session until scoring is terminal.
|
||||
@Default(false) bool awaitingScoringOutcome,
|
||||
@Default(<String, String>{}) Map<String, String> fieldErrors,
|
||||
String? error,
|
||||
}) = _RegisterConsultantState;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
||||
const AdditionalSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
||||
State<AdditionalSettingsScreen> createState() =>
|
||||
_AdditionalSettingsScreenState();
|
||||
}
|
||||
|
||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
||||
if (context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.hasMedicalEducation !=
|
||||
null) {
|
||||
haveMedicalDegree = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
value: publishDiplomasAndCertificatesInProfile,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
||||
publishDiplomasAndCertificatesInProfile =
|
||||
!publishDiplomasAndCertificatesInProfile;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||
setState(() {
|
||||
haveMedicalDegree = !haveMedicalDegree;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
hasMedicalEducation: haveMedicalDegree,
|
||||
),
|
||||
);
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.updateConsultantProfileModel(
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.copyWith(hasMedicalEducation: haveMedicalDegree),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BecomeConsultantScreen extends StatefulWidget {
|
||||
const BecomeConsultantScreen({super.key});
|
||||
final String? slug;
|
||||
|
||||
const BecomeConsultantScreen({super.key, this.slug});
|
||||
|
||||
@override
|
||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
title: const Text('Стать консультантом'),
|
||||
automaticallyImplyLeading: false,
|
||||
centerTitle: false,
|
||||
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
|
||||
leading: IconButton(
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
||||
|
||||
InkWell _startRegistrationButton() {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
|
||||
onTap: () => GoRouter.of(context).pushReplacementNamed(
|
||||
'stepperBecomeConsultantScreen',
|
||||
extra: {'slug': widget.slug, 'isNewRegistration': true},
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Начать регистрацию',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||
const CertificatesAndDiplomasScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
||||
State<CertificatesAndDiplomasScreen> createState() =>
|
||||
_CertificatesAndDiplomasScreenState();
|
||||
}
|
||||
|
||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
||||
class _CertificatesAndDiplomasScreenState
|
||||
extends State<CertificatesAndDiplomasScreen> {
|
||||
final scrollController = ScrollController();
|
||||
|
||||
List<DiplomaEntity> diplomasList = [
|
||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||
null) {
|
||||
diplomasList = [
|
||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
||||
...context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.certificates!
|
||||
.map(
|
||||
(e) => DiplomaEntity(
|
||||
date: DateTime.tryParse(e.date),
|
||||
textEditingController: TextEditingController(text: e.title),
|
||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
||||
itemBuilder: (context, index) =>
|
||||
_diplomaInfo(diploma: diplomasList[index]),
|
||||
separatorBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: _divider(context),
|
||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
);
|
||||
}
|
||||
|
||||
Column _diplomaInfo({
|
||||
required DiplomaEntity diploma,
|
||||
}) {
|
||||
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
||||
width:
|
||||
MediaQuery.of(context).size.width -
|
||||
(diploma.enableDeleteButton ? 92 : 32),
|
||||
child: ListTile(
|
||||
onTap: onTapCalendar(context, diploma),
|
||||
trailing: Icon(
|
||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
title: Text(
|
||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||
style: TextStyle(
|
||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
||||
color: diploma.date == null
|
||||
? Colors.grey.shade400
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
tileColor: const Color(0xFFF6F6F6),
|
||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
),
|
||||
child: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: child,
|
||||
),
|
||||
child: SafeArea(top: false, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void checkLast() {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
||||
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||
diplomasList.last.date != null) {
|
||||
setState(() {
|
||||
diplomasList.add(
|
||||
DiplomaEntity(
|
||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
||||
|
||||
void _saveState() {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
||||
0,
|
||||
diplomasList.length - 1,
|
||||
),
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
certificates: diplomasList
|
||||
.map(
|
||||
(e) => CertificatesModel(
|
||||
title: e.textEditingController.text,
|
||||
date: e.date == null
|
||||
? ''
|
||||
: DateFormat('yyyy-MM-dd').format(e.date!),
|
||||
),
|
||||
);
|
||||
)
|
||||
.toList()
|
||||
.sublist(0, diplomasList.length - 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LastStepScreenState extends State<LastStepScreen> {
|
||||
bool checkboxState = false;
|
||||
static const _frdoUrl =
|
||||
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||
|
||||
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_frdoTapRecognizer = TapGestureRecognizer()
|
||||
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frdoTapRecognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cubit = context.watch<RegisterConsultantCubit>();
|
||||
final state = cubit.state;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||
child: Column(
|
||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Последний шаг',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.awaitingScoringOutcome ||
|
||||
state.isScoringPolling ||
|
||||
state.alfaScoringStatus !=
|
||||
AlfaScoringStatus.none) ...[
|
||||
const SizedBox(height: 16),
|
||||
const ScoringStatusWidget(),
|
||||
],
|
||||
if (state.requestModel?.canceledModeration != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x33FFCC00),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.requestModel!.canceledModeration!.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
if (state.requestModel!.canceledModeration!.additional !=
|
||||
null) ...[
|
||||
const SizedBox(height: 8),
|
||||
for (final item
|
||||
in state
|
||||
.requestModel!
|
||||
.canceledModeration!
|
||||
.additional!) ...[
|
||||
Text(
|
||||
'• $item',
|
||||
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
CheckboxListTile(
|
||||
value: checkboxState,
|
||||
value: state.acceptOffer,
|
||||
title: Text(
|
||||
hidePlans
|
||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
||||
style: const TextStyle(fontSize: 18, height: 1),
|
||||
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
checkboxState = !checkboxState;
|
||||
});
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (!state.isUpdating) ...[
|
||||
CheckboxListTile(
|
||||
value: state.acceptFrdoAgreement,
|
||||
title: Text.rich(
|
||||
TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1.2,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text:
|
||||
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
|
||||
),
|
||||
TextSpan(
|
||||
text: 'ФРДО',
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
recognizer: _frdoTapRecognizer,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||
},
|
||||
activeColor: appTheme.blueA400,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: _divider(context),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: TextButton(
|
||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
||||
onPressed: () async => await launchUrl(
|
||||
Uri.parse(
|
||||
hidePlans
|
||||
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
|
||||
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
||||
hidePlans
|
||||
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||
: 'Договор оферты',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontSize: 16,
|
||||
color: Color(0xFF323F4B),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
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/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||
|
||||
class PassportDataScreen extends StatefulWidget {
|
||||
const PassportDataScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||
}
|
||||
|
||||
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
late final TextEditingController lastNameController;
|
||||
late final TextEditingController firstNameController;
|
||||
late final TextEditingController middleNameController;
|
||||
late final TextEditingController innController;
|
||||
late final TextEditingController snilsController;
|
||||
late final TextEditingController seriesController;
|
||||
late final TextEditingController numberController;
|
||||
late final TextEditingController issueDateController;
|
||||
late final TextEditingController issuerController;
|
||||
late final TextEditingController issuerCodeController;
|
||||
late final TextEditingController birthDateController;
|
||||
late final TextEditingController birthPlaceController;
|
||||
late final TextEditingController citizenshipController;
|
||||
late final TextEditingController registrationAddressController;
|
||||
late final TextEditingController residenceAddressController;
|
||||
late final TextEditingController sbpPhoneController;
|
||||
late final TextEditingController sbpBankController;
|
||||
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||
|
||||
bool taxResidentUs = false;
|
||||
bool foreignTaxResident = false;
|
||||
String? selectedSbpBankMemberId;
|
||||
String? selectedSbpBankName;
|
||||
|
||||
final snilsMask = MaskTextInputFormatter(
|
||||
mask: '###-###-### ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskIssue = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final dateMaskBirth = MaskTextInputFormatter(
|
||||
mask: '##.##.####',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final issuerCodeMask = MaskTextInputFormatter(
|
||||
mask: '###-###',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final phoneMask = MaskTextInputFormatter(
|
||||
mask: '+7 (###) ### ## ##',
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||
firstNameController = TextEditingController(
|
||||
text: consultant.firstName ?? '',
|
||||
);
|
||||
middleNameController = TextEditingController(
|
||||
text: consultant.middleName ?? '',
|
||||
);
|
||||
innController = TextEditingController(text: consultant.inn ?? '');
|
||||
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||
seriesController = TextEditingController(
|
||||
text: consultant.passportSeries ?? '',
|
||||
);
|
||||
numberController = TextEditingController(
|
||||
text: consultant.passportNumber ?? '',
|
||||
);
|
||||
issueDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.passportIssueDate),
|
||||
);
|
||||
issuerController = TextEditingController(
|
||||
text: consultant.passportIssuer ?? '',
|
||||
);
|
||||
issuerCodeController = TextEditingController(
|
||||
text: consultant.passportIssuerCode ?? '',
|
||||
);
|
||||
birthDateController = TextEditingController(
|
||||
text: _apiDateToUi(consultant.birthDate),
|
||||
);
|
||||
birthPlaceController = TextEditingController(
|
||||
text: consultant.birthPlace ?? '',
|
||||
);
|
||||
citizenshipController = TextEditingController(text: consultant.citizenship);
|
||||
registrationAddressController = TextEditingController(
|
||||
text: consultant.registrationAddress ?? '',
|
||||
);
|
||||
residenceAddressController = TextEditingController(
|
||||
text: consultant.residenceAddress ?? '',
|
||||
);
|
||||
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(consultant.sbpBankMemberId);
|
||||
selectedSbpBankName = selectedBank?.bankName;
|
||||
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||
}
|
||||
|
||||
void _hydrateSbpBankNameIfNeeded() {
|
||||
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (sbpBankController.text.trim().isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
final selectedBank = context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.findSbpBankById(selectedSbpBankMemberId);
|
||||
if (selectedBank == null) {
|
||||
return;
|
||||
}
|
||||
selectedSbpBankName = selectedBank.bankName;
|
||||
sbpBankController.text = selectedBank.bankName;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
lastNameController.dispose();
|
||||
firstNameController.dispose();
|
||||
middleNameController.dispose();
|
||||
innController.dispose();
|
||||
snilsController.dispose();
|
||||
seriesController.dispose();
|
||||
numberController.dispose();
|
||||
issueDateController.dispose();
|
||||
issuerController.dispose();
|
||||
issuerCodeController.dispose();
|
||||
birthDateController.dispose();
|
||||
birthPlaceController.dispose();
|
||||
citizenshipController.dispose();
|
||||
registrationAddressController.dispose();
|
||||
residenceAddressController.dispose();
|
||||
sbpPhoneController.dispose();
|
||||
sbpBankController.dispose();
|
||||
sbpBankSuggestionsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _apiDateToUi(String? apiDate) {
|
||||
if (apiDate == null || apiDate.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final parts = apiDate.split('-');
|
||||
if (parts.length != 3) {
|
||||
return apiDate;
|
||||
}
|
||||
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||
}
|
||||
|
||||
String? _uiDateToApi(String uiDate) {
|
||||
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.length != 8) {
|
||||
return null;
|
||||
}
|
||||
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||
}
|
||||
|
||||
String? _uiPhoneToApi(String uiPhone) {
|
||||
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||
if (digits.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (digits.length == 11 && digits.startsWith('7')) {
|
||||
return '+$digits';
|
||||
}
|
||||
if (digits.length == 10) {
|
||||
return '+7$digits';
|
||||
}
|
||||
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||
}
|
||||
|
||||
void _syncToCubit() {
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
final current = cubit.state.consultant;
|
||||
cubit.updateConsultantProfileModel(
|
||||
current.copyWith(
|
||||
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||
lastName: lastNameController.text.trim().isEmpty
|
||||
? null
|
||||
: lastNameController.text.trim(),
|
||||
firstName: firstNameController.text.trim().isEmpty
|
||||
? null
|
||||
: firstNameController.text.trim(),
|
||||
middleName: middleNameController.text.trim().isEmpty
|
||||
? null
|
||||
: middleNameController.text.trim(),
|
||||
inn: innController.text.trim().isEmpty
|
||||
? null
|
||||
: innController.text.trim(),
|
||||
snils: snilsController.text.trim().isEmpty
|
||||
? null
|
||||
: snilsController.text.trim(),
|
||||
passportSeries: seriesController.text.trim().isEmpty
|
||||
? null
|
||||
: seriesController.text.trim(),
|
||||
passportNumber: numberController.text.trim().isEmpty
|
||||
? null
|
||||
: numberController.text.trim(),
|
||||
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||
passportIssuer: issuerController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerController.text.trim(),
|
||||
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||
? null
|
||||
: issuerCodeController.text.trim(),
|
||||
birthDate: _uiDateToApi(birthDateController.text),
|
||||
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||
? null
|
||||
: birthPlaceController.text.trim(),
|
||||
citizenship: citizenshipController.text.trim().isEmpty
|
||||
? 'RU'
|
||||
: citizenshipController.text.trim().toUpperCase(),
|
||||
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: registrationAddressController.text.trim(),
|
||||
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||
? null
|
||||
: residenceAddressController.text.trim(),
|
||||
taxResidentUs: taxResidentUs,
|
||||
foreignTaxResident: foreignTaxResident,
|
||||
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||
sbpBankMemberId: selectedSbpBankMemberId,
|
||||
),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectSbpBank(SbpBankModel bank) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = bank.bankMemberId;
|
||||
selectedSbpBankName = bank.bankName;
|
||||
sbpBankController.text = bank.bankName;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
|
||||
void _clearSbpBankIfNeeded(String value) {
|
||||
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||
setState(() {
|
||||
selectedSbpBankMemberId = null;
|
||||
selectedSbpBankName = null;
|
||||
});
|
||||
_syncToCubit();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.fieldErrors != current.fieldErrors,
|
||||
builder: (context, state) {
|
||||
final errors = state.fieldErrors;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
left: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const PassportScreenTitle(title: 'Паспортные данные'),
|
||||
const PassportSectionTitle(
|
||||
title: 'Персональные данные (как в паспорте)',
|
||||
),
|
||||
PassportFieldWithError(
|
||||
error: errors['last_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['first_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['middle_name'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('middle_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const PassportSectionTitle(title: 'Идентификация'),
|
||||
PassportFieldWithError(
|
||||
error: errors['inn'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'ИНН (12 цифр)',
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['snils'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'СНИЛС',
|
||||
controller: snilsController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [snilsMask],
|
||||
hasError: errors.containsKey('snils'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['birth_date'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||
controller: birthDateController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [dateMaskBirth],
|
||||
hasError: errors.containsKey('birth_date'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['birth_place'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Место рождения',
|
||||
controller: birthPlaceController,
|
||||
hasError: errors.containsKey('birth_place'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['citizenship'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Гражданство (ISO, например RU)',
|
||||
controller: citizenshipController,
|
||||
maxLength: 2,
|
||||
hasError: errors.containsKey('citizenship'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['registration_address'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Адрес регистрации',
|
||||
controller: registrationAddressController,
|
||||
hasError: errors.containsKey('registration_address'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['residence_address'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Адрес проживания',
|
||||
controller: residenceAddressController,
|
||||
hasError: errors.containsKey('residence_address'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: taxResidentUs,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: Text(
|
||||
'Я являюсь налоговым резидентом США',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => taxResidentUs = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
CheckboxListTile(
|
||||
value: foreignTaxResident,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
activeColor: appTheme.blueA400,
|
||||
title: Text(
|
||||
'Я являюсь иностранным налоговым резидентом',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => foreignTaxResident = value ?? false);
|
||||
_syncToCubit();
|
||||
},
|
||||
),
|
||||
const PassportSectionTitle(title: 'Паспорт'),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: PassportFieldWithError(
|
||||
error: errors['passport_series'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Серия',
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: PassportFieldWithError(
|
||||
error: errors['passport_number'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Номер',
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issue_date'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
|
||||
controller: issueDateController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [dateMaskIssue],
|
||||
hasError: errors.containsKey('passport_issue_date'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issuer_code'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Код подразделения',
|
||||
controller: issuerCodeController,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [issuerCodeMask],
|
||||
hasError: errors.containsKey('passport_issuer_code'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['passport_issuer'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Кем выдан',
|
||||
controller: issuerController,
|
||||
hasError: errors.containsKey('passport_issuer'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const PassportSectionTitle(title: 'Реквизиты СБП'),
|
||||
PassportFieldWithError(
|
||||
error: errors['sbp_phone'],
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Телефон для выплат через СБП',
|
||||
controller: sbpPhoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [phoneMask],
|
||||
hasError: errors.containsKey('sbp_phone'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
PassportFieldWithError(
|
||||
error: errors['sbp_bank_member_id'],
|
||||
child: TypeAheadField<SbpBankModel>(
|
||||
debounceDuration: const Duration(milliseconds: 250),
|
||||
controller: sbpBankController,
|
||||
suggestionsController: sbpBankSuggestionsController,
|
||||
builder: (context, controller, focusNode) =>
|
||||
CustomTextFormField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
hintText: 'Банк получатель (поиск)',
|
||||
hasError: errors.containsKey('sbp_bank_member_id'),
|
||||
onChanged: _clearSbpBankIfNeeded,
|
||||
),
|
||||
suggestionsCallback: (query) async {
|
||||
return context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.searchSbpBanks(query);
|
||||
},
|
||||
itemBuilder: (context, bank) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
bank.bankName,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black900,
|
||||
),
|
||||
),
|
||||
subtitle:
|
||||
bank.shortName == null || bank.shortName!.isEmpty
|
||||
? null
|
||||
: Text(
|
||||
bank.shortName!,
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.grey7B,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
decorationBuilder: (context, child) => Material(
|
||||
type: MaterialType.card,
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: appTheme.whiteA700,
|
||||
child: child,
|
||||
),
|
||||
emptyBuilder: (context) => Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Ничего не найдено',
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.grey7B,
|
||||
),
|
||||
),
|
||||
),
|
||||
onSelected: _selectSbpBank,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
const Text(
|
||||
'Загрузите фото или сканы дипломов и сертификатов',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
builder: (context, state) {
|
||||
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
|
||||
final photos = state.certPhotos
|
||||
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
|
||||
.toList();
|
||||
final addedPhotos = state.newAddedPhotos;
|
||||
final totalPhotosCount = photos.length + addedPhotos.length;
|
||||
return Column(
|
||||
children: [
|
||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
||||
if (isUploading) ...[
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
childAspectRatio: 3 / 4,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == totalPhotosCount) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _pickAndUploadPhotos(),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(Icons.add, color: appTheme.blueA400),
|
||||
),
|
||||
);
|
||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
return _LocalCertPhotoCard(
|
||||
photo: localPhoto,
|
||||
allPhotos: addedPhotos,
|
||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
||||
onRemove: () =>
|
||||
_removePhoto(path: localPhoto.uri.path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
||||
});
|
||||
try {
|
||||
// ignore: use_build_context_synchronously
|
||||
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
|
||||
await context.read<RegisterConsultantCubit>().addCertPhotos(
|
||||
newImages.map((file) => File(file.path)).toList(),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
final List<ConsultantCertPhotoModel> allPhotos;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
||||
const _CertPhotoCard({
|
||||
required this.photo,
|
||||
required this.allPhotos,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
|
||||
PhotoViewScreen(
|
||||
imageUrls: allPhotos.map((e) => e.url).toList(),
|
||||
isLocal: false,
|
||||
index: allPhotos.indexOf(photo),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
Ink(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
image: NetworkImage(photo.url),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F2F6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
||||
final List<File> allPhotos;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
||||
const _LocalCertPhotoCard({
|
||||
required this.photo,
|
||||
required this.allPhotos,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF1F2F6),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class RegistrationAsScreen extends StatefulWidget {
|
||||
const RegistrationAsScreen({super.key});
|
||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||
|
||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
||||
static const RegistrationAsType registrationAsType =
|
||||
RegistrationAsType.physical;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final cubit = context.read<RegisterConsultantCubit>();
|
||||
cubit.updateConsultantProfileModel(
|
||||
cubit.state.consultant.copyWith(
|
||||
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||
),
|
||||
cubit.state.acceptOffer,
|
||||
cubit.state.acceptFrdoAgreement,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
||||
child: Text(
|
||||
'Регистрация в качестве',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Column(
|
||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: (_) {},
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Физического лица',
|
||||
style: TextStyle(fontSize: 18, height: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Самозанятого',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Индивидуального предпринимателя',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
groupValue: registrationAsType,
|
||||
activeColor: appTheme.blueA400,
|
||||
// ignore: deprecated_member_use
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
registrationAsType = value!;
|
||||
});
|
||||
},
|
||||
onChanged: null,
|
||||
),
|
||||
const Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
||||
child: Text(
|
||||
'Общества с ограниченной ответственностью',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
height: 1,
|
||||
color: Colors.black38,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: _divider(context),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
||||
child: Text(
|
||||
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||
return Container(
|
||||
height: 0.4,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
|
||||
var maskFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskTgFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://tg.me/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
var maskVkFormatter = MaskTextInputFormatter(
|
||||
mask: 'https://vk.com/########################################',
|
||||
filter: {
|
||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
||||
},
|
||||
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
||||
vkController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.vkLink ??
|
||||
'';
|
||||
telegramController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.tgLink ??
|
||||
'';
|
||||
webSiteController.text =
|
||||
context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks!
|
||||
.personalSiteLink ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/vk.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/telegram.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
color: const Color(0xFFF6F6F6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/web.svg',
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
||||
String? personalSiteLink,
|
||||
}) {
|
||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: tgLink,
|
||||
vkLink: vkLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
|
||||
: vkLink.isEmpty
|
||||
? null
|
||||
: vkLink,
|
||||
personalSiteLink: personalSiteLink == null
|
||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
|
||||
: personalSiteLink.isEmpty
|
||||
? null
|
||||
: personalSiteLink,
|
||||
),
|
||||
),
|
||||
);
|
||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||
socialLinks: context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.copyWith(
|
||||
tgLink: tgLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.tgLink
|
||||
: tgLink.isEmpty
|
||||
? null
|
||||
: tgLink,
|
||||
vkLink: vkLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.vkLink
|
||||
: vkLink.isEmpty
|
||||
? null
|
||||
: vkLink,
|
||||
personalSiteLink: personalSiteLink == null
|
||||
? context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.state
|
||||
.consultant
|
||||
.socialLinks
|
||||
?.personalSiteLink
|
||||
: personalSiteLink.isEmpty
|
||||
? null
|
||||
: personalSiteLink,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
|
||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||
final String? slug;
|
||||
final bool? isCanceled;
|
||||
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
|
||||
final bool isNewRegistration;
|
||||
const StepperBecomeConsultantScreen({
|
||||
super.key,
|
||||
this.slug,
|
||||
this.isCanceled,
|
||||
this.isNewRegistration = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
||||
State<StepperBecomeConsultantScreen> createState() =>
|
||||
_StepperBecomeConsultantScreenState();
|
||||
}
|
||||
|
||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 1 / 6;
|
||||
class _StepperBecomeConsultantScreenState
|
||||
extends State<StepperBecomeConsultantScreen> {
|
||||
double progressValue = 0;
|
||||
int step = 1;
|
||||
final pageController = PageController();
|
||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||
bool isLoading = false;
|
||||
bool _successSheetShown = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
||||
if (widget.isNewRegistration || widget.slug == null) {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||
..disableLoading(slug: widget.slug);
|
||||
} else {
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
|
||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||
..getMyRequestStatus(widget.slug!);
|
||||
}
|
||||
}
|
||||
|
||||
static const int totalSteps = 7;
|
||||
|
||||
List<Widget> _pages() {
|
||||
return const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
PassportDataScreen(),
|
||||
LastStepScreen(),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _showSuccessSheet(BuildContext context) async {
|
||||
if (_successSheetShown) {
|
||||
return;
|
||||
}
|
||||
_successSheetShown = true;
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset(
|
||||
'assets/lottie_animations/success.json',
|
||||
width: 160,
|
||||
height: 160,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null) {
|
||||
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.fieldErrors.isEmpty &&
|
||||
current.fieldErrors.isNotEmpty &&
|
||||
current.error == null,
|
||||
listener: (context, state) {
|
||||
pageController.jumpToPage(5);
|
||||
},
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Scaffold(body: Loader());
|
||||
}
|
||||
if (state.error != null &&
|
||||
state.requestModel == null &&
|
||||
widget.slug != null &&
|
||||
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
leading: BackButton(
|
||||
onPressed: () {
|
||||
if (context.canPop()) {
|
||||
GoRouter.of(context).pop();
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).go('/feed-screen');
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true)
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(state.requestModel!.canceledModeration!.title),
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
||||
],
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
backgroundColor: isLoading
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: isLoading
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == 6) {
|
||||
if (widget.isCanceled == true) {
|
||||
if (step == totalSteps) {
|
||||
final passportErrors =
|
||||
registrationConsultantCubit
|
||||
.validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
pageController.jumpToPage(5);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit.state.acceptOffer) {
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit.registerConsultant();
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error != null) {
|
||||
return;
|
||||
}
|
||||
await showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
showDragHandle: true,
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
width: MediaQuery.of(context).size.width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
GoRouter.of(context).pop();
|
||||
},
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Закрыть',
|
||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (registrationConsultantCubit
|
||||
.state
|
||||
.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
content: const Text('Примите договор публичной офёрты'),
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: isLoading
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true
|
||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / 6;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: const [
|
||||
CertificatesAndDiplomasScreen(),
|
||||
PhotoOrScansScreen(),
|
||||
AdditionalSettingsScreen(),
|
||||
SocialNetworksScreen(),
|
||||
RegistrationAsScreen(),
|
||||
LastStepScreen(),
|
||||
],
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
|
||||
/// Pass [hasError] into [CustomTextFormField] from the parent.
|
||||
class PassportFieldWithError extends StatelessWidget {
|
||||
const PassportFieldWithError({required this.child, this.error, super.key});
|
||||
|
||||
final Widget child;
|
||||
final String? error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final message = error?.trim();
|
||||
final showError = message != null && message.isNotEmpty;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
child,
|
||||
if (showError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: message),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PassportSectionTitle extends StatelessWidget {
|
||||
const PassportSectionTitle({required this.title, super.key});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PassportScreenTitle extends StatelessWidget {
|
||||
const PassportScreenTitle({required this.title, super.key});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
title,
|
||||
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ScoringStatusWidget extends StatelessWidget {
|
||||
const ScoringStatusWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
buildWhen: (previous, current) =>
|
||||
previous.alfaScoringStatus != current.alfaScoringStatus ||
|
||||
previous.isScoringPolling != current.isScoringPolling ||
|
||||
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
|
||||
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
|
||||
builder: (context, state) {
|
||||
final isWaiting =
|
||||
state.awaitingScoringOutcome || state.isScoringPolling;
|
||||
final status = state.alfaScoringStatus;
|
||||
final showProcessing = status.isProcessing ||
|
||||
(isWaiting && !status.isTerminal);
|
||||
|
||||
if (status == AlfaScoringStatus.none && !isWaiting) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final displayStatus = showProcessing && !status.isProcessing
|
||||
? AlfaScoringStatus.beneficiaryCreating
|
||||
: status;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: appTheme.greyD9),
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ScoringStatusHeader(status: displayStatus),
|
||||
const SizedBox(height: 12),
|
||||
if (showProcessing) const ScoringProcessingBody(),
|
||||
if (status.isComplete) const ScoringCompleteBody(),
|
||||
if (status.isFailed)
|
||||
ScoringFailedBody(
|
||||
retryInProgress: state.scoringRetryInProgress,
|
||||
onRetry: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.retryScoring(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringStatusHeader extends StatelessWidget {
|
||||
const ScoringStatusHeader({required this.status, super.key});
|
||||
|
||||
final AlfaScoringStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Проверка данных',
|
||||
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
ScoringStatusLabels.hintFor(status),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: ScoringStatusLabels.colorFor(status),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringProcessingBody extends StatelessWidget {
|
||||
const ScoringProcessingBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Идёт проверка…',
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringCompleteBody extends StatelessWidget {
|
||||
const ScoringCompleteBody({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: appTheme.green, size: 22),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Проверка успешно завершена',
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScoringFailedBody extends StatelessWidget {
|
||||
const ScoringFailedBody({
|
||||
required this.retryInProgress,
|
||||
required this.onRetry,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool retryInProgress;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: ElevatedButton(
|
||||
onPressed: retryInProgress ? null : onRetry,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: appTheme.blueA400,
|
||||
disabledBackgroundColor: appTheme.greyD9,
|
||||
foregroundColor: appTheme.whiteA700,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: retryInProgress
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Повторить',
|
||||
style: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract final class ScoringStatusLabels {
|
||||
static String hintFor(AlfaScoringStatus status) {
|
||||
return switch (status) {
|
||||
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
|
||||
AlfaScoringStatus.scoringPending => 'Ожидание результата',
|
||||
AlfaScoringStatus.scoringComplete => 'Успешно',
|
||||
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
|
||||
AlfaScoringStatus.scoringFailed => 'Ошибка',
|
||||
AlfaScoringStatus.none => '',
|
||||
};
|
||||
}
|
||||
|
||||
static Color colorFor(AlfaScoringStatus status) {
|
||||
return switch (status) {
|
||||
AlfaScoringStatus.scoringComplete => appTheme.green,
|
||||
AlfaScoringStatus.beneficiaryFailed ||
|
||||
AlfaScoringStatus.scoringFailed => appTheme.red,
|
||||
AlfaScoringStatus.beneficiaryCreating ||
|
||||
AlfaScoringStatus.scoringPending => appTheme.blueA400,
|
||||
AlfaScoringStatus.none => appTheme.grey7B,
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user