Compare commits
335 Commits
issue-911
...
android-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55e1633d1c | ||
| 5b2ac4f228 | |||
| 51aff6c175 | |||
| cba2689406 | |||
| 5f0b7e026b | |||
| 3a41a85f9d | |||
| 194b8d5b16 | |||
|
|
508c321913 | ||
|
|
4a5c67632e | ||
| e2d287eb9e | |||
| cf5c42ff4b | |||
|
|
5e6e114d5d | ||
|
|
e9cfcb9637 | ||
| 28c62bf775 | |||
| 67e6526552 | |||
|
|
1c8709fc50 | ||
| dedb55ecca | |||
| 6b4c88068e | |||
|
|
d30c63be85 | ||
|
|
0ddd4c6b14 | ||
| 04dab02a6d | |||
| c45612decb | |||
|
|
2779f79464 | ||
| db293bf13f | |||
| 05eaa3caa9 | |||
|
|
d8d7a211bd | ||
| fe54037cef | |||
| e6601e2fe6 | |||
|
|
992573aead | ||
| c70c9beb20 | |||
| d2fc087699 | |||
|
|
3122394037 | ||
| 18925fd95e | |||
|
|
9916828956 | ||
| 37a787c8d0 | |||
| fb68f02ca4 | |||
|
|
8cb3a184b0 | ||
| 4b72258b1f | |||
|
|
592f387221 | ||
|
|
dcf695777a | ||
| 3b8d1bf651 | |||
|
|
141cf5543d | ||
| 40ba5f304e | |||
|
|
996cf07fb8 | ||
|
|
1766314bbe | ||
| d1575d7ac4 | |||
| 94bca6a793 | |||
|
|
a6b94f835c | ||
|
|
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 | ||
|
|
54c27c28cd | ||
|
|
fe243965e3 | ||
|
|
56313a4fcc | ||
|
|
cb36ef8e07 | ||
|
|
b2072ab626 | ||
| 5b72684c95 | |||
|
|
91ad51d340 | ||
| 01282a35cb | |||
| 5d86b7c71b | |||
|
|
50aefde842 | ||
| 0991ac555e | |||
| 5fbd578463 | |||
|
|
426bd49aa1 | ||
| c6fc20d217 | |||
|
|
17e18dc68b | ||
| b5a889cbb0 | |||
| d483295aab | |||
|
|
c8284338aa | ||
|
|
03ec91a595 | ||
|
|
e814683984 | ||
|
|
684415ef5b | ||
|
|
631ceb2b7a | ||
|
|
71a405c63f | ||
|
|
4ca90eb2df | ||
|
|
f17f1ecb57 | ||
|
|
a71bcc752d | ||
|
|
aac085def4 | ||
| 985154c863 | |||
|
|
b6b13b1db5 | ||
|
|
6629286bf1 | ||
|
|
966bc23f0b | ||
|
|
a13b623ca2 | ||
|
|
ee2dd75a94 | ||
|
|
c450a41715 | ||
|
|
55b6b75134 | ||
|
|
335e36dd18 | ||
| 7106bde4ae | |||
|
|
4f241f7db6 | ||
|
|
3d7755a9a2 | ||
|
|
decc8c63e6 | ||
|
|
ad5b48ef1b | ||
|
|
e8dc0138e2 | ||
|
|
96896671b6 | ||
|
|
5e2f0430d9 | ||
|
|
2da3b10e63 | ||
|
|
5cb121d733 | ||
| f1e07b3d86 | |||
|
|
109013c25c | ||
|
|
f5d14d213f | ||
|
|
26ae28a705 | ||
|
|
7672477c03 | ||
|
|
07277d6e48 | ||
|
|
9d1bedff26 | ||
| 062079e7f1 | |||
|
|
e39638be7f | ||
| 442fc0ec14 | |||
| a8f8cc8eb0 | |||
|
|
a492c2d964 | ||
|
|
be0a99b164 | ||
|
|
6dab0d159b | ||
| 24652884ee | |||
| ea9cdb93d4 | |||
| 7c4010b275 | |||
| 4458aaf74f | |||
| f20f871c88 | |||
| f61fb8c6b3 | |||
| 3d14dee4e9 | |||
| 38647b4ac6 | |||
| c836b600c6 | |||
| dfe11a6df2 | |||
| 35cad4e05e |
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
@@ -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
@@ -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,15 +8,27 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: [macos-arm64]
|
runs-on: [macos-arm64]
|
||||||
steps:
|
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: |
|
||||||
|
git --version
|
||||||
|
fastlane --version
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v2
|
||||||
|
|
||||||
- name: Checkout env repository
|
- name: Checkout env repository
|
||||||
uses: https://github.com/actions/checkout@v2
|
uses: https://github.com/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: mso7nueh/eatvio_app_env
|
repository: eatvio/eatvio_app_env
|
||||||
path: env_repo
|
path: env_repo
|
||||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
# Здесь может быть токен любого юзера с доступом в gitea
|
||||||
|
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Copy .env file
|
- name: Copy .env file
|
||||||
@@ -54,23 +66,24 @@ jobs:
|
|||||||
|
|
||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter upgrade
|
flutter --version
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
rm -rf ios/Podfile.lock
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub upgrade
|
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
# Configure Flutter for release mode
|
cd ios
|
||||||
pod repo update
|
pod install --repo-update
|
||||||
|
cd ..
|
||||||
flutter build ios --config-only --release
|
flutter build ios --config-only --release
|
||||||
cd ios
|
cd ios
|
||||||
pod update
|
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
LC_ALL: en_US.UTF-8
|
LC_ALL: en_US.UTF-8
|
||||||
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }}
|
||||||
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }}
|
||||||
MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane
|
MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git
|
||||||
FASTLANE_USER: eatvio2023@icloud.com
|
FASTLANE_USER: eatvio2023@icloud.com
|
||||||
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }}
|
||||||
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }}
|
||||||
|
|||||||
@@ -1,37 +1,42 @@
|
|||||||
name: Eatvio App Android Test
|
name: Eatvio App Android Test
|
||||||
|
|
||||||
on: [ pull_request ]
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
flutter_version: "3.41.2"
|
flutter_version: '3.41.2'
|
||||||
java_version: "21.x"
|
java_version: '21.x'
|
||||||
flutter_channel: "stable"
|
flutter_channel: 'stable'
|
||||||
my_local_cache_dir: "/opt/hostedtoolcache/"
|
my_local_cache_dir: '/opt/hostedtoolcache/'
|
||||||
ANDROID_SDK_ROOT: "/opt/hostedtoolcache/Android/Sdk"
|
ANDROID_SDK_ROOT: '/opt/hostedtoolcache/Android/Sdk'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: [ ubuntu-latest ]
|
runs-on: [debian-12]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v3
|
- uses: https://github.com/actions/checkout@v3
|
||||||
|
|
||||||
- name: Set proxy server
|
# Пока что не нужно проксирование
|
||||||
env:
|
# - name: Set proxy server
|
||||||
PROXY_USER: ${{ secrets.PROXY_USER }}
|
# env:
|
||||||
PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }}
|
# PROXY_USER: ${{ secrets.PROXY_USER }}
|
||||||
run: |
|
# PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }}
|
||||||
echo "Setting up proxy..."
|
# run: |
|
||||||
cat <<EOF >/etc/apt/apt.conf.d/proxy.conf
|
# echo "Setting up proxy..."
|
||||||
Acquire::http::Proxy "socks5h://${PROXY_USER}:${PROXY_PASSWORD}@10.55.5.202:19843/";
|
# cat <<EOF >/etc/apt/apt.conf.d/proxy.conf
|
||||||
EOF
|
# Acquire::http::Proxy "socks5h://${PROXY_USER}:${PROXY_PASSWORD}@10.55.5.202:19843/";
|
||||||
|
# EOF
|
||||||
|
|
||||||
- name: Checkout env repository
|
- name: Checkout env repository
|
||||||
uses: https://github.com/actions/checkout@v3
|
uses: https://github.com/actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
repository: mso7nueh/eatvio_app_env
|
repository: eatvio/eatvio_app_env
|
||||||
path: env_repo
|
path: env_repo
|
||||||
token: ${{ vars.MSO7NUEH_TOKEN }}
|
# Здесь может быть токен любого юзера с доступом в gitea
|
||||||
|
token: ${{ vars.READ_USER_GITEA_TOKEN }}
|
||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Copy .env file
|
- name: Copy .env file
|
||||||
@@ -66,7 +71,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache Android SDK
|
- name: Cache Android SDK
|
||||||
id: cache-sdk
|
id: cache-sdk
|
||||||
uses: "https://github.com/actions/cache@v4"
|
uses: 'https://github.com/actions/cache@v4'
|
||||||
with:
|
with:
|
||||||
path: ${{ env.my_local_cache_dir }}
|
path: ${{ env.my_local_cache_dir }}
|
||||||
key: ${{ runner.os }}-cache-sdk
|
key: ${{ runner.os }}-cache-sdk
|
||||||
@@ -74,7 +79,7 @@ jobs:
|
|||||||
- name: Set up JDK
|
- name: Set up JDK
|
||||||
uses: https://github.com/actions/setup-java@v3
|
uses: https://github.com/actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
distribution: "zulu"
|
distribution: 'zulu'
|
||||||
java-version: ${{ env.java_version }}
|
java-version: ${{ env.java_version }}
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
@@ -103,13 +108,12 @@ jobs:
|
|||||||
sdkmanager --install "cmake;3.22.1"
|
sdkmanager --install "cmake;3.22.1"
|
||||||
|
|
||||||
- name: Set up Android environment variables
|
- name: Set up Android environment variables
|
||||||
id: "android-env-setup"
|
id: 'android-env-setup'
|
||||||
run: |
|
run: |
|
||||||
echo "ANDROID_SDK_ROOT=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV
|
echo "ANDROID_SDK_ROOT=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV
|
||||||
echo "PATH=$PATH:${{ env.my_local_cache_dir }}Android/Sdk/cmdline-tools/latest/bin:${{ env.my_local_cache_dir }}Android/Sdk/platform-tools:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/30.0.3:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/34.0.0" >> $GITHUB_ENV
|
echo "PATH=$PATH:${{ env.my_local_cache_dir }}Android/Sdk/cmdline-tools/latest/bin:${{ env.my_local_cache_dir }}Android/Sdk/platform-tools:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/30.0.3:${{ env.my_local_cache_dir }}Android/Sdk/build-tools/34.0.0" >> $GITHUB_ENV
|
||||||
echo "ANDROID_HOME=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV
|
echo "ANDROID_HOME=${{ env.my_local_cache_dir }}Android/Sdk" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
|
||||||
- name: Set up Flutter
|
- name: Set up Flutter
|
||||||
run: |
|
run: |
|
||||||
FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter"
|
FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter"
|
||||||
@@ -152,6 +156,7 @@ jobs:
|
|||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
run: |
|
run: |
|
||||||
flutter clean
|
flutter clean
|
||||||
|
flutter pub cache repair
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart run build_runner build --delete-conflicting-outputs
|
dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
@@ -161,67 +166,66 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: flutter test --coverage
|
run: flutter test --coverage
|
||||||
|
|
||||||
- name: Test coverage
|
# - name: Test coverage
|
||||||
run: |
|
# run: |
|
||||||
echo "📊 Analyzing test coverage..."
|
# echo "📊 Analyzing test coverage..."
|
||||||
echo ""
|
# echo ""
|
||||||
awk -F: '
|
# awk -F: '
|
||||||
/^SF:/ {
|
# /^SF:/ {
|
||||||
file = $0
|
# file = $0
|
||||||
sub(/^SF:/, "", file)
|
# sub(/^SF:/, "", file)
|
||||||
sub(/^[[:space:]]+/, "", file)
|
# sub(/^[[:space:]]+/, "", file)
|
||||||
is_generated = 0
|
# is_generated = 0
|
||||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
# if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
# } else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||||
is_generated = 1
|
# is_generated = 1
|
||||||
generated_files++
|
# generated_files++
|
||||||
} else {
|
# } else {
|
||||||
source_files++
|
# source_files++
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LF:/ {
|
# /^LF:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
total += $2
|
# total += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_lines += $2
|
# generated_lines += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
/^LH:/ {
|
# /^LH:/ {
|
||||||
if (!is_generated) {
|
# if (!is_generated) {
|
||||||
covered += $2
|
# covered += $2
|
||||||
} else {
|
# } else {
|
||||||
generated_covered += $2
|
# generated_covered += $2
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
END {
|
# END {
|
||||||
print "📁 Files:"
|
# print "📁 Files:"
|
||||||
printf " Source files: %d\n", source_files
|
# printf " Source files: %d\n", source_files
|
||||||
printf " Generated files: %d (excluded)\n", generated_files
|
# printf " Generated files: %d (excluded)\n", generated_files
|
||||||
print ""
|
# print ""
|
||||||
print "📝 Lines:"
|
# print "📝 Lines:"
|
||||||
printf " Total lines: %d\n", total
|
# printf " Total lines: %d\n", total
|
||||||
printf " Covered lines: %d\n", covered
|
# printf " Covered lines: %d\n", covered
|
||||||
printf " Uncovered lines: %d\n", total - covered
|
# printf " Uncovered lines: %d\n", total - covered
|
||||||
print ""
|
# print ""
|
||||||
if (total == 0) {
|
# if (total == 0) {
|
||||||
print "⚠️ Coverage: 0%"
|
# print "⚠️ Coverage: 0%"
|
||||||
exit 1
|
# exit 1
|
||||||
} else {
|
# } else {
|
||||||
percentage = (covered / total) * 100
|
# percentage = (covered / total) * 100
|
||||||
printf "✅ Coverage: %.2f%%\n", percentage
|
# printf "✅ Coverage: %.2f%%\n", percentage
|
||||||
print ""
|
# print ""
|
||||||
if (percentage < 60) {
|
# if (percentage < 70) {
|
||||||
printf "⚠️ Warning: Coverage is below 60%%\n"
|
# printf "❌ FAIL: Coverage is below 70%%\n"
|
||||||
} else if (percentage < 80) {
|
# exit 1
|
||||||
printf "💛 Good: Coverage is above 60%%\n"
|
# } else {
|
||||||
} else {
|
# printf "💚 Excellent: Coverage is above 70%%\n"
|
||||||
printf "💚 Excellent: Coverage is above 80%%\n"
|
# }
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
}
|
# ' coverage/lcov.info
|
||||||
' coverage/lcov.info
|
|
||||||
|
|
||||||
- name: Build apk
|
- name: Build apk
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
1
.gitignore
vendored
@@ -25,3 +25,4 @@ backend/
|
|||||||
.fvm/
|
.fvm/
|
||||||
.fvmrc
|
.fvmrc
|
||||||
/eatvio
|
/eatvio
|
||||||
|
*.code-workspace
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Current Context
|
# Current Context
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||||
|
|
||||||
## Recent Activity
|
## Recent Activity
|
||||||
- Memory Bank initialization completed
|
- Memory Bank initialization completed
|
||||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
|||||||
- Architecture and technology stack catalogued
|
- Architecture and technology stack catalogued
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
Ready for development tasks. No active work in progress.
|
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||||
|
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||||
|
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||||
|
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||||
|
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||||
|
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||||
|
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||||
|
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
None documented at initialization.
|
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||||
|
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||||
|
связаны с fixtures/маппингом, не с видеоплеером.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
Awaiting user direction for:
|
Awaiting user direction for:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
YANDEX_CLIENT_ID: yandexClientId,
|
YANDEX_CLIENT_ID: yandexClientId,
|
||||||
applicationName: "io.flutter.app.FlutterApplication"
|
applicationName: "com.eatvioapp.app.EatvioApplication"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +69,9 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.agconnect:agconnect-core:1.9.5.300'
|
||||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||||
|
|||||||
6
android/app/proguard-rules.pro
vendored
@@ -4,6 +4,12 @@
|
|||||||
-keep class org.bouncycastle.** { *; }
|
-keep class org.bouncycastle.** { *; }
|
||||||
-dontwarn org.bouncycastle.**
|
-dontwarn org.bouncycastle.**
|
||||||
|
|
||||||
|
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
|
||||||
|
-dontwarn com.hiennv.flutter_callkit_incoming.**
|
||||||
|
|
||||||
|
-keep class com.pravera.flutter_foreground_task.** { *; }
|
||||||
|
-dontwarn com.pravera.flutter_foreground_task.**
|
||||||
|
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-dontwarn io.flutter.**
|
-dontwarn io.flutter.**
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
@@ -11,6 +17,16 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<service
|
||||||
|
android:name=".EatvioFcmMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
<service
|
||||||
|
android:name="io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingService"
|
||||||
|
tools:node="remove" />
|
||||||
<service
|
<service
|
||||||
android:name=".HmsPushService"
|
android:name=".HmsPushService"
|
||||||
android:exported="false">
|
android:exported="false">
|
||||||
@@ -31,7 +47,7 @@
|
|||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:supportsPictureInPicture="true"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
@@ -55,32 +71,14 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="https" />
|
<data android:scheme="https" />
|
||||||
|
|
||||||
|
<!-- Main domains -->
|
||||||
<data android:host="eatvio.ru" />
|
<data android:host="eatvio.ru" />
|
||||||
<data android:host="www.eatvio.ru" />
|
<data android:host="www.eatvio.ru" />
|
||||||
|
<data android:host="eatvio.com" />
|
||||||
|
<data android:host="www.eatvio.com" />
|
||||||
|
|
||||||
<data android:pathPattern="/ru/recipe/.*" />
|
|
||||||
<data android:pathPattern="/en/recipe/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/blog_post/.*" />
|
|
||||||
<data android:pathPattern="/en/blog_post/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/diet/.*" />
|
|
||||||
<data android:pathPattern="/en/diet/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/plan/.*" />
|
|
||||||
<data android:pathPattern="/en/plan/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/chat/.*" />
|
|
||||||
<data android:pathPattern="/en/chat/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/chat" />
|
|
||||||
<data android:pathPattern="/en/chat" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/profile/.*" />
|
|
||||||
<data android:pathPattern="/en/profile/.*" />
|
|
||||||
|
|
||||||
<data android:pathPattern="/ru/diary/.*" />
|
|
||||||
<data android:pathPattern="/en/diary/.*" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
@@ -88,6 +86,77 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
@@ -99,5 +168,168 @@
|
|||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- SBP bank packages for СБП payment detection -->
|
||||||
|
<package android:name="ru.sberbankmobile" />
|
||||||
|
<package android:name="com.idamob.tinkoff.android" />
|
||||||
|
<package android:name="ru.vtb24.mobilebanking.android" />
|
||||||
|
<package android:name="ru.alfabank.mobile.android" />
|
||||||
|
<package android:name="ru.raiffeisennews" />
|
||||||
|
<package android:name="com.openbank" />
|
||||||
|
<package android:name="ru.gazprombank.android.mobilebank.app" />
|
||||||
|
<package android:name="com.logo.mbanking" />
|
||||||
|
<package android:name="ru.sovcomcard.halva.v1" />
|
||||||
|
<package android:name="ru.rosbank.android" />
|
||||||
|
<package android:name="ru.rshb.dbo" />
|
||||||
|
<package android:name="ru.mkb.mobile" />
|
||||||
|
<package android:name="ru.ucb.android" />
|
||||||
|
<package android:name="ru.unicredit.android" />
|
||||||
|
<package android:name="ru.skbbank.ib" />
|
||||||
|
<package android:name="ru.gebank.ib" />
|
||||||
|
<package android:name="ru.avangard.sbpent" />
|
||||||
|
<package android:name="ru.elplat.elplat2" />
|
||||||
|
<package android:name="com.bifit.rncbbeta" />
|
||||||
|
<package android:name="ru.ftc.faktura.expobank" />
|
||||||
|
<package android:name="com.bssys.vbrrretail" />
|
||||||
|
<package android:name="ru.wildberries.razz" />
|
||||||
|
<package android:name="ru.artsofte.russiafl" />
|
||||||
|
<package android:name="ru.ftc.faktura.multibank" />
|
||||||
|
<package android:name="ru.bank_hlynov.xbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.vesta" />
|
||||||
|
<package android:name="com.isimplelab.ibank.solidarnost" />
|
||||||
|
<package android:name="com.bssys.roscapretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.kbhmb" />
|
||||||
|
<package android:name="ru.mts.money" />
|
||||||
|
<package android:name="ru.ftc.faktura.pskb" />
|
||||||
|
<package android:name="ru.ftc.faktura.nskbl" />
|
||||||
|
<package android:name="ru.akbars.mobile" />
|
||||||
|
<package android:name="ru.ftc.faktura.rostfinance" />
|
||||||
|
<package android:name="ru.ilb.bbmobile" />
|
||||||
|
<package android:name="ru.mcbankrus.MCBankRus" />
|
||||||
|
<package android:name="ru.albank.online.aebit" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.icbru" />
|
||||||
|
<package android:name="ru.ftc.faktura.orbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.agropromkredit" />
|
||||||
|
<package android:name="com.energobank.digital" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.kbnk" />
|
||||||
|
<package android:name="ru.ftc.faktura.sdm" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.mbb" />
|
||||||
|
<package android:name="ru.ftc.faktura.absolutbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.akcept" />
|
||||||
|
<package android:name="ru.faktura.etbank.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.razvitiestolica" />
|
||||||
|
<package android:name="ru.ftc.faktura.resokreditbank" />
|
||||||
|
<package android:name="ru.yoo.money" />
|
||||||
|
<package android:name="ru.bspb" />
|
||||||
|
<package android:name="com.isimplelab.ibank.kubankredit" />
|
||||||
|
<package android:name="com.tochka.bank" />
|
||||||
|
<package android:name="com.bssys.novikomretail" />
|
||||||
|
<package android:name="ru.rosdorbank.physical" />
|
||||||
|
<package android:name="ru.ftc.faktura.primsoc" />
|
||||||
|
<package android:name="ru.ftc.faktura.banksaratov" />
|
||||||
|
<package android:name="ru.ftc.faktura.tavrich" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.thbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.kremlevskiy" />
|
||||||
|
<package android:name="ru.ftc.faktura.tkbbank" />
|
||||||
|
<package android:name="ru.chelyabinvestbank.investpay" />
|
||||||
|
<package android:name="ru.ftc.faktura.derzhava" />
|
||||||
|
<package android:name="ru.nbd.android" />
|
||||||
|
<package android:name="ru.sngb.dbo.client.android" />
|
||||||
|
<package android:name="ru.ftc.faktura.united" />
|
||||||
|
<package android:name="ru.ftc.faktura.venetsbank" />
|
||||||
|
<package android:name="ru.letobank.Prometheus" />
|
||||||
|
<package android:name="ru.simpls.brs2.mobbank" />
|
||||||
|
<package android:name="com.bifit.dvbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.intesabank" />
|
||||||
|
<package android:name="com.mmonline.mobile" />
|
||||||
|
<package android:name="cb.ibank.vuz" />
|
||||||
|
<package android:name="ru.uralprombank.mobilebanknew.googleplay" />
|
||||||
|
<package android:name="ru.ftc.faktura.nsbank" />
|
||||||
|
<package android:name="ru.emb.android" />
|
||||||
|
<package android:name="ru.ftc.faktura.maritimebank" />
|
||||||
|
<package android:name="com.credituralbank.CUBmobilenew" />
|
||||||
|
<package android:name="com.isimplelab.isimpleceo.uglemet" />
|
||||||
|
<package android:name="com.bssys.stroylesretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.iturup" />
|
||||||
|
<package android:name="ru.ftc.faktura.finbank" />
|
||||||
|
<package android:name="ru.faktura.gaztransbank.person" />
|
||||||
|
<package android:name="com.isimplelab.ibank.chelind" />
|
||||||
|
<package android:name="ru.ftc.faktura.nokss" />
|
||||||
|
<package android:name="ru.ftc.faktura.vlbb" />
|
||||||
|
<package android:name="ru.ftc.faktura.kbb" />
|
||||||
|
<package android:name="ru.faktura.avtograd.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.forshtadt" />
|
||||||
|
<package android:name="ru.ftc.faktura.solidbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.alexbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.akibank" />
|
||||||
|
<package android:name="ru.ftc.faktura.nipbank" />
|
||||||
|
<package android:name="ru.ftc.faktura.alefbank" />
|
||||||
|
<package android:name="com.bifit.vfbank" />
|
||||||
|
<package android:name="com.bssys.uralfdretail" />
|
||||||
|
<package android:name="com.snb.online" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.okbank" />
|
||||||
|
<package android:name="com.tatsotsbank.dbomobile" />
|
||||||
|
<package android:name="ru.vtkbank.android" />
|
||||||
|
<package android:name="ru.faktura.jtbank.person" />
|
||||||
|
<package android:name="ru.ftc.faktura.ns" />
|
||||||
|
<package android:name="ru.ftc.faktura.zemskybank" />
|
||||||
|
<package android:name="com.bssys.aversretail" />
|
||||||
|
<package android:name="ru.rusnarbank.correqts.retail" />
|
||||||
|
<package android:name="ru.ftc.faktura.baikalinvestbank" />
|
||||||
|
<package android:name="ru.sbi.android" />
|
||||||
|
<package android:name="com.metkombank.mobile" />
|
||||||
|
<package android:name="com.isimplelab.ionic.kazan.fl" />
|
||||||
|
<package android:name="com.intervale.sbpas" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.zarech" />
|
||||||
|
<package android:name="ru.centrinvest.mobilebanking2018" />
|
||||||
|
<package android:name="com.mifors.izhcombank" />
|
||||||
|
<package android:name="com.bifit.pmobile.isbank" />
|
||||||
|
<package android:name="ru.zenit.android" />
|
||||||
|
<package android:name="ru.briginvest.sense" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.cfb" />
|
||||||
|
<package android:name="com.isimplelab.ionic.gorbank.prod" />
|
||||||
|
<package android:name="ru.ftc.faktura.moscombank" />
|
||||||
|
<package android:name="com.timerbank.retail" />
|
||||||
|
<package android:name="com.mob.android" />
|
||||||
|
<package android:name="com.citibank.mobile.ru" />
|
||||||
|
<package android:name="com.bifit.atbbank" />
|
||||||
|
<package android:name="ru.mw" />
|
||||||
|
<package android:name="ru.bankuralsib.mb.android" />
|
||||||
|
<package android:name="com.banksoyuz.artsofte" />
|
||||||
|
<package android:name="ru.rubank.ubsmobile" />
|
||||||
|
<package android:name="cb.ibank" />
|
||||||
|
<package android:name="ru.ipb.ubsmobile" />
|
||||||
|
<package android:name="com.openwaygroup.ic.panda.poidem" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.tenderbank" />
|
||||||
|
<package android:name="ru.otpbank.mobile" />
|
||||||
|
<package android:name="ru.krk.ubsmobile" />
|
||||||
|
<package android:name="ru.homecredit.mycredit" />
|
||||||
|
<package android:name="cz.bsc.rc" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.belsocbank" />
|
||||||
|
<package android:name="com.hicebank.android" />
|
||||||
|
<package android:name="com.bpc.crossplatform_trading.bpc_trading" />
|
||||||
|
<package android:name="com.bifit.nrb" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.moskb" />
|
||||||
|
<package android:name="ru.isfront.android.kt" />
|
||||||
|
<package android:name="ru.ftc.faktura.novobank" />
|
||||||
|
<package android:name="com.smp.android" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.nkbank" />
|
||||||
|
<package android:name="com.isimplelab.ibank.toyota" />
|
||||||
|
<package android:name="com.bifit.mobile.private.bbr" />
|
||||||
|
<package android:name="ru.ftc.faktura.lanta" />
|
||||||
|
<package android:name="com.yandex.bank" />
|
||||||
|
<package android:name="ru.ftc.faktura.bgfbank" />
|
||||||
|
<package android:name="com.idamobile.android.LockoBank" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.MCBank" />
|
||||||
|
<package android:name="com.cetelem.cetelem_android" />
|
||||||
|
<package android:name="com.bssys.gutaretail" />
|
||||||
|
<package android:name="ru.ftc.faktura.siab" />
|
||||||
|
<package android:name="com.isimplelab.ionic.round.prod" />
|
||||||
|
<package android:name="com.priobank.prio" />
|
||||||
|
<package android:name="com.inbank.mobilebank" />
|
||||||
|
<package android:name="com.isimplelab.isimplemobile.payjet" />
|
||||||
|
<package android:name="ru.ftc.faktura.agroros" />
|
||||||
|
<package android:name="com.bifit.mobile.citizen.invb" />
|
||||||
|
<package android:name="com.compassplus.mobicash.customer" />
|
||||||
|
<package android:name="ru.finambank.app" />
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
|
||||||
|
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
|
||||||
|
return payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 payloadMap(data, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
|
||||||
|
intent.action = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
|
||||||
|
PendingCallKitDeclineBridge.store(payload)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
MainActivity.onPendingDecline?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,10 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
class EatvioApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
VideoCallDeclineReporter.install(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.dismissEndedVideoCall(this, message.data)
|
||||||
|
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
|
if (message == null) return
|
||||||
|
|
||||||
// Берём action_url из data-поля, если оно есть.
|
if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) {
|
||||||
val actionUrl = message.dataOfMap["action_url"]
|
VideoCallPushBridge.dismissEndedVideoCall(this, message.dataOfMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
|
if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) {
|
||||||
|
VideoCallPushBridge.showIncomingCall(this, message.dataOfMap)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionUrl = message.dataOfMap["action_url"]
|
||||||
showNotification(message, actionUrl)
|
showNotification(message, actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||||
val context = applicationContext
|
val context = applicationContext
|
||||||
|
|
||||||
// Интент для запуска Flutter-активити.
|
|
||||||
val intent = Intent(context, MainActivity::class.java).apply {
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
if (!actionUrl.isNullOrBlank()) {
|
if (!actionUrl.isNullOrBlank()) {
|
||||||
@@ -48,7 +54,7 @@ class HmsPushService : HmsMessageService() {
|
|||||||
val channelId = "hms_push_channel"
|
val channelId = "hms_push_channel"
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, channelId)
|
val notification = NotificationCompat.Builder(context, channelId)
|
||||||
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
||||||
.setContentText(message.notification?.body ?: "")
|
.setContentText(message.notification?.body ?: "")
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
@@ -58,5 +64,3 @@ class HmsPushService : HmsMessageService() {
|
|||||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,140 @@
|
|||||||
package com.eatvioapp.app
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Rational
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||||
import com.huawei.hms.aaid.HmsInstanceId
|
import com.huawei.hms.aaid.HmsInstanceId
|
||||||
|
import com.huawei.hms.api.ConnectionResult
|
||||||
|
import com.huawei.hms.api.HuaweiApiAvailability
|
||||||
|
import com.huawei.hms.common.ApiException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
var isInForeground: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
var onPendingDecline: (() -> Unit)? = null
|
||||||
|
}
|
||||||
|
|
||||||
private val channelName = "com.eatvioapp/hms_push"
|
private val channelName = "com.eatvioapp/hms_push"
|
||||||
|
private val videoCallPipChannelName = "com.eatvioapp/video_call_pip"
|
||||||
|
private val videoCallPushChannelName = "com.eatvioapp/video_call_push"
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipChannel: MethodChannel? = null
|
||||||
|
private var videoCallPushChannel: MethodChannel? = null
|
||||||
|
private var videoCallPipEnabled: Boolean = false
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
isInForeground = true
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
if (PendingCallKitAcceptBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
if (PendingCallKitDeclineBridge.peek() != null) {
|
||||||
|
scheduleDeliverPendingCallKitDecline()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
onPendingDecline = null
|
||||||
|
isInForeground = false
|
||||||
|
super.onPause()
|
||||||
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||||
|
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
|
||||||
|
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
|
||||||
|
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
|
||||||
|
videoCallPushChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"dismissIncomingCalls" -> {
|
||||||
|
try {
|
||||||
|
val args = call.arguments as? Map<*, *>
|
||||||
|
val callId = args?.get("call_id")?.toString()
|
||||||
|
?: args?.get("callId")?.toString()
|
||||||
|
val conversationUuid = args?.get("conversation_uuid")?.toString()
|
||||||
|
?: args?.get("conversationUuid")?.toString()
|
||||||
|
VideoCallPushBridge.dismissIncomingCalls(
|
||||||
|
applicationContext,
|
||||||
|
callId = callId,
|
||||||
|
conversationUuid = conversationUuid,
|
||||||
|
)
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
"drainPendingCallKitDecline" -> {
|
||||||
|
result.success(PendingCallKitDeclineBridge.drain())
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
videoCallPipChannel?.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"setPipEnabled" -> {
|
||||||
|
videoCallPipEnabled = call.arguments as? Boolean ?: false
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
methodChannel?.setMethodCallHandler { call, result ->
|
methodChannel?.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"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" -> {
|
"getHuaweiToken" -> {
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
@@ -61,10 +174,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
result.success(token)
|
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) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
val message = e.message.orEmpty()
|
||||||
runOnUiThread {
|
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
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 +208,91 @@ class MainActivity: FlutterActivity() {
|
|||||||
|
|
||||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
enterVideoCallPictureInPictureIfNeeded()
|
||||||
|
videoCallPipChannel?.invokeMethod("onUserLeaveHint", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
captureCallKitLaunchIntent(intent)
|
||||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||||
handleIntentForActionUrl(intent)
|
handleIntentForActionUrl(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun captureCallKitLaunchIntent(intent: Intent?) {
|
||||||
|
val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
PendingCallKitAcceptBridge.store(payload)
|
||||||
|
CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent)
|
||||||
|
scheduleDeliverPendingCallKitAccept()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitAccept() {
|
||||||
|
PendingCallKitAcceptBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitAcceptToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitAcceptToFlutter() {
|
||||||
|
val payload = PendingCallKitAcceptBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitLaunchIntent", payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleDeliverPendingCallKitDecline() {
|
||||||
|
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
|
||||||
|
deliver = { deliverPendingCallKitDeclineToFlutter() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deliverPendingCallKitDeclineToFlutter() {
|
||||||
|
val payload = PendingCallKitDeclineBridge.peek() ?: return
|
||||||
|
val channel = videoCallPushChannel
|
||||||
|
if (channel == null) {
|
||||||
|
android.util.Log.w(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: channel not ready",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.i(
|
||||||
|
"VideoCallPush",
|
||||||
|
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
|
||||||
|
)
|
||||||
|
channel.invokeMethod("onCallKitDecline", payload)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
|
||||||
|
if (intent.hasExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Вариант 1: action_url лежит в extras.
|
// Вариант 1: action_url лежит в extras.
|
||||||
val fromExtras = intent.extras?.getString("action_url")
|
val fromExtras = intent.extras?.getString("action_url")
|
||||||
|
|
||||||
@@ -101,4 +308,19 @@ class MainActivity: FlutterActivity() {
|
|||||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun enterVideoCallPictureInPictureIfNeeded() {
|
||||||
|
if (!videoCallPipEnabled) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
if (isInPictureInPictureMode) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val params = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(Rational(2, 3))
|
||||||
|
.build()
|
||||||
|
enterPictureInPictureMode(params)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores accepted call payloads until the Flutter engine is ready to drain them.
|
||||||
|
object PendingCallKitAcceptBridge {
|
||||||
|
private const val TAG = "PendingCallKitAccept"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var pendingPayload: Map<String, Any?>? = null
|
||||||
|
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
fun store(payload: Map<String, Any?>) {
|
||||||
|
pendingPayload = payload
|
||||||
|
Log.i(TAG, "stored pending accept id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peek(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun drain(): Map<String, Any?>? {
|
||||||
|
val payload = pendingPayload
|
||||||
|
pendingPayload = null
|
||||||
|
if (payload != null) {
|
||||||
|
Log.i(TAG, "drained pending accept id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peekPending(): Map<String, Any?>? = pendingPayload
|
||||||
|
|
||||||
|
fun scheduleDeliverToFlutter(
|
||||||
|
deliver: () -> Unit,
|
||||||
|
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
|
||||||
|
) {
|
||||||
|
for (delayMs in delaysMs) {
|
||||||
|
mainHandler.postDelayed({ deliver() }, delayMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/// Stores a decline until Flutter can POST /video-call/decline.
|
||||||
|
///
|
||||||
|
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
|
||||||
|
/// that broadcast never reaches the backend and the call keeps ringing on the
|
||||||
|
/// user's other devices.
|
||||||
|
object PendingCallKitDeclineBridge {
|
||||||
|
private const val TAG = "PendingCallKitDecline"
|
||||||
|
|
||||||
|
@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 decline 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 decline id=${payload["id"]}")
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
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,73 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
internal object FlutterSharedPrefs {
|
||||||
|
const val NAME = "FlutterSharedPreferences"
|
||||||
|
private const val PREFIX = "flutter."
|
||||||
|
|
||||||
|
fun string(context: Context, key: String): String? {
|
||||||
|
return context
|
||||||
|
.getSharedPreferences(NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(PREFIX + key, null)
|
||||||
|
?.trim()
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object VideoCallDeclineClient {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val TIMEOUT_MS = 4_000
|
||||||
|
|
||||||
|
fun send(context: Context, handle: String?, extra: Map<*, *>?) {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(handle, extra)
|
||||||
|
val baseUrl = FlutterSharedPrefs.string(context, "apiBaseUrl")
|
||||||
|
?: FlutterSharedPrefs.string(context, "customBaseUrl")
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
token = FlutterSharedPrefs.string(context, "token"),
|
||||||
|
hwId = FlutterSharedPrefs.string(context, "eatvio.android_install_hw_id"),
|
||||||
|
fields = fields,
|
||||||
|
)
|
||||||
|
if (request == null) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"skip decline POST conversation=${fields.conversationUuid} ended=${fields.endedPush}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val code = post(request)
|
||||||
|
Log.i(TAG, "decline POST status=$code conversation=${fields.conversationUuid}")
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "decline POST failed conversation=${fields.conversationUuid}", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(request: VideoCallDeclineRequest): Int {
|
||||||
|
val connection = (URL(request.url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = TIMEOUT_MS
|
||||||
|
readTimeout = TIMEOUT_MS
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Authorization", "Bearer ${request.token}")
|
||||||
|
setRequestProperty("Accept", "application/json")
|
||||||
|
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.outputStream.use { stream ->
|
||||||
|
stream.write(request.body.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val code = connection.responseCode
|
||||||
|
val response = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||||
|
response?.use { stream -> stream.readBytes() }
|
||||||
|
return code
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitConstants
|
||||||
|
import com.hiennv.flutter_callkit_incoming.CallkitEventCallback
|
||||||
|
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends `POST /video-call/decline` from the process that handled the tap.
|
||||||
|
*
|
||||||
|
* The notification Decline button is an explicit broadcast into the CallKit
|
||||||
|
* plugin. That broadcast starts the process when the app was killed and does
|
||||||
|
* not start Flutter. The plugin notifies this callback before it returns, so
|
||||||
|
* the request can finish while the process is still alive.
|
||||||
|
*/
|
||||||
|
object VideoCallDeclineReporter : CallkitEventCallback {
|
||||||
|
private const val TAG = "VideoCallDecline"
|
||||||
|
private const val WAIT_FOR_HTTP_MS = 9_000L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var appContext: Context? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var installed = false
|
||||||
|
|
||||||
|
fun install(context: Context) {
|
||||||
|
if (installed) return
|
||||||
|
installed = true
|
||||||
|
appContext = context.applicationContext
|
||||||
|
FlutterCallkitIncomingPlugin.registerEventCallback(this)
|
||||||
|
Log.i(TAG, "installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallEvent(event: CallkitEventCallback.CallEvent, callData: Bundle) {
|
||||||
|
if (event != CallkitEventCallback.CallEvent.DECLINE) return
|
||||||
|
val context = appContext ?: return
|
||||||
|
val handle = callData.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)
|
||||||
|
val extra = copyExtra(callData)
|
||||||
|
if (MainActivity.isInForeground) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The Decline button starts this process and then the broadcast ends.
|
||||||
|
// Wait here so Android does not kill the process before the POST returns.
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
withTimeoutOrNull(WAIT_FOR_HTTP_MS) {
|
||||||
|
VideoCallDeclineClient.send(context, handle, extra)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyExtra(callData: Bundle): Map<String, Any?> {
|
||||||
|
val raw = BundleCompat.getSerializable(
|
||||||
|
callData,
|
||||||
|
CallkitConstants.EXTRA_CALLKIT_EXTRA,
|
||||||
|
HashMap::class.java,
|
||||||
|
) ?: return emptyMap()
|
||||||
|
return buildMap {
|
||||||
|
for ((key, value) in raw) {
|
||||||
|
if (key is String) put(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of `POST /api/v2/chat/{conversation}/video-call/decline`.
|
||||||
|
*
|
||||||
|
* [callId] is omitted when it is missing, not a uuid, or equal to the
|
||||||
|
* conversation uuid: the server treats `call_id` as the session uuid.
|
||||||
|
*/
|
||||||
|
internal data class VideoCallDeclineRequest(
|
||||||
|
val url: String,
|
||||||
|
val token: String,
|
||||||
|
val body: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class VideoCallDeclineFields(
|
||||||
|
val conversationUuid: String,
|
||||||
|
val callId: String?,
|
||||||
|
val endedPush: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object VideoCallDeclineRequestFactory {
|
||||||
|
private val HEX = "0123456789ABCDEF".toCharArray()
|
||||||
|
private val uuidPattern =
|
||||||
|
Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
||||||
|
|
||||||
|
fun fields(handle: String?, extra: Map<*, *>?): VideoCallDeclineFields {
|
||||||
|
val conversation = firstString(
|
||||||
|
extra,
|
||||||
|
listOf("conversationUuid", "conversation_uuid"),
|
||||||
|
) ?: handle?.trim().orEmpty()
|
||||||
|
val rawCallId = firstString(extra, listOf("call_id", "callId"))
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid = conversation,
|
||||||
|
callId = backendCallId(rawCallId, conversation),
|
||||||
|
endedPush = marksEndedPush(extra),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields,
|
||||||
|
): VideoCallDeclineRequest? {
|
||||||
|
if (fields.endedPush) return null
|
||||||
|
val conversation = fields.conversationUuid.trim()
|
||||||
|
val authToken = token?.trim().orEmpty()
|
||||||
|
val deviceId = hwId?.trim().orEmpty()
|
||||||
|
val root = baseUrl?.trim()?.trimEnd('/').orEmpty()
|
||||||
|
if (conversation.isEmpty() || authToken.isEmpty()) return null
|
||||||
|
if (deviceId.length < 8 || deviceId.length > 64) return null
|
||||||
|
if (!root.startsWith("http://") && !root.startsWith("https://")) return null
|
||||||
|
|
||||||
|
val encoded = encodePathSegment(conversation)
|
||||||
|
return VideoCallDeclineRequest(
|
||||||
|
url = "$root/api/v2/chat/$encoded/video-call/decline",
|
||||||
|
token = authToken,
|
||||||
|
body = jsonBody(deviceId, fields.callId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun backendCallId(callId: String?, conversationUuid: String): String? {
|
||||||
|
val id = callId?.trim().orEmpty()
|
||||||
|
if (!uuidPattern.matches(id)) return null
|
||||||
|
if (id.equals(conversationUuid.trim(), ignoreCase = true)) return null
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun firstString(extra: Map<*, *>?, keys: List<String>): String? {
|
||||||
|
if (extra == null) return null
|
||||||
|
for (key in keys) {
|
||||||
|
val value = extra[key] as? String ?: continue
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isNotEmpty()) return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun marksEndedPush(extra: Map<*, *>?): Boolean {
|
||||||
|
val value = extra?.get("endedPush") ?: return false
|
||||||
|
return when (value) {
|
||||||
|
is Boolean -> value
|
||||||
|
is Number -> value.toInt() != 0
|
||||||
|
is String -> {
|
||||||
|
val normalized = value.trim().lowercase()
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonBody(hwId: String, callId: String?): String {
|
||||||
|
val hw = jsonString(hwId)
|
||||||
|
if (callId == null) return """{"hw_id":$hw}"""
|
||||||
|
return """{"hw_id":$hw,"call_id":${jsonString(callId)}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePathSegment(value: String): String {
|
||||||
|
val bytes = value.encodeToByteArray()
|
||||||
|
return buildString(bytes.size) {
|
||||||
|
for (byte in bytes) {
|
||||||
|
val code = byte.toInt() and 0xFF
|
||||||
|
val allowed = code in 'A'.code..'Z'.code ||
|
||||||
|
code in 'a'.code..'z'.code ||
|
||||||
|
code in '0'.code..'9'.code ||
|
||||||
|
code == '-'.code ||
|
||||||
|
code == '_'.code ||
|
||||||
|
code == '.'.code ||
|
||||||
|
code == '~'.code
|
||||||
|
if (allowed) {
|
||||||
|
append(code.toChar())
|
||||||
|
} else {
|
||||||
|
append('%')
|
||||||
|
append(HEX[code ushr 4])
|
||||||
|
append(HEX[code and 0x0F])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(value: String): String {
|
||||||
|
val escaped = buildString {
|
||||||
|
for (char in value) {
|
||||||
|
when (char) {
|
||||||
|
'\\' -> append("\\\\")
|
||||||
|
'"' -> append("\\\"")
|
||||||
|
'\n' -> append("\\n")
|
||||||
|
'\r' -> append("\\r")
|
||||||
|
'\t' -> append("\\t")
|
||||||
|
else -> append(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "\"$escaped\""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
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 com.hiennv.flutter_callkit_incoming.removeCall
|
||||||
|
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 TOMBSTONE_TTL_MS = 2 * 60 * 1000L
|
||||||
|
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 val tombstonedCallIds = HashMap<String, Long>()
|
||||||
|
|
||||||
|
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 serverCallId = callId(data)
|
||||||
|
if (isTombstoned(serverCallId)) {
|
||||||
|
Log.i(TAG, "showIncomingCall: skip tombstoned call_id=$serverCallId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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: id=$callId conversation=${conversationUuid(data)}")
|
||||||
|
|
||||||
|
// 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 dismissIncomingCalls(
|
||||||
|
context: Context,
|
||||||
|
callId: String? = null,
|
||||||
|
conversationUuid: String? = null,
|
||||||
|
) {
|
||||||
|
val normalizedCallId = callId?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
if (normalizedCallId != null) {
|
||||||
|
markTombstone(normalizedCallId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val calls = getDataActiveCalls(appContext)
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: active=${calls.size} call_id=${normalizedCallId ?: "-"} " +
|
||||||
|
"conversation=${normalizedConversation ?: "-"}",
|
||||||
|
)
|
||||||
|
playbackGeneration.incrementAndGet()
|
||||||
|
|
||||||
|
val dismiss = Runnable {
|
||||||
|
synchronized(playbackLock) {
|
||||||
|
val notificationManager = resolveNotificationManager(appContext)
|
||||||
|
val allBundles = calls.map { it.toBundle() }
|
||||||
|
val targeted = hasDismissTarget(normalizedCallId, normalizedConversation)
|
||||||
|
val bundles = if (targeted) {
|
||||||
|
allBundles.filter { bundleMatches(it, normalizedCallId, normalizedConversation) }
|
||||||
|
} else {
|
||||||
|
allBundles
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeMatches = activeCallBundle?.let { bundle ->
|
||||||
|
!targeted || bundleMatches(bundle, normalizedCallId, normalizedConversation)
|
||||||
|
} == true
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopPlaybackLocked(appContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
dismissIncomingCallNotification(appContext, notificationManager, bundle)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"dismissIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}",
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted || bundles.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
CallkitNotificationService.stopService(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to stop callkit service", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targeted) {
|
||||||
|
try {
|
||||||
|
removeAllCalls(appContext)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove persisted calls", error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (bundle in bundles) {
|
||||||
|
try {
|
||||||
|
removeCall(appContext, Data.fromBundle(bundle))
|
||||||
|
} catch (error: Exception) {
|
||||||
|
Log.w(TAG, "dismissIncomingCalls: failed to remove targeted call", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeMatches || !targeted) {
|
||||||
|
stopIncomingRingtone()
|
||||||
|
}
|
||||||
|
scheduleMissedCallCleanup(notificationManager, bundles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
|
dismiss.run()
|
||||||
|
} else {
|
||||||
|
mainHandler.post(dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissAllIncomingCalls(context: Context) {
|
||||||
|
dismissIncomingCalls(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissEndedVideoCall(context: Context, data: Map<String, String>) {
|
||||||
|
dismissIncomingCalls(
|
||||||
|
context = context,
|
||||||
|
callId = callId(data),
|
||||||
|
conversationUuid = conversationUuid(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 serverCallId = callId(data)
|
||||||
|
val callKitId = serverCallId ?: conversationUuid
|
||||||
|
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
|
||||||
|
if (!serverCallId.isNullOrBlank()) {
|
||||||
|
extra["call_id"] = serverCallId
|
||||||
|
}
|
||||||
|
extra["counterpartName"] = callerName
|
||||||
|
if (avatarUrl.isNotBlank()) {
|
||||||
|
extra["counterpartAvatarUrl"] = avatarUrl
|
||||||
|
}
|
||||||
|
if (!localParticipantName.isNullOrBlank()) {
|
||||||
|
extra["localParticipantName"] = localParticipantName
|
||||||
|
}
|
||||||
|
if (counterpartIsConsultant) {
|
||||||
|
extra["counterpartIsConsultant"] = true
|
||||||
|
}
|
||||||
|
val messageId = data["message_id"] ?: data["messageId"]
|
||||||
|
if (!messageId.isNullOrBlank()) {
|
||||||
|
extra["message_id"] = messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
return Bundle().apply {
|
||||||
|
putString(CallkitConstants.EXTRA_CALLKIT_ID, callKitId)
|
||||||
|
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 callId(data: Map<String, String>): String? {
|
||||||
|
return data["call_id"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: data["callId"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasDismissTarget(callId: String?, conversationUuid: String?): Boolean {
|
||||||
|
return !callId.isNullOrBlank() || !conversationUuid.isNullOrBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bundleMatches(
|
||||||
|
bundle: Bundle,
|
||||||
|
callId: String?,
|
||||||
|
conversationUuid: String?,
|
||||||
|
): Boolean {
|
||||||
|
val normalizedCallId = callId?.trim()?.lowercase()
|
||||||
|
if (!normalizedCallId.isNullOrEmpty()) {
|
||||||
|
val id = bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)?.trim()?.lowercase()
|
||||||
|
if (id != null && id == normalizedCallId) return true
|
||||||
|
val extraCallId = extraString(bundle, "call_id")
|
||||||
|
?: extraString(bundle, "callId")
|
||||||
|
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A ring that already stores its own call id must not be closed by
|
||||||
|
// another call in the same chat. Legacy rings have no call id and
|
||||||
|
// still match the conversation.
|
||||||
|
if (!extraCallId.isNullOrBlank()) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false
|
||||||
|
val handle = bundle.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE)?.trim()?.lowercase()
|
||||||
|
if (handle != null && handle == normalizedConversation) return true
|
||||||
|
val extraConversation = extraString(bundle, "conversationUuid")
|
||||||
|
?: extraString(bundle, "conversation_uuid")
|
||||||
|
return extraConversation?.lowercase() == normalizedConversation
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun extraString(bundle: Bundle, key: String): String? {
|
||||||
|
val extra = bundle.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA) as? HashMap<String, Any?>
|
||||||
|
?: return null
|
||||||
|
val value = extra[key] ?: return null
|
||||||
|
val text = value.toString().trim()
|
||||||
|
return text.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun markTombstone(callId: String) {
|
||||||
|
val normalized = callId.trim().lowercase()
|
||||||
|
if (normalized.isEmpty()) return
|
||||||
|
pruneTombstones()
|
||||||
|
tombstonedCallIds[normalized] = System.currentTimeMillis() + TOMBSTONE_TTL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTombstoned(callId: String?): Boolean {
|
||||||
|
val normalized = callId?.trim()?.lowercase() ?: return false
|
||||||
|
if (normalized.isEmpty()) return false
|
||||||
|
pruneTombstones()
|
||||||
|
return tombstonedCallIds.containsKey(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pruneTombstones() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val iterator = tombstonedCallIds.entries.iterator()
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
val entry = iterator.next()
|
||||||
|
if (entry.value <= now) {
|
||||||
|
iterator.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.eatvioapp.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VideoCallDeclineRequestTest {
|
||||||
|
private val baseUrl = "https://eatvio.example"
|
||||||
|
private val token = "sanctum-token"
|
||||||
|
private val hwId = "install-hw-id-01"
|
||||||
|
private val conversation = "11111111-1111-1111-1111-111111111111"
|
||||||
|
private val callId = "22222222-2222-2222-2222-222222222222"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildsDeclinePostWithCallId() {
|
||||||
|
val request = request(callId = callId)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
assertEquals(token, request?.token)
|
||||||
|
assertEquals(
|
||||||
|
"""{"hw_id":"$hwId","call_id":"$callId"}""",
|
||||||
|
request?.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdWhenItIsTheConversationUuid() {
|
||||||
|
val request = request(callId = conversation.uppercase())
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun omitsCallIdThatIsNotAUuid() {
|
||||||
|
val request = request(callId = "message-15")
|
||||||
|
|
||||||
|
assertEquals("""{"hw_id":"$hwId"}""", request?.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsConversationFromHandleWhenExtraHasNone() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(conversation, emptyMap<String, Any?>())
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
|
||||||
|
assertTrue(request?.url?.contains(conversation) == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsEndedPush() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
conversation,
|
||||||
|
mapOf("conversationUuid" to conversation, "endedPush" to true),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun skipsMissingTokenBaseUrlOrShortHwId() {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation, "call_id" to callId),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(null, token, hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, " ", hwId, fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build(baseUrl, token, "short", fields))
|
||||||
|
assertNull(VideoCallDeclineRequestFactory.build("eatvio.example", token, hwId, fields))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trimsATrailingSlashOnTheBaseUrl() {
|
||||||
|
val request = VideoCallDeclineRequestFactory.build(
|
||||||
|
"$baseUrl/",
|
||||||
|
token,
|
||||||
|
hwId,
|
||||||
|
VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf("conversationUuid" to conversation),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"https://eatvio.example/api/v2/chat/$conversation/video-call/decline",
|
||||||
|
request?.url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun request(callId: String?): VideoCallDeclineRequest? {
|
||||||
|
val fields = VideoCallDeclineRequestFactory.fields(
|
||||||
|
null,
|
||||||
|
mapOf(
|
||||||
|
"conversationUuid" to conversation,
|
||||||
|
"call_id" to callId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return VideoCallDeclineRequestFactory.build(baseUrl, token, hwId, fields)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
assets/icons/crossed_eye.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="10" height="9" viewBox="0 0 10 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.53669 8.89177L6.8122 7.15382C6.52317 7.26749 6.22614 7.34957 5.92111 7.40005C5.61615 7.45053 5.3063 7.47578 4.99155 7.47578C3.95998 7.47578 3.01527 7.19683 2.15742 6.63893C1.29964 6.08096 0.619788 5.34562 0.117849 4.43293C0.0770142 4.36144 0.047164 4.28862 0.0282984 4.21447C0.00943278 4.14041 0 4.06506 0 3.98842C0 3.91178 0.0102087 3.8356 0.030626 3.75987C0.0510433 3.68423 0.0816693 3.61224 0.122504 3.5439C0.317857 3.21368 0.525501 2.89417 0.745437 2.58538C0.96529 2.2765 1.22096 2.00328 1.51243 1.76572L0.406101 0.633204C0.338315 0.564287 0.303606 0.478889 0.301972 0.377008C0.300421 0.275127 0.337662 0.185536 0.413696 0.108233C0.484667 0.0360776 0.570746 0 0.671934 0C0.773041 0 0.85908 0.0360776 0.93005 0.108233L9.05304 8.3668C9.12091 8.43579 9.15689 8.52119 9.16097 8.62299C9.16505 8.72487 9.12908 8.81446 9.05304 8.89177C8.98207 8.96392 8.89599 9 8.79481 9C8.6937 9 8.60766 8.96392 8.53669 8.89177ZM4.99155 6.01955C5.10025 6.01955 5.20405 6.01299 5.30295 5.99988C5.40194 5.98676 5.49855 5.95815 5.5928 5.91406L3.09751 3.37713C3.05725 3.47295 3.02989 3.57118 3.01544 3.67181C3.00098 3.77237 2.99375 3.8779 2.99375 3.98842C2.99375 4.5537 3.18772 5.03355 3.57565 5.42795C3.96358 5.82235 4.43554 6.01955 4.99155 6.01955ZM4.99155 0.501058C6.02622 0.501058 6.97529 0.781293 7.83878 1.34176C8.70227 1.90223 9.38344 2.64201 9.88227 3.56109C9.91992 3.62436 9.94896 3.69224 9.96937 3.76473C9.98979 3.83721 10 3.91178 10 3.98842C10 4.06506 9.99106 4.13962 9.97317 4.21211C9.95529 4.28459 9.92748 4.35247 9.88975 4.41574C9.74086 4.70378 9.57442 4.97521 9.39042 5.23004C9.20634 5.48487 9.00057 5.72471 8.77312 5.94956C8.67136 6.05302 8.55074 6.10059 8.41125 6.09229C8.27184 6.08399 8.15121 6.02811 8.04937 5.92465L7.0223 4.88517C6.97142 4.83344 6.93781 4.77369 6.92147 4.70594C6.90514 4.63827 6.90608 4.56769 6.92429 4.49421C6.94749 4.40868 6.96411 4.32565 6.97415 4.24511C6.98428 4.16465 6.98934 4.07909 6.98934 3.98842C6.98934 3.42313 6.79538 2.94329 6.40745 2.54888C6.01952 2.15448 5.54755 1.95728 4.99155 1.95728C4.90236 1.95728 4.81898 1.96322 4.74139 1.97509C4.66381 1.98688 4.58447 2.00461 4.50337 2.02827C4.43117 2.04994 4.35894 2.05248 4.28666 2.03587C4.21446 2.01926 4.15293 1.9851 4.10205 1.93337L3.64584 1.46967C3.50643 1.32785 3.46012 1.16274 3.50692 0.974343C3.55372 0.785942 3.67136 0.665878 3.85985 0.614149C4.04516 0.575788 4.23174 0.547349 4.41958 0.528832C4.60742 0.510316 4.79807 0.501058 4.99155 0.501058ZM5.94046 3.04758C6.03977 3.15876 6.1216 3.28347 6.18596 3.42172C6.25032 3.55997 6.29066 3.70449 6.30699 3.85527C6.31516 3.91531 6.29303 3.95699 6.2406 3.98032C6.18817 4.00357 6.139 3.9919 6.0931 3.94532L5.04153 2.88093C4.99563 2.83427 4.98542 2.78428 5.0109 2.73097C5.0363 2.67767 5.07852 2.65101 5.13757 2.65101C5.29593 2.66438 5.44199 2.70478 5.57577 2.7722C5.70962 2.83954 5.83119 2.93133 5.94046 3.04758Z" fill="#2F80ED"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
BIN
assets/icons/video_call/eatvio_c.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
3
assets/icons/video_call/poor_connection.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 12.0833V7.91666C0 7.56944 0.121528 7.2743 0.364583 7.03125C0.607639 6.78819 0.902778 6.66666 1.25 6.66666C1.59722 6.66666 1.89236 6.78819 2.13542 7.03125C2.37847 7.2743 2.5 7.56944 2.5 7.91666V12.0833C2.5 12.4306 2.37847 12.7257 2.13542 12.9687C1.89236 13.2118 1.59722 13.3333 1.25 13.3333C0.902778 13.3333 0.607639 13.2118 0.364583 12.9687C0.121528 12.7257 0 12.4306 0 12.0833ZM5 12.0833V5.83333C5 5.48611 5.12153 5.19097 5.36458 4.94791C5.60764 4.70486 5.90278 4.58333 6.25 4.58333C6.59722 4.58333 6.89236 4.70486 7.13542 4.94791C7.37847 5.19097 7.5 5.48611 7.5 5.83333V12.0833C7.5 12.4306 7.37847 12.7257 7.13542 12.9687C6.89236 13.2118 6.59722 13.3333 6.25 13.3333C5.90278 13.3333 5.60764 13.2118 5.36458 12.9687C5.12153 12.7257 5 12.4306 5 12.0833ZM10 12.0833V3.75C10 3.40278 10.1215 3.10764 10.3646 2.86458C10.6076 2.62153 10.9028 2.5 11.25 2.5C11.5972 2.5 11.8924 2.62153 12.1354 2.86458C12.3785 3.10764 12.5 3.40278 12.5 3.75V12.0833C12.5 12.4306 12.3785 12.7257 12.1354 12.9687C11.8924 13.2118 11.5972 13.3333 11.25 13.3333C10.9028 13.3333 10.6076 13.2118 10.3646 12.9687C10.1215 12.7257 10 12.4306 10 12.0833ZM15 6.8125V1.25C15 0.902777 15.1215 0.607639 15.3646 0.364583C15.6076 0.121528 15.9028 0 16.25 0C16.5972 0 16.8924 0.121528 17.1354 0.364583C17.3785 0.607639 17.5 0.902777 17.5 1.25V6.54166C17.3611 6.51389 17.2257 6.49305 17.0938 6.47916C16.9618 6.46528 16.8194 6.45833 16.6667 6.45833C16.375 6.45833 16.0868 6.48958 15.8021 6.55208C15.5174 6.61458 15.25 6.70139 15 6.8125ZM16.0729 13.0937C15.9132 12.934 15.8333 12.7361 15.8333 12.5C15.8333 12.2639 15.9132 12.066 16.0729 11.9062C16.2326 11.7465 16.4306 11.6667 16.6667 11.6667C16.9028 11.6667 17.1007 11.7465 17.2604 11.9062C17.4201 12.066 17.5 12.2639 17.5 12.5C17.5 12.7361 17.4201 12.934 17.2604 13.0937C17.1007 13.2535 16.9028 13.3333 16.6667 13.3333C16.4306 13.3333 16.2326 13.2535 16.0729 13.0937ZM15.8333 10V8.75C15.8333 8.51389 15.9132 8.31597 16.0729 8.15625C16.2326 7.99652 16.4306 7.91666 16.6667 7.91666C16.9028 7.91666 17.1007 7.99652 17.2604 8.15625C17.4201 8.31597 17.5 8.51389 17.5 8.75V10C17.5 10.2361 17.4201 10.434 17.2604 10.5937C17.1007 10.7535 16.9028 10.8333 16.6667 10.8333C16.4306 10.8333 16.2326 10.7535 16.0729 10.5937C15.9132 10.434 15.8333 10.2361 15.8333 10Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
3
assets/icons/video_call/vc_cam_off.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.292969 2.29289C0.683493 1.90237 1.31651 1.90237 1.70703 2.29289L3.41406 3.99992H11.4424C12.3865 3.99991 13.1558 3.99969 13.7803 4.05071C14.4257 4.10345 15.0042 4.21564 15.543 4.49016C16.3897 4.92159 17.0783 5.61023 17.5098 6.45696C17.7843 6.99573 17.8965 7.57424 17.9492 8.21965C17.9518 8.25175 17.9517 8.28444 17.9541 8.31731L22.4189 5.12981C23.0808 4.65704 24 5.12991 24 5.94328V18.0566C24 18.8699 23.0808 19.3428 22.4189 18.87L18 15.7138V18.5859L19.707 20.2929C20.0976 20.6834 20.0976 21.3164 19.707 21.707C19.3165 22.0975 18.6835 22.0975 18.293 21.707L0.292969 3.70696C-0.097555 3.31643 -0.0975551 2.68342 0.292969 2.29289ZM1 7.99895C1.55212 7.99895 1.99974 8.44689 2 8.99895V13.3993C2 14.3959 2.00036 15.0836 2.04395 15.6171C2.08658 16.1387 2.16554 16.4249 2.27246 16.6347C2.51215 17.105 2.89491 17.4868 3.36523 17.7265C3.57511 17.8334 3.86102 17.9124 4.38281 17.955C4.91619 17.9986 5.60333 17.9989 6.59961 17.9989H11C11.5521 17.9989 11.9997 18.4469 12 18.9989C12 19.5512 11.5523 19.9989 11 19.9989H6.55762C5.61342 19.999 4.84421 19.9992 4.21973 19.9482C3.57428 19.8954 2.99583 19.7832 2.45703 19.5087C1.61035 19.0773 0.921646 18.3886 0.490235 17.5419C0.215871 17.0032 0.103504 16.4245 0.0507818 15.7792C-0.000207456 15.1548 -1.10191e-05 14.3854 5.0477e-07 13.4413V8.99895C0.00026378 8.44689 0.447878 7.99895 1 7.99895ZM16 16.5859V10.5995C16 9.60324 15.9986 8.91611 15.9551 8.38274C15.9125 7.86113 15.8344 7.57502 15.7275 7.36516C15.4879 6.89475 15.1052 6.51207 14.6348 6.27239C14.4249 6.16547 14.1389 6.0865 13.6172 6.04387C13.0837 6.00029 12.396 5.99992 11.3994 5.99992H5.41406L16 16.5859Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/icons/video_call/vc_camera_switch.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
assets/icons/video_call/vc_control_chat.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
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 |
BIN
assets/icons/video_call/vc_control_leave.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
assets/icons/video_call/vc_control_mic.png
Normal file
|
After Width: | Height: | Size: 694 B |
BIN
assets/icons/video_call/vc_control_mic_off.png
Normal file
|
After Width: | Height: | Size: 855 B |
BIN
assets/icons/video_call/vc_control_sound_active.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
assets/icons/video_call/vc_control_video.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
assets/icons/video_call/vc_expand.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
3
assets/icons/video_call/vc_expand.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.83398 9.03418C6.1464 8.72177 6.6534 8.72178 6.96582 9.03418C7.27824 9.3466 7.27824 9.8536 6.96582 10.166L2.73047 14.4004H5.59961C6.04144 14.4004 6.40039 14.7584 6.40039 15.2002C6.40032 15.642 6.04139 16 5.59961 16H0.799805C0.750353 16 0.702243 15.994 0.655273 15.9854C0.650431 15.9845 0.645453 15.9844 0.640625 15.9834C0.632394 15.9817 0.624348 15.9795 0.616211 15.9775C0.599845 15.9737 0.583526 15.9697 0.567383 15.9648C0.525709 15.9522 0.485753 15.936 0.447266 15.917C0.370564 15.8792 0.298188 15.8294 0.234375 15.7656C0.170233 15.7015 0.119834 15.6289 0.0820312 15.5518C0.0522722 15.4911 0.0292924 15.4265 0.015625 15.3584C0.0146554 15.3536 0.0145519 15.3486 0.0136719 15.3438C0.0052094 15.2971 7.59671e-06 15.2493 0 15.2002V10.4004C0 9.95863 0.358067 9.59972 0.799805 9.59961C1.24163 9.59961 1.59961 9.95856 1.59961 10.4004V13.2686L5.83398 9.03418ZM15.2002 0C15.2493 1.2747e-05 15.2971 0.0052045 15.3438 0.0136719C15.3486 0.0145519 15.3536 0.0146554 15.3584 0.015625C15.3666 0.017277 15.3747 0.0195822 15.3828 0.0214844C15.3992 0.0253083 15.4155 0.0293148 15.4316 0.0341797C15.4733 0.0467652 15.5133 0.0631386 15.5518 0.0820312C15.6289 0.119834 15.7015 0.170233 15.7656 0.234375C15.8294 0.298188 15.8792 0.370564 15.917 0.447266C15.9252 0.463964 15.9334 0.480716 15.9404 0.498047C15.9612 0.549032 15.9755 0.601773 15.9854 0.655273C15.994 0.702239 16 0.750358 16 0.799805V5.59961C16 6.04137 15.6419 6.40028 15.2002 6.40039C14.7584 6.40039 14.4004 6.04144 14.4004 5.59961V2.73047L10.166 6.96582C9.8536 7.27824 9.3466 7.27824 9.03418 6.96582C8.72178 6.6534 8.72177 6.1464 9.03418 5.83398L13.2695 1.59961H10.4004C9.95856 1.59961 9.59961 1.24163 9.59961 0.799805C9.59972 0.358067 9.95863 0 10.4004 0H15.2002Z" fill="#EFF0FA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.8 KiB |
9
assets/icons/video_call/vc_mic_off.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M16 6.3V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9.04004 14.19C9.77004 15 10.83 15.5 12 15.5C14.21 15.5 16 13.71 16 11.5V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M6.77979 16.9499C8.14979 18.2199 9.97978 18.9999 11.9998 18.9999C16.2198 18.9999 19.6498 15.5699 19.6498 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.3501 9.6499V11.3499C4.3501 12.4099 4.5601 13.4099 4.9501 14.3299" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M20.0702 2.83984L3.93018 18.9898" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M11 3V6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
5
assets/icons/video_call/vc_mic_on.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 15.5C14.21 15.5 16 13.71 16 11.5V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11.5C8 13.71 9.79 15.5 12 15.5Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4.35059 9.6499V11.3499C4.35059 15.5699 7.78059 18.9999 12.0006 18.9999C16.2206 18.9999 19.6506 15.5699 19.6506 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
3
assets/icons/video_call/vc_shrink.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.40023 8.79965C6.44894 8.79967 6.49651 8.80496 6.54281 8.81332C6.54796 8.81425 6.5533 8.81424 6.55844 8.81527C6.56666 8.81692 6.57472 8.81923 6.58285 8.82113C6.5992 8.82496 6.61555 8.82896 6.63168 8.83383C6.64056 8.83651 6.64929 8.83963 6.65805 8.84262C6.69881 8.85648 6.73865 8.87374 6.77719 8.89437C6.80436 8.90894 6.83012 8.9257 6.85531 8.9432C6.89387 8.97001 6.93129 8.99965 6.96566 9.03402C6.9997 9.06805 7.02889 9.10526 7.05551 9.1434C7.05897 9.14836 7.06289 9.153 7.06625 9.15804C7.12722 9.24979 7.16631 9.35128 7.18539 9.4559C7.19389 9.50255 7.20001 9.55035 7.20004 9.59945V14.4002C7.19984 14.8418 6.84178 15.1998 6.40023 15.2C5.95852 15.2 5.59964 14.8419 5.59945 14.4002V11.5311L1.36508 15.7655C1.05264 16.0777 0.546577 16.0778 0.234217 15.7655C-0.0781257 15.4531 -0.0780189 14.947 0.234217 14.6346L4.46762 10.4002H1.59945C1.15782 10.4 0.799647 10.0411 0.799647 9.59945C0.799876 9.15796 1.15796 8.79988 1.59945 8.79965H6.40023ZM14.6346 0.234217C14.947 -0.0780189 15.4531 -0.0781257 15.7655 0.234217C16.0778 0.546577 16.0777 1.05264 15.7655 1.36508L11.5321 5.59945H14.4002C14.8419 5.59964 15.2 5.95852 15.2 6.40023C15.1998 6.84178 14.8418 7.19984 14.4002 7.20004H9.59945C9.55035 7.20001 9.50255 7.19389 9.4559 7.18539C9.45074 7.18445 9.44541 7.18448 9.44027 7.18344C9.43204 7.18177 9.42399 7.17949 9.41586 7.17758C9.3995 7.17372 9.38317 7.16978 9.36703 7.16488C9.35943 7.16257 9.35207 7.1596 9.34457 7.15707C9.30221 7.14282 9.2605 7.12587 9.22054 7.10433C9.19365 7.08982 9.16833 7.0729 9.1434 7.05551C9.10526 7.02889 9.06805 6.9997 9.03402 6.96566C8.99965 6.93129 8.97001 6.89387 8.9432 6.85531C8.9257 6.83012 8.90894 6.80436 8.89437 6.77719C8.85465 6.70298 8.82796 6.62378 8.81332 6.54281C8.80496 6.49651 8.79967 6.44894 8.79965 6.40023V1.59945C8.79988 1.15796 9.15796 0.799876 9.59945 0.799647C10.0411 0.799647 10.4 1.15782 10.4002 1.59945V4.46762L14.6346 0.234217Z" fill="#EFF0FA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
3
assets/icons/video_call/warning.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M10 0C15.523 0 20 4.477 20 10C20 15.523 15.523 20 10 20C4.477 20 0 15.523 0 10C0 4.477 4.477 0 10 0ZM10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 12.1217 2.84285 14.1566 4.34315 15.6569C5.84344 17.1571 7.87827 18 10 18C12.1217 18 14.1566 17.1571 15.6569 15.6569C17.1571 14.1566 18 12.1217 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2ZM10 13C10.2652 13 10.5196 13.1054 10.7071 13.2929C10.8946 13.4804 11 13.7348 11 14C11 14.2652 10.8946 14.5196 10.7071 14.7071C10.5196 14.8946 10.2652 15 10 15C9.73478 15 9.48043 14.8946 9.29289 14.7071C9.10536 14.5196 9 14.2652 9 14C9 13.7348 9.10536 13.4804 9.29289 13.2929C9.48043 13.1054 9.73478 13 10 13ZM10 4C10.2652 4 10.5196 4.10536 10.7071 4.29289C10.8946 4.48043 11 4.73478 11 5V11C11 11.2652 10.8946 11.5196 10.7071 11.7071C10.5196 11.8946 10.2652 12 10 12C9.73478 12 9.48043 11.8946 9.29289 11.7071C9.10536 11.5196 9 11.2652 9 11V5C9 4.73478 9.10536 4.48043 9.29289 4.29289C9.48043 4.10536 9.73478 4 10 4Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -44,11 +44,11 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
## dart: PermissionGroup.camera
|
## dart: PermissionGroup.camera
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
|
## dart: PermissionGroup.microphone
|
||||||
|
'PERMISSION_MICROPHONE=1',
|
||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
## dart: PermissionGroup.mediaLibrary
|
|
||||||
'PERMISSION_MEDIA_LIBRARY=1',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
46B43B96F809CEC6058EFDD3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 15ADDB2A35F334AF0AE50011 /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */; };
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
@@ -37,6 +39,8 @@
|
|||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCallDeclineClient.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>"; };
|
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>"; };
|
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>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
@@ -115,6 +119,8 @@
|
|||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
|
E47D0C1A2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift */,
|
||||||
|
C4A8E2012E9A4B1000123456 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
FEE92A0E2D772EA300E6E989 /* Runner.entitlements */,
|
||||||
);
|
);
|
||||||
@@ -300,6 +306,8 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
|
E47D0C1B2F0B4D1000EA7A01 /* VideoCallDeclineClient.swift in Sources */,
|
||||||
|
C4A8E2022E9A4B1000123456 /* SceneDelegate.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -397,7 +405,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -548,7 +556,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -587,7 +595,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
@@ -31,18 +31,43 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>Нужен для звука в видеозвонках с консультантом</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string></string>
|
<string>Может использоваться для стабильного поддержания связи во время звонка.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
<string>Предоставление доступа позволит Вам добавлять фото к записям</string>
|
||||||
|
<key>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>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
<string>voip</string>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
</array>
|
</array>
|
||||||
@@ -72,6 +97,57 @@
|
|||||||
<string>yandexauth</string>
|
<string>yandexauth</string>
|
||||||
<string>yandexauth2</string>
|
<string>yandexauth2</string>
|
||||||
<string>yandexauth4</string>
|
<string>yandexauth4</string>
|
||||||
|
<!-- SBP bank schemes (top ~50 by popularity, iOS 15+ caps at 50) -->
|
||||||
|
<string>bank100000000111</string>
|
||||||
|
<string>bank100000000004</string>
|
||||||
|
<string>bank100000000005</string>
|
||||||
|
<string>bank100000000008</string>
|
||||||
|
<string>bank100000000007</string>
|
||||||
|
<string>bank100000000015</string>
|
||||||
|
<string>bank100000000001</string>
|
||||||
|
<string>bank100000000010</string>
|
||||||
|
<string>bank100000000013</string>
|
||||||
|
<string>bank100000000012</string>
|
||||||
|
<string>bank100000000020</string>
|
||||||
|
<string>bank100000000025</string>
|
||||||
|
<string>bank100000000030</string>
|
||||||
|
<string>bank100000000030_new</string>
|
||||||
|
<string>bank100000000003</string>
|
||||||
|
<string>bank100000000043</string>
|
||||||
|
<string>bank100000000028</string>
|
||||||
|
<string>bank100000000086</string>
|
||||||
|
<string>bank100000000011</string>
|
||||||
|
<string>bank100000000044</string>
|
||||||
|
<string>bank100000000049</string>
|
||||||
|
<string>bank100000000500</string>
|
||||||
|
<string>bank100000000095</string>
|
||||||
|
<string>bank100000000900</string>
|
||||||
|
<string>bank100000000056</string>
|
||||||
|
<string>bank100000000053</string>
|
||||||
|
<string>bank100000000121</string>
|
||||||
|
<string>bank100000000082</string>
|
||||||
|
<string>bank100000000127</string>
|
||||||
|
<string>bank100000000017</string>
|
||||||
|
<string>bank100000000087</string>
|
||||||
|
<string>bank100000000052</string>
|
||||||
|
<string>bank100000000006</string>
|
||||||
|
<string>bank100000000098</string>
|
||||||
|
<string>bank100000000092</string>
|
||||||
|
<string>bank100000000229</string>
|
||||||
|
<string>bank100000000027</string>
|
||||||
|
<string>bank100000000080</string>
|
||||||
|
<string>bank100000000122</string>
|
||||||
|
<string>bank100000000124</string>
|
||||||
|
<string>bank100000000118</string>
|
||||||
|
<string>bank100000000159</string>
|
||||||
|
<string>bank100000000146</string>
|
||||||
|
<string>bank100000000069</string>
|
||||||
|
<string>bank100000000140</string>
|
||||||
|
<string>bank100000000047</string>
|
||||||
|
<string>bank100000000099</string>
|
||||||
|
<string>bank100000000135</string>
|
||||||
|
<string>bank100000000139</string>
|
||||||
|
<string>bank100000000022</string>
|
||||||
</array>
|
</array>
|
||||||
<key>YAClientId</key>
|
<key>YAClientId</key>
|
||||||
<string>5399b03636104c6d920b87c1ad329254</string>
|
<string>5399b03636104c6d920b87c1ad329254</string>
|
||||||
@@ -85,6 +161,14 @@
|
|||||||
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
<string>yx5399b03636104c6d920b87c1ad329254</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>Eatvio</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>eatvio</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>applinks:eatvio.ru</string>
|
<string>applinks:eatvio.ru</string>
|
||||||
<string>applinks:www.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>
|
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
90
ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
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()
|
||||||
|
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
appDelegate.deliverStoredAcceptedCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
163
ios/Runner/VideoCallDeclineClient.swift
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
enum VideoCallDeclineClient {
|
||||||
|
private static let uuidPattern =
|
||||||
|
#"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"#
|
||||||
|
|
||||||
|
/// `POST /video-call/decline` from the CallKit callback. The process is
|
||||||
|
/// awake here even when the user never opens the app afterwards.
|
||||||
|
static func send(extra: [String: Any], handle: String) {
|
||||||
|
post(action: "decline", extra: extra, handle: handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
|
||||||
|
/// still be frozen on the splash snapshot, so the other device must be
|
||||||
|
/// told from here. A later Dart retry with the same hw_id is idempotent.
|
||||||
|
static func sendAccept(extra: [String: Any], handle: String) {
|
||||||
|
post(action: "accept", extra: extra, handle: handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func post(action: String, extra: [String: Any], handle: String) {
|
||||||
|
let fields = declineFields(extra: extra, handle: handle)
|
||||||
|
guard let request = makeRequest(
|
||||||
|
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
|
||||||
|
token: prefsString("token"),
|
||||||
|
hwId: UIDevice.current.identifierForVendor?.uuidString,
|
||||||
|
fields: fields,
|
||||||
|
action: action
|
||||||
|
) else {
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = EatvioBackgroundTask()
|
||||||
|
task.begin(name: "video-call-\(action)")
|
||||||
|
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
NSLog(
|
||||||
|
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
|
||||||
|
)
|
||||||
|
task.end()
|
||||||
|
}
|
||||||
|
urlTask.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeRequest(
|
||||||
|
baseUrl: String?,
|
||||||
|
token: String?,
|
||||||
|
hwId: String?,
|
||||||
|
fields: VideoCallDeclineFields,
|
||||||
|
action: String = "decline"
|
||||||
|
) -> URLRequest? {
|
||||||
|
if fields.endedPush { return nil }
|
||||||
|
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
while root.hasSuffix("/") {
|
||||||
|
root.removeLast()
|
||||||
|
}
|
||||||
|
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
|
||||||
|
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
|
||||||
|
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
|
||||||
|
guard action == "accept" || action == "decline" else { return nil }
|
||||||
|
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
var body: [String: String] = ["hw_id": deviceId]
|
||||||
|
if let callId = fields.callId {
|
||||||
|
body["call_id"] = callId
|
||||||
|
}
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields {
|
||||||
|
let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"])
|
||||||
|
?? handle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let rawCallId = firstString(extra, keys: ["call_id", "callId"])
|
||||||
|
return VideoCallDeclineFields(
|
||||||
|
conversationUuid: conversation,
|
||||||
|
callId: backendCallId(rawCallId, conversation: conversation),
|
||||||
|
endedPush: marksEndedPush(extra["endedPush"])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func backendCallId(_ callId: String?, conversation: String) -> String? {
|
||||||
|
let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: uuidPattern),
|
||||||
|
regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil
|
||||||
|
else { return nil }
|
||||||
|
if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstString(_ extra: [String: Any], keys: [String]) -> String? {
|
||||||
|
for key in keys {
|
||||||
|
guard let value = extra[key] as? String else { continue }
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func marksEndedPush(_ value: Any?) -> Bool {
|
||||||
|
if let bool = value as? Bool { return bool }
|
||||||
|
if let number = value as? NSNumber { return number.intValue != 0 }
|
||||||
|
if let text = value as? String {
|
||||||
|
let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prefsString(_ key: String) -> String? {
|
||||||
|
let value = UserDefaults.standard.string(forKey: "flutter.\(key)")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let value, !value.isEmpty { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VideoCallDeclineFields {
|
||||||
|
var conversationUuid: String
|
||||||
|
var callId: String?
|
||||||
|
var endedPush: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class EatvioBackgroundTask {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var id: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
|
||||||
|
func begin(name: String) {
|
||||||
|
let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
||||||
|
self?.end()
|
||||||
|
}
|
||||||
|
lock.lock()
|
||||||
|
id = taskId
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
lock.lock()
|
||||||
|
let current = id
|
||||||
|
id = .invalid
|
||||||
|
lock.unlock()
|
||||||
|
guard current != .invalid else { return }
|
||||||
|
UIApplication.shared.endBackgroundTask(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,15 +76,23 @@ platform :ios do
|
|||||||
build_number: flutter_build_number
|
build_number: flutter_build_number
|
||||||
)
|
)
|
||||||
|
|
||||||
|
update_code_signing_settings(
|
||||||
|
use_automatic_signing: false,
|
||||||
|
path: "Runner.xcodeproj",
|
||||||
|
team_id: "VL68C36HYS",
|
||||||
|
targets: ["Runner"],
|
||||||
|
code_sign_identity: "Apple Distribution",
|
||||||
|
profile_name: "match AppStore com.eatvioapp.app 1781098260"
|
||||||
|
)
|
||||||
|
|
||||||
build_app(
|
build_app(
|
||||||
workspace: "Runner.xcworkspace",
|
workspace: "Runner.xcworkspace",
|
||||||
scheme: "Runner",
|
scheme: "Runner",
|
||||||
export_method: "app-store",
|
export_method: "app-store",
|
||||||
codesigning_identity: "Apple Distribution",
|
# codesigning_identity: "Apple Distribution",
|
||||||
|
export_team_id: "VL68C36HYS"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app_store_connect_api_key(
|
app_store_connect_api_key(
|
||||||
key_id: "325JS9JCZH",
|
key_id: "325JS9JCZH",
|
||||||
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
|
||||||
@@ -93,6 +101,7 @@ platform :ios do
|
|||||||
in_house: false
|
in_house: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
begin
|
begin
|
||||||
upload_to_testflight(
|
upload_to_testflight(
|
||||||
skip_waiting_for_build_processing: true,
|
skip_waiting_for_build_processing: true,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
import 'package:eatvio_app/core/cubit/marketing/marketing_cubit.dart';
|
||||||
import 'package:eatvio_app/core/env_config.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/internet_connection_cubit/internet_connection_cubit.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.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/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/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/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -30,19 +35,51 @@ class AppConfig {
|
|||||||
|
|
||||||
void setBaseUrl(String baseUrls) {
|
void setBaseUrl(String baseUrls) {
|
||||||
_baseUrl = 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));
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Список глобальных BlocProvider'ов
|
/// Список глобальных BlocProvider'ов
|
||||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||||
List<BlocProvider> get globalBlocProviders => [
|
List<BlocProvider> get globalBlocProviders => [
|
||||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
BlocProvider<VideoCallSessionCubit>.value(
|
||||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
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<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<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
BlocProvider<InternetConnectionCubit>(
|
||||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
lazy: false,
|
||||||
|
create: (context) => sl<InternetConnectionCubit>(),
|
||||||
|
),
|
||||||
|
BlocProvider<MarketingCubit>(
|
||||||
|
lazy: true,
|
||||||
|
create: (context) => sl<MarketingCubit>(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Делегаты локализации
|
/// Делегаты локализации
|
||||||
@@ -54,5 +91,7 @@ class AppConfig {
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Поддерживаемые локали
|
/// Поддерживаемые локали
|
||||||
List<Locale> get supportedLocales => [const Locale.fromSubtags(languageCode: 'ru')];
|
List<Locale> get supportedLocales => [
|
||||||
|
const Locale.fromSubtags(languageCode: 'ru'),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io' as io;
|
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/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' 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:eatvio_app/firebase_options.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
@@ -39,24 +37,21 @@ class AppInitializer {
|
|||||||
|
|
||||||
/// Инициализация Firebase
|
/// Инициализация Firebase
|
||||||
static Future<void> _initializeFirebase() async {
|
static Future<void> _initializeFirebase() async {
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
await Firebase.initializeApp(
|
||||||
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
onTimeout: () {
|
onTimeout: () {
|
||||||
throw TimeoutException('Firebase initialization timeout');
|
throw TimeoutException('Firebase initialization timeout');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
VideoCallFcmHandler.registerBackgroundHandler();
|
||||||
try {
|
|
||||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем работу
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Инициализация системы внедрения зависимостей
|
/// Инициализация системы внедрения зависимостей
|
||||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
static Future<void> _initializeDependencyInjection({
|
||||||
|
required AppBuildType buildType,
|
||||||
|
}) async {
|
||||||
await di.init(buildType: buildType);
|
await di.init(buildType: buildType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +74,12 @@ class AppInitializer {
|
|||||||
options.dsn = EnvConfig.sentryDsn;
|
options.dsn = EnvConfig.sentryDsn;
|
||||||
options.tracesSampleRate = 1.0;
|
options.tracesSampleRate = 1.0;
|
||||||
options.debug = false;
|
options.debug = false;
|
||||||
|
options.beforeSend = (event, hint) {
|
||||||
|
if (kDebugMode) return null;
|
||||||
|
if (!ErrorReportPolicy.allowSentryEvent(event, hint)) return null;
|
||||||
|
return event;
|
||||||
|
};
|
||||||
}, appRunner: appRunner);
|
}, appRunner: appRunner);
|
||||||
FlutterError.onError = (details) async {
|
|
||||||
if (kDebugMode) return;
|
|
||||||
await Sentry.captureException(details.exception, stackTrace: details.stack);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/routing/auth_routes.dart';
|
import 'package:eatvio_app/core/routing/auth_routes.dart';
|
||||||
import 'package:eatvio_app/core/routing/content_routes.dart';
|
import 'package:eatvio_app/core/routing/content_routes.dart';
|
||||||
import 'package:eatvio_app/core/routing/main_navigation_routes.dart';
|
import 'package:eatvio_app/core/routing/main_navigation_routes.dart';
|
||||||
@@ -6,6 +7,7 @@ import 'package:eatvio_app/core/routing/other_routes.dart';
|
|||||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/core/routing/settings_routes.dart';
|
import 'package:eatvio_app/core/routing/settings_routes.dart';
|
||||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||||
|
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -14,8 +16,8 @@ import 'package:go_router/go_router.dart';
|
|||||||
class AppRouter {
|
class AppRouter {
|
||||||
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
// Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com)
|
static const List<String> deepLinkHosts = ['eatvio.ru', 'eatvio.com', 'dev2.eatvio.com'];
|
||||||
static const List<String> _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru', 'dev2.eatvio.com', 'www.dev2.eatvio.com'];
|
static const List<String> metaSegments = ['ru', 'en', 'api', 'v2'];
|
||||||
|
|
||||||
static GoRouter appRouter = GoRouter(
|
static GoRouter appRouter = GoRouter(
|
||||||
initialLocation: RouteConstants.updateScreen,
|
initialLocation: RouteConstants.updateScreen,
|
||||||
@@ -30,11 +32,116 @@ class AppRouter {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
static String? _resolveDeepLinkUri(Uri uri) {
|
static String? _resolveRecipeDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 2) return null;
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
developer.log('Deep link: recipe/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '/about-recipes-screen?slug=$contentIdentifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveBlogPostDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 2) return null;
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
developer.log('Deep link: blog_post/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '/blog-post-screen?id=$contentIdentifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolvePlanDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 2) return null;
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
final planId = int.tryParse(contentIdentifier);
|
||||||
|
if (planId != null) {
|
||||||
|
developer.log('Deep link: diary', name: 'AppRouter');
|
||||||
|
return RouteConstants.diaryScreen;
|
||||||
|
}
|
||||||
|
developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '/about-plan-screen?slug=$contentIdentifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveDietDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length == 2) {
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
final dietId = int.tryParse(contentIdentifier);
|
||||||
|
if (dietId != null) {
|
||||||
|
developer.log('Deep link: diet/id=$dietId', name: 'AppRouter');
|
||||||
|
return '/about-diet-screen?id=$dietId';
|
||||||
|
}
|
||||||
|
developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '/about-diet-screen?slug=$contentIdentifier';
|
||||||
|
}
|
||||||
|
const dietTabIndexWithHiddenPlansTab = 3;
|
||||||
|
const dietTabIndexWithoutHiddenPlansTab = 4;
|
||||||
|
|
||||||
|
final initialTabIndex = hidePlans ? dietTabIndexWithHiddenPlansTab : dietTabIndexWithoutHiddenPlansTab;
|
||||||
|
return '${RouteConstants.feedScreen}?initialTabIndex=$initialTabIndex';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveChatDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length == 2) {
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '${RouteConstants.chatScreen}?uuid=$contentIdentifier';
|
||||||
|
} else if (effectiveSegments.length == 3 && effectiveSegments[1] == 'show') {
|
||||||
|
final contentIdentifier = effectiveSegments[2];
|
||||||
|
developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '${RouteConstants.chatScreen}?uuid=$contentIdentifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log('Deep link: chat', name: 'AppRouter');
|
||||||
|
return RouteConstants.chatSelectScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveProfileDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length == 2) {
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter');
|
||||||
|
return '/profile-screen?slug=$contentIdentifier';
|
||||||
|
}
|
||||||
|
return RouteConstants.myProfileScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveBadgeDeepLink(List<String> effectiveSegments) {
|
||||||
|
return RouteConstants.myProfileScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveDiaryDeepLink(List<String> effectiveSegments) {
|
||||||
|
developer.log('Deep link: diary', name: 'AppRouter');
|
||||||
|
return RouteConstants.diaryScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveConsultantDiaryCommentDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 2) return null;
|
||||||
|
final contentIdentifier = effectiveSegments[1];
|
||||||
|
developer.log('Deep link: consultant_diary_comment/$contentIdentifier → diary', name: 'AppRouter');
|
||||||
|
return RouteConstants.diaryScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _resolveConsultationsDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 3 || effectiveSegments[1] != 'status') return RouteConstants.consultationsScreen;
|
||||||
|
final consultationId = effectiveSegments[2];
|
||||||
|
developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter');
|
||||||
|
return '${InfoByOrderScreen.path}?planId=$consultationId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep link после оплаты Alfa: `finance/alfa/return/{orderId}` → чат с query `orderId`.
|
||||||
|
static String? _resolveFinanceAlfaReturnDeepLink(List<String> effectiveSegments) {
|
||||||
|
if (effectiveSegments.length != 4 ||
|
||||||
|
effectiveSegments[1] != 'alfa' ||
|
||||||
|
effectiveSegments[2] != 'return') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final orderId = effectiveSegments[3];
|
||||||
|
if (orderId.isEmpty) return null;
|
||||||
|
developer.log('Deep link: finance/alfa/return/$orderId', name: 'AppRouter');
|
||||||
|
return '${RouteConstants.chatScreen}?orderId=$orderId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбирает deep link URI в внутренний route target (или null, если host не наш).
|
||||||
|
static String? resolveDeepLinkTarget(Uri uri) {
|
||||||
final host = uri.host.toLowerCase();
|
final host = uri.host.toLowerCase();
|
||||||
final segments = uri.pathSegments;
|
final segments = uri.pathSegments;
|
||||||
|
|
||||||
final isDeepLink = _deepLinkHosts.contains(host);
|
final isDeepLink = deepLinkHosts.any((allowedHost) => host == allowedHost || host.endsWith('.$allowedHost'));
|
||||||
|
|
||||||
if (!isDeepLink) {
|
if (!isDeepLink) {
|
||||||
return null;
|
return null;
|
||||||
@@ -44,125 +151,70 @@ class AppRouter {
|
|||||||
|
|
||||||
if (segments.isEmpty) {
|
if (segments.isEmpty) {
|
||||||
developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter');
|
developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter');
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.feedScreen;
|
return RouteConstants.feedScreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segments.length >= 4 &&
|
final effectiveSegments = segments.where((segment) => !metaSegments.contains(segment)).toList();
|
||||||
segments[0] == 'api' &&
|
if (effectiveSegments.isEmpty) {
|
||||||
segments[1] == 'v2' &&
|
developer.log('No effective segments after language check, redirecting to feed', name: 'AppRouter');
|
||||||
segments[2] == 'chat' &&
|
|
||||||
segments[3] == 'show') {
|
|
||||||
developer.log('Deep link: api/v2/chat/show → chat-select-screen', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.chatSelectScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments[0] != 'ru' && segments[0] != 'en') {
|
|
||||||
developer.log('Invalid language segment: ${segments[0]}, redirecting to feed', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.feedScreen;
|
return RouteConstants.feedScreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (segments.length == 4) {
|
final type = effectiveSegments[0];
|
||||||
final type = segments[1];
|
|
||||||
|
|
||||||
if (type == 'diary') {
|
switch (type) {
|
||||||
developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter');
|
case 'recipe':
|
||||||
FlutterNativeSplash.remove();
|
final target = _resolveRecipeDeepLink(effectiveSegments);
|
||||||
return RouteConstants.diaryScreen;
|
if (target != null) return target;
|
||||||
}
|
break;
|
||||||
if (type == 'consultations' && segments[2] == 'status') {
|
case 'blog_post':
|
||||||
final consultationId = segments[3];
|
final target = _resolveBlogPostDeepLink(effectiveSegments);
|
||||||
developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter');
|
if (target != null) return target;
|
||||||
FlutterNativeSplash.remove();
|
break;
|
||||||
return '${RouteConstants.consultationsScreen}?id=$consultationId';
|
case 'plan':
|
||||||
}
|
final target = _resolvePlanDeepLink(effectiveSegments);
|
||||||
|
if (target != null) return target;
|
||||||
developer.log('Unknown deep link type for 4 segments: $type, redirecting to feed', name: 'AppRouter');
|
break;
|
||||||
FlutterNativeSplash.remove();
|
case 'diet':
|
||||||
return RouteConstants.feedScreen;
|
final target = _resolveDietDeepLink(effectiveSegments);
|
||||||
} else if (segments.length == 3) {
|
if (target != null) return target;
|
||||||
final type = segments[1];
|
break;
|
||||||
final contentIdentifier = segments[2];
|
case 'chat':
|
||||||
|
final target = _resolveChatDeepLink(effectiveSegments);
|
||||||
switch (type) {
|
if (target != null) return target;
|
||||||
case 'recipe':
|
break;
|
||||||
developer.log('Deep link: recipe/$contentIdentifier', name: 'AppRouter');
|
case 'profile':
|
||||||
FlutterNativeSplash.remove();
|
final target = _resolveProfileDeepLink(effectiveSegments);
|
||||||
return '/about-recipes-screen?slug=$contentIdentifier';
|
if (target != null) return target;
|
||||||
case 'blog_post':
|
break;
|
||||||
developer.log('Deep link: blog_post/$contentIdentifier', name: 'AppRouter');
|
case 'badge':
|
||||||
FlutterNativeSplash.remove();
|
final target = _resolveBadgeDeepLink(effectiveSegments);
|
||||||
return '/blog-post-screen?id=$contentIdentifier';
|
if (target != null) return target;
|
||||||
case 'plan':
|
break;
|
||||||
developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter');
|
case 'diary':
|
||||||
FlutterNativeSplash.remove();
|
final target = _resolveDiaryDeepLink(effectiveSegments);
|
||||||
return '/about-plan-screen?slug=$contentIdentifier';
|
if (target != null) return target;
|
||||||
case 'diet': {
|
break;
|
||||||
final dietId = int.tryParse(contentIdentifier);
|
case 'consultant_diary_comment':
|
||||||
if (dietId != null) {
|
final target = _resolveConsultantDiaryCommentDeepLink(effectiveSegments);
|
||||||
developer.log('Deep link: diet/id=$dietId', name: 'AppRouter');
|
if (target != null) return target;
|
||||||
FlutterNativeSplash.remove();
|
break;
|
||||||
return '/about-diet-screen?id=$dietId';
|
case 'consultations':
|
||||||
}
|
final target = _resolveConsultationsDeepLink(effectiveSegments);
|
||||||
developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter');
|
if (target != null) return target;
|
||||||
FlutterNativeSplash.remove();
|
break;
|
||||||
return '/about-diet-screen?slug=$contentIdentifier';
|
case 'finance':
|
||||||
}
|
final target = _resolveFinanceAlfaReturnDeepLink(effectiveSegments);
|
||||||
case 'chat':
|
if (target != null) return target;
|
||||||
developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter');
|
break;
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.chatSelectScreen;
|
|
||||||
case 'profile':
|
|
||||||
// В диплинке передан slug — открываем профиль пользователя по slug.
|
|
||||||
developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return '/profile-screen?slug=$contentIdentifier';
|
|
||||||
case 'badge':
|
|
||||||
// В badge нет slug, открываем вкладку «Мой профиль».
|
|
||||||
developer.log('Deep link: badge → my-profile tab', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.myProfileScreen;
|
|
||||||
case 'diary':
|
|
||||||
developer.log('Deep link: diary/$contentIdentifier', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.diaryScreen;
|
|
||||||
case 'consultant_diary_comment':
|
|
||||||
developer.log('Deep link: consultant_diary_comment/$contentIdentifier → diary', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.diaryScreen;
|
|
||||||
default:
|
|
||||||
developer.log('Unknown deep link type: $type, redirecting to feed', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.feedScreen;
|
|
||||||
}
|
|
||||||
} else if (segments.length == 2) {
|
|
||||||
final type = segments[1];
|
|
||||||
|
|
||||||
if (type == 'chat') {
|
|
||||||
developer.log('Deep link: chat', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.chatSelectScreen;
|
|
||||||
}
|
|
||||||
if (type == 'diary') {
|
|
||||||
developer.log('Deep link: diary', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.diaryScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
developer.log('Unknown deep link type for 2 segments: $type, redirecting to feed', name: 'AppRouter');
|
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.feedScreen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log('Unknown deep link format: ${segments.length} segments, redirecting to feed', name: 'AppRouter');
|
developer.log('Unknown deep link format: ${effectiveSegments.length} effective segments, type=$type, redirecting to feed', name: 'AppRouter');
|
||||||
FlutterNativeSplash.remove();
|
|
||||||
return RouteConstants.feedScreen;
|
return RouteConstants.feedScreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||||
return _resolveDeepLinkUri(state.uri);
|
return resolveDeepLinkTarget(state.uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateByDeepLinkUrl(String url) {
|
static void navigateByDeepLinkUrl(String url) {
|
||||||
@@ -190,20 +242,20 @@ class AppRouter {
|
|||||||
if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) {
|
if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) {
|
||||||
uri = Uri(
|
uri = Uri(
|
||||||
scheme: 'https',
|
scheme: 'https',
|
||||||
host: _deepLinkHosts.first,
|
host: deepLinkHosts.first,
|
||||||
pathSegments: uri.pathSegments,
|
pathSegments: uri.pathSegments,
|
||||||
queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters,
|
queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final target = _resolveDeepLinkUri(uri);
|
final target = resolveDeepLinkTarget(uri);
|
||||||
|
FlutterNativeSplash.remove();
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
appRouter.go(target);
|
appRouter.go(target);
|
||||||
} else {
|
return;
|
||||||
developer.log('navigateByDeepLinkUrl: unresolved url=$url (normalized=${uri.toString()}), redirecting to feed', name: 'AppRouter');
|
|
||||||
appRouter.go(RouteConstants.feedScreen);
|
|
||||||
}
|
}
|
||||||
|
developer.log('navigateByDeepLinkUrl: unresolved url=$url (normalized=${uri.toString()}), redirecting to feed', name: 'AppRouter');
|
||||||
|
appRouter.go(RouteConstants.feedScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) {
|
static void navigateToRegisterViaSocial({required AuthResultEntity authResult, required String provider}) {
|
||||||
|
|||||||
30
lib/core/cached_base_url.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
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-клиента.
|
||||||
|
///
|
||||||
|
/// Действующий адрес также пишется в [PrefsKeys.apiBaseUrl], чтобы нативный
|
||||||
|
/// decline мог собрать `POST /video-call/decline`, когда Flutter не запущен.
|
||||||
|
void applyCachedBaseUrl() {
|
||||||
|
if (!sl.isRegistered<Prefs>() || !sl.isRegistered<AppConfig>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cached = sl<Prefs>().getString(PrefsKeys.customBaseUrl)?.trim();
|
||||||
|
if (cached != null && cached.isNotEmpty) {
|
||||||
|
sl<AppConfig>().setBaseUrl(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_persistApiBaseUrl(sl<AppConfig>().baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _persistApiBaseUrl(String baseUrl) {
|
||||||
|
final normalized = baseUrl.trim();
|
||||||
|
if (normalized.isEmpty || !sl.isRegistered<Prefs>()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unawaited(sl<Prefs>().setString(PrefsKeys.apiBaseUrl, normalized));
|
||||||
|
}
|
||||||
@@ -5,15 +5,13 @@ import 'package:eatvio_app/core/comments/repositories/comments_repository.dart';
|
|||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
class CommentsCubit extends Cubit<CommentsState> {
|
class CommentsCubit extends Cubit<CommentsState> {
|
||||||
final FeedRepository feedRepository;
|
final FeedRepository feedRepository;
|
||||||
final CommentsRepository commentsRepository;
|
final CommentsRepository commentsRepository;
|
||||||
|
|
||||||
CommentsCubit({
|
CommentsCubit({required this.feedRepository, required this.commentsRepository}) : super(const CommentsState());
|
||||||
required this.feedRepository,
|
|
||||||
required this.commentsRepository,
|
|
||||||
}) : super(const CommentsState());
|
|
||||||
|
|
||||||
void setReplyId(int? replyId) {
|
void setReplyId(int? replyId) {
|
||||||
emit((state as CommentsStateLoaded).copyWith(replyId: replyId));
|
emit((state as CommentsStateLoaded).copyWith(replyId: replyId));
|
||||||
@@ -36,51 +34,34 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
emit(CommentsState.loaded(comment: await feedRepository.getPlansComments(slug: id)));
|
emit(CommentsState.loaded(comment: await feedRepository.getPlansComments(slug: id)));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
emit(CommentsState.error(errorMessage: e.toString()));
|
emit(CommentsState.error(errorMessage: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendComment({
|
Future<bool> sendComment({required String id, required String commentText, required CommentSourceType commentSourceType, int? parentCommentId}) async {
|
||||||
required String id,
|
|
||||||
required String commentText,
|
|
||||||
required CommentSourceType commentSourceType,
|
|
||||||
int? parentCommentId,
|
|
||||||
}) async {
|
|
||||||
try {
|
try {
|
||||||
switch (commentSourceType) {
|
switch (commentSourceType) {
|
||||||
case CommentSourceType.blogPost:
|
case CommentSourceType.blogPost:
|
||||||
await feedRepository.sendBlogPostComment(
|
await feedRepository.sendBlogPostComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
|
||||||
id: id,
|
|
||||||
commentText: commentText,
|
|
||||||
parentCommentId: parentCommentId,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case CommentSourceType.diet:
|
case CommentSourceType.diet:
|
||||||
await feedRepository.sendDietsComment(
|
await feedRepository.sendDietsComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
|
||||||
id: id,
|
|
||||||
commentText: commentText,
|
|
||||||
parentCommentId: parentCommentId,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case CommentSourceType.recipe:
|
case CommentSourceType.recipe:
|
||||||
await feedRepository.sendRecipesComment(
|
await feedRepository.sendRecipesComment(id: id, commentText: commentText, parentCommentId: parentCommentId);
|
||||||
id: id,
|
|
||||||
commentText: commentText,
|
|
||||||
parentCommentId: parentCommentId,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case CommentSourceType.plan:
|
case CommentSourceType.plan:
|
||||||
await feedRepository.sendPlansComment(
|
await feedRepository.sendPlansComment(slug: id, commentText: commentText, parentCommentId: parentCommentId);
|
||||||
slug: id,
|
|
||||||
commentText: commentText,
|
|
||||||
parentCommentId: parentCommentId,
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
getComments(id, commentSourceType);
|
getComments(id, commentSourceType);
|
||||||
} catch (e) {
|
return true;
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
emit(CommentsState.error(errorMessage: e.toString()));
|
emit(CommentsState.error(errorMessage: e.toString()));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +74,7 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
emit(CommentsState.loaded(comment: updatedCommentModel));
|
emit(CommentsState.loaded(comment: updatedCommentModel));
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
emit(CommentsState.loaded(comment: commentModel));
|
emit(CommentsState.loaded(comment: commentModel));
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
@@ -101,13 +83,15 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
|
|
||||||
Future<bool> updateComment({required int id, required String body}) async {
|
Future<bool> updateComment({required int id, required String body}) async {
|
||||||
final commentModel = (state as CommentsStateLoaded).comment;
|
final commentModel = (state as CommentsStateLoaded).comment;
|
||||||
final updatedCommentModel =
|
final updatedCommentModel = commentModel.copyWith(
|
||||||
commentModel.copyWith(data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList());
|
data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList(),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await commentsRepository.updateComment(id: id, body: body);
|
await commentsRepository.updateComment(id: id, body: body);
|
||||||
emit(CommentsState.loaded(comment: updatedCommentModel));
|
emit(CommentsState.loaded(comment: updatedCommentModel));
|
||||||
return true;
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
emit(CommentsState.loaded(comment: commentModel));
|
emit(CommentsState.loaded(comment: commentModel));
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/comments/models/comment_update_model.dart';
|
import 'package:eatvio_app/core/comments/models/comment_update_model.dart';
|
||||||
import 'package:eatvio_app/core/comments/services/comments_service.dart';
|
import 'package:eatvio_app/core/comments/services/comments_service.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
||||||
|
|
||||||
class CommentsRepository {
|
class CommentsRepository {
|
||||||
final CommentsService service;
|
final CommentsService service;
|
||||||
@@ -8,20 +7,12 @@ class CommentsRepository {
|
|||||||
CommentsRepository({required this.service});
|
CommentsRepository({required this.service});
|
||||||
|
|
||||||
Future<void> deleteComment({required int id}) async {
|
Future<void> deleteComment({required int id}) async {
|
||||||
try {
|
return service.deleteComment(id);
|
||||||
return service.deleteComment(id);
|
|
||||||
} catch (e, s) {
|
|
||||||
await Sentry.captureException(e, stackTrace: s);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateComment({required int id, required String body}) async {
|
Future<void> updateComment({required int id, required String body}) async {
|
||||||
try {
|
return service.updateComment(id, CommentUpdateModel(body: body));
|
||||||
return service.updateComment(id, CommentUpdateModel(body: body));
|
|
||||||
} catch (e, s) {
|
|
||||||
await Sentry.captureException(e, stackTrace: s);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
|||||||
const newMessagesChannelName = 'private-new-chat-messages';
|
const newMessagesChannelName = 'private-new-chat-messages';
|
||||||
const newNotificationsChannelName = 'private-message-events';
|
const newNotificationsChannelName = 'private-message-events';
|
||||||
const directChatChannelName = 'private-direct-chat';
|
const directChatChannelName = 'private-direct-chat';
|
||||||
|
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||||
const thresholdForPagination = 150;
|
const thresholdForPagination = 150;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/cubit/marketing/marketing_cubit_state.dart';
|
|||||||
import 'package:eatvio_app/core/prefs.dart';
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
|
|
||||||
class MarketingCubit extends Cubit<MarketingCubitState> {
|
class MarketingCubit extends Cubit<MarketingCubitState> {
|
||||||
final Prefs prefs;
|
final Prefs prefs;
|
||||||
@@ -22,8 +23,9 @@ class MarketingCubit extends Cubit<MarketingCubitState> {
|
|||||||
await prefs.setBool(PrefsKeys.marketingBottomSheetHasBeenShown, true);
|
await prefs.setBool(PrefsKeys.marketingBottomSheetHasBeenShown, true);
|
||||||
}
|
}
|
||||||
return needToShowMarketingBottomSheet;
|
return needToShowMarketingBottomSheet;
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
rethrow;
|
await reportFrom(e, st, this);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
lib/core/device/device_hw_id_provider.dart
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Stable id of this install, sent as `hw_id` on push registration and on
|
||||||
|
/// video-call accept/decline.
|
||||||
|
///
|
||||||
|
/// Android `Build.ID` is the firmware id. Two phones on the same build share
|
||||||
|
/// it, so the backend treats them as one device and a decline on one does not
|
||||||
|
/// reach the other. A per-install id stays unique.
|
||||||
|
abstract final class DeviceHwIdProvider {
|
||||||
|
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
|
||||||
|
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
|
||||||
|
|
||||||
|
static Future<String?> current({SharedPreferences? preferences}) async {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
|
||||||
|
}
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
return androidInstallId(preferences: preferences);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String> androidInstallId({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(androidInstallIdKey)?.trim();
|
||||||
|
if (existing != null && existing.length >= 8) return existing;
|
||||||
|
|
||||||
|
final created = _newInstallId();
|
||||||
|
await prefs.setString(androidInstallIdKey, created);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous Android registration used `Build.ID`. Drop that token once
|
||||||
|
/// and register again with the install id.
|
||||||
|
static Future<bool> androidTokenNeedsReregister({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
if (!Platform.isAndroid) return false;
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
return prefs.getBool(androidTokenMigratedKey) != true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> markAndroidTokenReregistered({
|
||||||
|
SharedPreferences? preferences,
|
||||||
|
}) async {
|
||||||
|
final prefs = preferences ?? await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(androidTokenMigratedKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _newInstallId() {
|
||||||
|
final random = Random.secure();
|
||||||
|
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '${hex.substring(0, 8)}-'
|
||||||
|
'${hex.substring(8, 12)}-'
|
||||||
|
'${hex.substring(12, 16)}-'
|
||||||
|
'${hex.substring(16, 20)}-'
|
||||||
|
'${hex.substring(20)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,9 +39,11 @@ import 'package:eatvio_app/features/my_finance/presentation/cubit/user_cards_cub
|
|||||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
|
||||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
|
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
|
||||||
|
as profile_plans;
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||||
@@ -51,6 +53,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
|
|||||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||||
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
||||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
@@ -67,57 +71,174 @@ class CubitRegistrations {
|
|||||||
/// Регистрация Cubit'ов как синглтонов
|
/// Регистрация Cubit'ов как синглтонов
|
||||||
static void _registerSingletonCubits() {
|
static void _registerSingletonCubits() {
|
||||||
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
|
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
|
||||||
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()));
|
sl.registerLazySingleton<NotificationsCubit>(
|
||||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
|
||||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
);
|
||||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Регистрация Cubit'ов как фабрик
|
/// Регистрация Cubit'ов как фабрик
|
||||||
static void _registerFactoryCubits() {
|
static void _registerFactoryCubits() {
|
||||||
// Auth related cubits
|
// Auth related cubits
|
||||||
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
|
sl.registerFactory<AuthCubit>(
|
||||||
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
|
() => AuthCubit(
|
||||||
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
|
authRepository: sl(),
|
||||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
|
firebaseApi: sl(),
|
||||||
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
|
prefs: sl(),
|
||||||
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
|
profileRepository: sl(),
|
||||||
|
sessionLifecycleService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<RegistrationCubit>(
|
||||||
|
() => RegistrationCubit(
|
||||||
|
registrationRepository: sl(),
|
||||||
|
prefs: sl(),
|
||||||
|
sessionLifecycleService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<ResetPasswordCubit>(
|
||||||
|
() => ResetPasswordCubit(resetPasswordRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<VerifyEmailCubit>(
|
||||||
|
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<OAuthCubit>(
|
||||||
|
() => OAuthCubit(
|
||||||
|
authRepository: sl(),
|
||||||
|
firebaseApi: sl(),
|
||||||
|
prefs: sl(),
|
||||||
|
sessionLifecycleService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<RegistrationCubitV2>(
|
||||||
|
() => RegistrationCubitV2(
|
||||||
|
registrationRepository: sl(),
|
||||||
|
firebaseApi: sl(),
|
||||||
|
sessionLifecycleService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
// Feed related cubits
|
// Feed related cubits
|
||||||
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
|
sl.registerFactory<RecipesCubit>(
|
||||||
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
|
() => RecipesCubit(
|
||||||
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl()));
|
feedRepository: sl(),
|
||||||
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl()));
|
recipeRepository: sl(),
|
||||||
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl()));
|
aboutRecipeRepository: sl(),
|
||||||
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()));
|
),
|
||||||
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
|
);
|
||||||
|
sl.registerFactory<DietsCubit>(
|
||||||
|
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<PlansCubit>(
|
||||||
|
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<ConsultantsCubit>(
|
||||||
|
() => ConsultantsCubit(feedRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<CommentsCubit>(
|
||||||
|
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<FeedProfileCubit>(
|
||||||
|
() => FeedProfileCubit(profileRepository: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
// Chat related cubits
|
// Chat related cubits
|
||||||
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
|
sl.registerFactory<ChatRoomCubit>(
|
||||||
|
() => ChatRoomCubit(
|
||||||
|
cardsRepository: sl(),
|
||||||
|
chatRepository: sl(),
|
||||||
|
wsManager: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Profile related cubits
|
// Profile related cubits
|
||||||
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
|
sl.registerFactory<ProfileCubit>(
|
||||||
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
|
() => ProfileCubit(
|
||||||
|
profileRepository: sl(),
|
||||||
|
chatRepository: sl(),
|
||||||
|
consultantRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<profile_plans.PlansCubit>(
|
||||||
|
() => profile_plans.PlansCubit(plansRepository: sl()),
|
||||||
|
);
|
||||||
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
|
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
|
||||||
|
|
||||||
// Content creation cubits
|
// Content creation cubits
|
||||||
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl()));
|
sl.registerFactory<CreateDietCubit>(
|
||||||
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl()));
|
() => CreateDietCubit(
|
||||||
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()));
|
profileRepository: sl(),
|
||||||
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl()));
|
createDietRepository: sl(),
|
||||||
|
glossaryRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<CreatePlanCubit>(
|
||||||
|
() => CreatePlanCubit(createPlanRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<CreateRecipeCubit>(
|
||||||
|
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<BlogPostCreateCubit>(
|
||||||
|
() => BlogPostCreateCubit(blogPostRepository: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
// Content viewing cubits
|
// Content viewing cubits
|
||||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
sl.registerFactory<AboutDietCubit>(
|
||||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
|
||||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
);
|
||||||
|
sl.registerFactory<AboutPlanCubit>(
|
||||||
|
() => AboutPlanCubit(
|
||||||
|
planRepository: sl(),
|
||||||
|
profileRepository: sl(),
|
||||||
|
dietRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<AboutRecipeCubit>(
|
||||||
|
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
|
||||||
|
);
|
||||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||||
|
|
||||||
// User content cubits
|
// User content cubits
|
||||||
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
|
sl.registerFactory<MyDietsCubit>(
|
||||||
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
|
() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
|
||||||
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
|
);
|
||||||
|
sl.registerFactory<MyPlansCubit>(
|
||||||
|
() => MyPlansCubit(myPlansRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<MyRecipesCubit>(
|
||||||
|
() => MyRecipesCubit(
|
||||||
|
myRecipesRepository: sl(),
|
||||||
|
aboutRecipeRepository: sl(),
|
||||||
|
recipeRepository: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Other feature cubits
|
// Other feature cubits
|
||||||
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
|
sl.registerFactory<CalendarCubit>(
|
||||||
|
() => CalendarCubit(calendarRepository: sl()),
|
||||||
|
);
|
||||||
sl.registerFactory<DiaryCubit>(
|
sl.registerFactory<DiaryCubit>(
|
||||||
() => DiaryCubit(
|
() => DiaryCubit(
|
||||||
chatRepository: sl(),
|
chatRepository: sl(),
|
||||||
@@ -129,25 +250,74 @@ class CubitRegistrations {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
sl.registerFactory<SettingsCubit>(
|
sl.registerFactory<SettingsCubit>(
|
||||||
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
|
() => SettingsCubit(
|
||||||
|
profileRepository: sl(),
|
||||||
|
settingsRepository: sl(),
|
||||||
|
consultantRepository: sl(),
|
||||||
|
consultationsRepository: sl(),
|
||||||
|
sessionLifecycleService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
sl.registerFactory<RegisterConsultantCubit>(
|
||||||
|
() => RegisterConsultantCubit(consultantRepository: sl()),
|
||||||
|
);
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<ConsultationsArchiveCubit>(
|
||||||
|
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<ConsultantFinancesCubit>(
|
||||||
|
() => ConsultantFinancesCubit(consultationsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<UserPurchasesCubit>(
|
||||||
|
() => UserPurchasesCubit(consultationsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<BlockedUsersCubit>(
|
||||||
|
() => BlockedUsersCubit(blockedUsersRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<NotificationSettingsCubit>(
|
||||||
|
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<ConsultantRatingCubit>(
|
||||||
|
() => ConsultantRatingCubit(
|
||||||
|
feedRepository: sl(),
|
||||||
|
repository: sl(),
|
||||||
|
profileRepository: sl(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
|
|
||||||
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
|
|
||||||
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
|
|
||||||
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
|
|
||||||
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
|
|
||||||
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
|
|
||||||
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
|
|
||||||
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
|
|
||||||
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
|
|
||||||
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
|
|
||||||
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
|
|
||||||
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
|
|
||||||
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
|
|
||||||
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
|
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
|
||||||
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
|
sl.registerFactory<UserDetailsCubit>(
|
||||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
|
() => UserDetailsCubit(profileRepository: sl()),
|
||||||
|
);
|
||||||
|
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
|
||||||
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
||||||
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
|
sl.registerFactory<PersonalDietSuggestionCubit>(
|
||||||
|
() => PersonalDietSuggestionCubit(
|
||||||
|
dietRepository: sl(),
|
||||||
|
personalDietSuggestionService: sl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/constants/app_constants.dart';
|
import 'package:eatvio_app/core/constants/app_constants.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/sentry_error_reporter.dart';
|
||||||
import 'package:eatvio_app/core/interceptors/phone_verify_error_interceptor.dart';
|
import 'package:eatvio_app/core/interceptors/phone_verify_error_interceptor.dart';
|
||||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||||
import 'package:eatvio_app/core/prefs.dart';
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:sentry_dio/sentry_dio.dart';
|
import 'package:sentry_dio/sentry_dio.dart';
|
||||||
|
|
||||||
@@ -14,6 +17,7 @@ class ExternalRegistrations {
|
|||||||
|
|
||||||
/// Регистрация всех внешних зависимостей
|
/// Регистрация всех внешних зависимостей
|
||||||
static void registerAll() {
|
static void registerAll() {
|
||||||
|
sl.registerLazySingleton<ErrorReporter>(SentryErrorReporter.new);
|
||||||
_registerDio();
|
_registerDio();
|
||||||
_registerPrefs();
|
_registerPrefs();
|
||||||
}
|
}
|
||||||
@@ -30,7 +34,9 @@ class ExternalRegistrations {
|
|||||||
sendTimeout: AppConstants.pendingDelay,
|
sendTimeout: AppConstants.pendingDelay,
|
||||||
),
|
),
|
||||||
)..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()])
|
)..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()])
|
||||||
..addSentry(),
|
..addSentry(
|
||||||
|
failedRequestStatusCodes: const [SentryStatusCode.defaultRange()],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.da
|
|||||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
import 'package:eatvio_app/features/my_plans/repositories/my_diets_repository.dart';
|
||||||
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
import 'package:eatvio_app/features/notifications/repository/notifications_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/repositories/sbp_repository.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/repository/personal_diet_suggestion_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||||
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
import 'package:eatvio_app/features/plans/repositories/plans_repository.dart';
|
||||||
@@ -33,6 +35,7 @@ import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.da
|
|||||||
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
|
import 'package:eatvio_app/features/registration/repositories/registration_repository.dart';
|
||||||
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
|
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
|
||||||
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
|
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
/// Регистрация всех Repository в системе внедрения зависимостей
|
/// Регистрация всех Repository в системе внедрения зависимостей
|
||||||
@@ -49,6 +52,7 @@ class RepositoryRegistrations {
|
|||||||
static void _registerSingletonRepositories() {
|
static void _registerSingletonRepositories() {
|
||||||
// Core repositories
|
// Core repositories
|
||||||
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
|
sl.registerLazySingleton<ChatRepository>(() => ChatRepository(chatService: sl()));
|
||||||
|
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
|
||||||
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
|
||||||
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
|
||||||
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));
|
||||||
@@ -72,6 +76,8 @@ class RepositoryRegistrations {
|
|||||||
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
sl.registerLazySingleton<BlockedUsersRepository>(() => BlockedUsersRepository(blockedUserService: sl()));
|
||||||
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
sl.registerLazySingleton<CommentsRepository>(() => CommentsRepository(service: sl()));
|
||||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
sl.registerLazySingleton<NotificationSettingsRepository>(() => NotificationSettingsRepository(sl()));
|
||||||
|
sl.registerLazySingleton<SbpRepository>(() => SbpRepository(sbpService: sl()));
|
||||||
|
sl.registerLazySingleton<PaymentRepository>(() => PaymentRepository(chatService: sl()));
|
||||||
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
sl.registerLazySingleton<PersonalDietSuggestionRepository>(() => PersonalDietSuggestionRepository(personalDietSuggestionService: sl()));
|
||||||
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
sl.registerLazySingleton<RegistrationRepositoryV2>(() => RegistrationRepositoryV2(registrationService: sl()));
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
|||||||
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
import 'package:eatvio_app/features/my_finance/services/cards_service.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
import 'package:eatvio_app/features/my_plans/services/my_plans_service.dart';
|
||||||
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
import 'package:eatvio_app/features/notifications/service/notifications_service.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/services/sbp_service.dart';
|
||||||
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
import 'package:eatvio_app/features/personal_diet_suggestion/services/personal_diet_suggestion_service.dart';
|
||||||
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
import 'package:eatvio_app/features/plans/services/plan_service.dart';
|
||||||
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
import 'package:eatvio_app/features/plans/services/plans_service.dart';
|
||||||
@@ -35,6 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
|
|||||||
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
||||||
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
||||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
|
||||||
/// Регистрация всех Service в системе внедрения зависимостей
|
/// Регистрация всех Service в системе внедрения зависимостей
|
||||||
@@ -50,6 +54,7 @@ class ServiceRegistrations {
|
|||||||
static void _registerSingletonServices() {
|
static void _registerSingletonServices() {
|
||||||
// Core services
|
// Core services
|
||||||
sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
|
sl.registerLazySingleton<ChatService>(() => ChatService(sl()));
|
||||||
|
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
|
||||||
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
|
||||||
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
|
||||||
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));
|
||||||
@@ -72,7 +77,13 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<CancelPlanService>(() => CancelPlanService(sl()));
|
sl.registerLazySingleton<CancelPlanService>(() => CancelPlanService(sl()));
|
||||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi());
|
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||||
|
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||||
|
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||||
|
() => VideoCallSystemPipCoordinator(),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||||
@@ -80,6 +91,7 @@ class ServiceRegistrations {
|
|||||||
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
sl.registerLazySingleton<ConsultantRatingService>(() => ConsultantRatingService(sl()));
|
||||||
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
sl.registerLazySingleton<NotificationSettingsService>(() => NotificationSettingsService(sl()));
|
||||||
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
sl.registerLazySingleton<ViewsService>(() => ViewsService(sl()));
|
||||||
|
sl.registerLazySingleton<SbpService>(() => const SbpServiceImpl());
|
||||||
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
sl.registerLazySingleton<PersonalDietSuggestionService>(() => PersonalDietSuggestionService(sl()));
|
||||||
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
sl.registerLazySingleton<RegistrationServiceV2>(() => RegistrationServiceV2(sl()));
|
||||||
|
|
||||||
|
|||||||
15
lib/core/di/session_registrations.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||||
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
|
|
||||||
|
class SessionRegistrations {
|
||||||
|
static void registerAll() {
|
||||||
|
sl.registerLazySingleton<SessionLifecycleService>(
|
||||||
|
() => SessionLifecycleService(
|
||||||
|
wsManager: sl<WsManager>(),
|
||||||
|
errorReporter: sl<ErrorReporter>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/core/error_reporting/error_report_policy.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
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.
|
||||||
|
///
|
||||||
|
/// [shouldReport] is used by [ErrorReporter]. [allowSentryEvent] is used in
|
||||||
|
/// [SentryFlutter.init] as a last-line filter (Dio failed-request capture, etc.).
|
||||||
|
class ErrorReportPolicy {
|
||||||
|
ErrorReportPolicy._();
|
||||||
|
|
||||||
|
/// When `false`, the error is not sent via [ErrorReporter].
|
||||||
|
static bool shouldReport(Object? error) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used in SDK [SentryOptions.beforeSend]. Return `false` to drop the event.
|
||||||
|
///
|
||||||
|
/// For [DioException], rules differ from [shouldReport]: events from `sentry_dio`
|
||||||
|
/// (e.g. 5xx) must pass through here, while [shouldReport] returns false for 5xx
|
||||||
|
/// so [SentryErrorReporter] does not duplicate them after `catch` in cubits / manual [report] calls.
|
||||||
|
static bool allowSentryEvent(SentryEvent event, Hint hint) {
|
||||||
|
final t = event.throwable;
|
||||||
|
if (t == null) return true;
|
||||||
|
if (t is DioException) {
|
||||||
|
return _allowSentrySdkDio(t);
|
||||||
|
}
|
||||||
|
return shouldReport(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel, timeouts, socket / connection errors — do not send (noisy, often user environment).
|
||||||
|
static bool _isTransientDioFailure(DioException e) {
|
||||||
|
switch (e.type) {
|
||||||
|
case DioExceptionType.cancel:
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.sendTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
case DioExceptionType.transformTimeout:
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return true;
|
||||||
|
case DioExceptionType.unknown:
|
||||||
|
case DioExceptionType.badResponse:
|
||||||
|
case DioExceptionType.badCertificate:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return e.error is SocketException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Noise to drop for any Dio-originated SDK event; do not use for manual [report].
|
||||||
|
static bool _allowSentrySdkDio(DioException e) {
|
||||||
|
if (_isTransientDioFailure(e)) return false;
|
||||||
|
final code = e.response?.statusCode;
|
||||||
|
if (code != null && code >= 400 && code < 500) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _shouldReportDio(DioException e) {
|
||||||
|
if (_isTransientDioFailure(e)) return false;
|
||||||
|
|
||||||
|
final code = e.response?.statusCode;
|
||||||
|
if (code != null) {
|
||||||
|
if (code >= 500 && code <= 599) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (code >= 400 && code < 500) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
lib/core/error_reporting/error_reporter.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// Application-wide error reporting (e.g. Sentry).
|
||||||
|
abstract class ErrorReporter {
|
||||||
|
Future<void> report(
|
||||||
|
Object error,
|
||||||
|
StackTrace? stackTrace, {
|
||||||
|
String? origin,
|
||||||
|
Map<String, Object?>? extras,
|
||||||
|
});
|
||||||
|
}
|
||||||
14
lib/core/error_reporting/no_op_error_reporter.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
|
||||||
|
/// Reporting sink that does nothing (dev tooling, tests, diagnostics).
|
||||||
|
class NoOpErrorReporter implements ErrorReporter {
|
||||||
|
const NoOpErrorReporter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> report(
|
||||||
|
Object error,
|
||||||
|
StackTrace? stackTrace, {
|
||||||
|
String? origin,
|
||||||
|
Map<String, Object?>? extras,
|
||||||
|
}) async {}
|
||||||
|
}
|
||||||
17
lib/core/error_reporting/report_from.dart
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/no_op_error_reporter.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
|
||||||
|
/// Отправка в [ErrorReporter] с тегом origin = [context.runtimeType] (имя кубита/виджета и т.д.).
|
||||||
|
///
|
||||||
|
/// Если [ErrorReporter] не зарегистрирован (например, после [GetIt.reset] в тестах), используется [NoOpErrorReporter].
|
||||||
|
Future<void> reportFrom(Object error, StackTrace? stackTrace, Object context) {
|
||||||
|
final reporter = sl.isRegistered<ErrorReporter>()
|
||||||
|
? sl<ErrorReporter>()
|
||||||
|
: const NoOpErrorReporter();
|
||||||
|
return reporter.report(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
origin: context.runtimeType.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
33
lib/core/error_reporting/sentry_error_reporter.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
class SentryErrorReporter implements ErrorReporter {
|
||||||
|
SentryErrorReporter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> report(
|
||||||
|
Object error,
|
||||||
|
StackTrace? stackTrace, {
|
||||||
|
String? origin,
|
||||||
|
Map<String, Object?>? extras,
|
||||||
|
}) async {
|
||||||
|
if (!ErrorReportPolicy.shouldReport(error)) return;
|
||||||
|
|
||||||
|
await Sentry.captureException(
|
||||||
|
error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
withScope: (scope) {
|
||||||
|
if (origin != null && origin.isNotEmpty) {
|
||||||
|
scope.setTag('origin', origin);
|
||||||
|
}
|
||||||
|
if (extras != null && extras.isNotEmpty) {
|
||||||
|
scope.setContexts(
|
||||||
|
'report_extras',
|
||||||
|
extras.map((k, v) => MapEntry(k, v?.toString())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,55 +3,59 @@ import 'dart:developer';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
import 'package:eatvio_app/core/app_initializer.dart';
|
||||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
import 'package:eatvio_app/core/device/device_hw_id_provider.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:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||||
|
|
||||||
class FirebaseApi {
|
class FirebaseApi {
|
||||||
|
final ErrorReporter _errorReporter;
|
||||||
|
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||||
|
|
||||||
Future<String?> getToken() async {
|
FirebaseApi({
|
||||||
String? token;
|
required ErrorReporter errorReporter,
|
||||||
|
NotificationPermissionRequester? notificationPermissionRequester,
|
||||||
|
}) : _errorReporter = errorReporter,
|
||||||
|
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||||
|
|
||||||
if (await _isHuaweiDevice()) {
|
Future<String?> getToken() async {
|
||||||
token = await _getHuaweiPushTokenNative().timeout(
|
final credentials = await _resolvePushCredentials();
|
||||||
const Duration(seconds: 5),
|
return credentials.token;
|
||||||
onTimeout: () => null,
|
}
|
||||||
);
|
|
||||||
} else {
|
Future<void> _requestNotificationPermission() async {
|
||||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||||
try {
|
try {
|
||||||
await _firebaseMessaging.requestPermission().timeout(
|
await _notificationPermissionRequester.requestOnce(
|
||||||
const Duration(seconds: 5),
|
() => _firebaseMessaging.requestPermission(),
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
// Разрешение не критично, продолжаем
|
|
||||||
log('Failed to request Firebase messaging permission: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
token = await _firebaseMessaging.getToken().timeout(
|
|
||||||
const Duration(seconds: 5),
|
|
||||||
onTimeout: () => null,
|
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
log('Failed to request Firebase messaging permission: $e');
|
||||||
}
|
}
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _requestNotificationPermission();
|
||||||
|
final credentials = await _resolvePushCredentials();
|
||||||
|
final token = credentials.token;
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
await sl<Dio>().post(
|
await sl<Dio>().post(
|
||||||
'/api/anonymous_token',
|
'/api/anonymous_token',
|
||||||
data: {
|
data: {
|
||||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
'platform': credentials.platform,
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
'hw_id': hwId,
|
||||||
'token': await getToken(),
|
'token': token,
|
||||||
},
|
},
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 10),
|
const Duration(seconds: 10),
|
||||||
@@ -61,63 +65,214 @@ class FirebaseApi {
|
|||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
if (await TokenStorage.haveTokenInStorage()) {
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
|
try {
|
||||||
|
await _requestNotificationPermission();
|
||||||
|
final credentials = await _resolvePushCredentials();
|
||||||
|
final token = credentials.token;
|
||||||
|
final hwId = await DeviceHwIdProvider.current();
|
||||||
|
final reregisterAndroid =
|
||||||
|
await DeviceHwIdProvider.androidTokenNeedsReregister();
|
||||||
|
if (token != null &&
|
||||||
|
token.isNotEmpty &&
|
||||||
|
hwId != null &&
|
||||||
|
hwId.length >= 8) {
|
||||||
|
if (reregisterAndroid) {
|
||||||
|
try {
|
||||||
|
await unregisterPushToken();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await sl<Dio>()
|
||||||
|
.post(
|
||||||
|
'/api/profile/notification/token/register',
|
||||||
|
data: {
|
||||||
|
'platform': credentials.platform,
|
||||||
|
'hw_id': hwId,
|
||||||
|
'token': token,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () {
|
||||||
|
throw TimeoutException('registerPushToken timeout');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (reregisterAndroid) {
|
||||||
|
await DeviceHwIdProvider.markAndroidTokenReregistered();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||||
|
}
|
||||||
|
// Incoming calls on iOS come only through the VoIP token. It does not
|
||||||
|
// depend on the FCM token, so a missing or failed FCM registration must
|
||||||
|
// not leave the device without calls.
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
await _registerIosVoipPushToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<void> unregisterPushTokens() async {
|
||||||
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
|
try {
|
||||||
|
await unregisterPushToken();
|
||||||
|
} catch (_) {}
|
||||||
|
if (Platform.isIOS) {
|
||||||
try {
|
try {
|
||||||
final deviceInfo = DeviceInfoPlugin();
|
await _unregisterIosVoipPushToken();
|
||||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
} catch (_) {}
|
||||||
await sl<Dio>().post(
|
}
|
||||||
'/api/profile/notification/token/register',
|
}
|
||||||
data: {
|
|
||||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
Future<void> unregisterPushToken() async {
|
||||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
'token': await getToken(),
|
try {
|
||||||
},
|
final credentials = await _resolvePushCredentials();
|
||||||
).timeout(
|
final token = credentials.token ?? '';
|
||||||
const Duration(seconds: 10),
|
if (token.isEmpty) return;
|
||||||
onTimeout: () {
|
await sl<Dio>().post(
|
||||||
throw TimeoutException('registerPushToken timeout');
|
'/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 hwId = await DeviceHwIdProvider.current();
|
||||||
|
if (hwId == null || hwId.length < 8) return;
|
||||||
|
await sl<Dio>().post(
|
||||||
|
'/api/profile/notification/token/voip/register',
|
||||||
|
data: {
|
||||||
|
'platform': 'ios',
|
||||||
|
'hw_id': hwId,
|
||||||
|
'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),
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
|
||||||
log(e.toString(), stackTrace: st);
|
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
|
||||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<bool> _isHuaweiDevice() async {
|
||||||
if (!Platform.isAndroid) return false;
|
if (!Platform.isAndroid) return false;
|
||||||
try {
|
try {
|
||||||
final info = await DeviceInfoPlugin().androidInfo;
|
final info = await DeviceInfoPlugin().androidInfo;
|
||||||
final brand = info.brand.toLowerCase();
|
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||||
final manufacturer = info.manufacturer.toLowerCase();
|
|
||||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
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 {
|
Future<String?> _getHuaweiPushTokenNative() async {
|
||||||
try {
|
try {
|
||||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||||
return token;
|
return token;
|
||||||
} on PlatformException catch (e, st) {
|
} on PlatformException catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
if (!isHmsClientUnavailable(e)) {
|
||||||
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
showErrorToast('$e $st');
|
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:eatvio_app/core/app_initializer.dart';
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:image/image.dart' as img;
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
abstract class ImageCompressUtility {
|
abstract class ImageCompressUtility {
|
||||||
@@ -30,44 +29,21 @@ abstract class ImageCompressUtility {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<File> compressFile(File file) async {
|
static Future<File> compressFile(File file) async {
|
||||||
// Skip compression in test environment to avoid hangs
|
|
||||||
try {
|
try {
|
||||||
final appConfig = sl<AppConfig>();
|
final appConfig = sl<AppConfig>();
|
||||||
if (appConfig.buildType == AppBuildType.tests) {
|
if (appConfig.buildType == AppBuildType.tests) {
|
||||||
// In tests, return the original file without compression
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// If AppConfig is not available, proceed with compression
|
|
||||||
|
final compressedPath = '${p.withoutExtension(file.path)}_compressed.jpg';
|
||||||
|
|
||||||
|
final result = await FlutterImageCompress.compressAndGetFile(file.absolute.path, compressedPath, quality: 90, minHeight: 1080, minWidth: 1080);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
final image = img.decodeImage(file.readAsBytesSync())!;
|
return File(result.path);
|
||||||
int width = image.width;
|
|
||||||
int height = image.height;
|
|
||||||
|
|
||||||
if (width > height) {
|
|
||||||
if (width > 1080) {
|
|
||||||
height = (height * 1080) ~/ width;
|
|
||||||
width = 1080;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (height > 1080) {
|
|
||||||
width = (width * 1080) ~/ height;
|
|
||||||
height = 1080;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String filePathWithoutExtension = p.withoutExtension(file.path);
|
|
||||||
String compressedFilePath = '${filePathWithoutExtension}_compressed.jpg';
|
|
||||||
|
|
||||||
final result = await FlutterImageCompress.compressAndGetFile(
|
|
||||||
file.absolute.path,
|
|
||||||
compressedFilePath,
|
|
||||||
quality: 90,
|
|
||||||
minHeight: height,
|
|
||||||
minWidth: width,
|
|
||||||
);
|
|
||||||
|
|
||||||
return File(result!.path);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/interceptors/verification_requirement.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -8,25 +10,77 @@ import 'package:go_router/go_router.dart';
|
|||||||
class PhoneVerifyErrorInterceptor extends Interceptor {
|
class PhoneVerifyErrorInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||||
if (err.response?.statusCode == 405) {
|
if (err.response?.statusCode != 405) {
|
||||||
final data = err.response?.data;
|
return handler.next(err);
|
||||||
|
}
|
||||||
if (data is Map<String, dynamic> && data['type'] == 'unverificated_phone') {
|
|
||||||
final String phone = await sl<ProfileRepository>().getMyPhone();
|
final requirement = VerificationRequirement.tryParse(err.response?.data);
|
||||||
final String passKey = await sl<ProfileRepository>().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone});
|
if (requirement == null) {
|
||||||
await showErrorDialog(AppRouter.navigatorKey.currentContext!,
|
return handler.next(err);
|
||||||
title: 'Привяжите номер телефона', description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона');
|
}
|
||||||
AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey);
|
|
||||||
return handler.reject(
|
// Бэкенд может требовать подтверждения почты, телефона или обоих каналов сразу.
|
||||||
DioException(
|
if (requirement.needsEmail) {
|
||||||
requestOptions: err.requestOptions,
|
await _handleEmailVerificationRequired();
|
||||||
response: err.response,
|
} else {
|
||||||
error: 'Phone number not verified',
|
await _handlePhoneVerificationRequired();
|
||||||
type: DioExceptionType.badResponse,
|
}
|
||||||
),
|
|
||||||
);
|
return handler.reject(
|
||||||
}
|
DioException(
|
||||||
|
requestOptions: err.requestOptions,
|
||||||
|
response: err.response,
|
||||||
|
error: 'Phone number not verified',
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Старый сценарий: показывает привязку телефона и открывает passkey-экран.
|
||||||
|
Future<void> _handlePhoneVerificationRequired() async {
|
||||||
|
final String phone = await sl<ProfileRepository>().getMyPhone();
|
||||||
|
final String passKey = await sl<ProfileRepository>().getPassKey(
|
||||||
|
routeName: 'user_detailz.change-phone-verify',
|
||||||
|
routeParams: {"phone": phone},
|
||||||
|
);
|
||||||
|
await showErrorDialog(
|
||||||
|
AppRouter.navigatorKey.currentContext!,
|
||||||
|
title: 'Привяжите номер телефона',
|
||||||
|
description:
|
||||||
|
'Для того, что бы выполнить это действие необходимо привязать номер телефона',
|
||||||
|
);
|
||||||
|
final currentContext = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (currentContext != null &&
|
||||||
|
currentContext.mounted &&
|
||||||
|
GoRouter.of(currentContext).state.path ==
|
||||||
|
RouteConstants.stepperBecomeConsultantScreen) {
|
||||||
|
GoRouter.of(currentContext).pop();
|
||||||
|
}
|
||||||
|
AppRouter.navigatorKey.currentContext?.pushNamed(
|
||||||
|
'verifyScreen',
|
||||||
|
extra: passKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Новый сценарий: предлагает подтвердить e-mail и открывает экран ввода кода.
|
||||||
|
Future<void> _handleEmailVerificationRequired() async {
|
||||||
|
final context = AppRouter.navigatorKey.currentContext;
|
||||||
|
|
||||||
|
if (context == null || !context.mounted) {
|
||||||
|
// UI недоступен (например, в тестах) — просто отклоняем запрос, без падения.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showErrorDialog(
|
||||||
|
context,
|
||||||
|
title: 'Подтвердите e-mail',
|
||||||
|
description:
|
||||||
|
'Чтобы публиковать материалы, подтвердите адрес электронной почты. Мы отправим код на вашу почту.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
context.pushNamed('verifyEmailScreen', extra: true);
|
||||||
}
|
}
|
||||||
return handler.next(err);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
|
|
||||||
class TokenInterceptor extends Interceptor {
|
class TokenInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
options.headers['Authorization'] = 'Bearer $token';
|
options.headers['Authorization'] = 'Bearer $token';
|
||||||
|
options.extra['authToken'] = token;
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onRequest(options, handler);
|
super.onRequest(options, handler);
|
||||||
@@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor {
|
|||||||
ErrorInterceptorHandler handler,
|
ErrorInterceptorHandler handler,
|
||||||
) async {
|
) async {
|
||||||
if (err.response?.statusCode == 401) {
|
if (err.response?.statusCode == 401) {
|
||||||
await sl<WsManager>().clear();
|
final requestCarriedAuth = err.requestOptions.headers.containsKey(
|
||||||
await TokenStorage.removeTokenInStorage();
|
'Authorization',
|
||||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
);
|
||||||
|
final requestToken = err.requestOptions.extra['authToken'];
|
||||||
|
final currentToken = await TokenStorage.getTokenFromStorage();
|
||||||
|
if (requestCarriedAuth &&
|
||||||
|
requestToken != null &&
|
||||||
|
requestToken == currentToken) {
|
||||||
|
await sl<SessionLifecycleService>().endSession(
|
||||||
|
reason: SessionEndReason.unauthorized,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
super.onError(err, handler);
|
super.onError(err, handler);
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/core/interceptors/verification_requirement.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/// Канал верификации, которого не хватает для публичного действия.
|
||||||
|
enum VerificationChannel { phone, email }
|
||||||
|
|
||||||
|
/// Разбор 405-контракта верификации (type/reason/missing) в список каналов.
|
||||||
|
///
|
||||||
|
/// Бэкенд отвечает на публичное действие без подтверждения так:
|
||||||
|
/// `{"type": "unverificated_phone", "reason": "verification_required", "missing": ["email"]}`.
|
||||||
|
/// Старые ответы не содержали поля `missing` — тогда не хватало только телефона.
|
||||||
|
class VerificationRequirement {
|
||||||
|
const VerificationRequirement({required this.missing});
|
||||||
|
|
||||||
|
/// Каналы, подтверждение которых требует бэкенд.
|
||||||
|
final List<VerificationChannel> missing;
|
||||||
|
|
||||||
|
bool get needsEmail => missing.contains(VerificationChannel.email);
|
||||||
|
|
||||||
|
bool get needsPhone => missing.contains(VerificationChannel.phone);
|
||||||
|
|
||||||
|
/// Возвращает null, если ответ не является отказом верификации.
|
||||||
|
static VerificationRequirement? tryParse(Object? data) {
|
||||||
|
if (data is! Map) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data['type'] != 'unverificated_phone') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final Object? rawMissing = data['missing'];
|
||||||
|
if (rawMissing is! List) {
|
||||||
|
// Legacy-контракт: поля missing не было, значит не подтверждён телефон.
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPhone = false;
|
||||||
|
var hasEmail = false;
|
||||||
|
for (final Object? value in rawMissing) {
|
||||||
|
// Неизвестные значения игнорируем, чтобы новые каналы бэкенда не ломали разбор.
|
||||||
|
if (value == 'phone') {
|
||||||
|
hasPhone = true;
|
||||||
|
} else if (value == 'email') {
|
||||||
|
hasEmail = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустой или нераспознанный missing трактуем как legacy-контракт «телефон».
|
||||||
|
if (!hasPhone && !hasEmail) {
|
||||||
|
return const VerificationRequirement(
|
||||||
|
missing: [VerificationChannel.phone],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return VerificationRequirement(
|
||||||
|
missing: [
|
||||||
|
if (hasPhone) VerificationChannel.phone,
|
||||||
|
if (hasEmail) VerificationChannel.email,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
||||||
@@ -33,7 +34,8 @@ class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
result = await _connectivity.checkConnectivity();
|
result = await _connectivity.checkConnectivity();
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
log('Couldn\'t check connectivity status', error: e);
|
log('Couldn\'t check connectivity status', error: e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,9 +44,13 @@ class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateAccessStatus(List<ConnectivityResult> result) async {
|
Future<void> _updateAccessStatus(List<ConnectivityResult> result) async {
|
||||||
final hasInternet = await _checkInternetAccess(result);
|
try {
|
||||||
if (!hasInternet) showInternetConnectionToast();
|
final hasInternet = await _checkInternetAccess(result);
|
||||||
emit(state.copyWith(isInternetAvailable: hasInternet));
|
if (!hasInternet) showInternetConnectionToast();
|
||||||
|
emit(state.copyWith(isInternetAvailable: hasInternet));
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _checkInternetAccess(List<ConnectivityResult> result) async {
|
Future<bool> _checkInternetAccess(List<ConnectivityResult> result) async {
|
||||||
@@ -57,7 +63,8 @@ class InternetConnectionCubit extends Cubit<InternetConnectionState> {
|
|||||||
try {
|
try {
|
||||||
final response = await http.get(uri).timeout(const Duration(seconds: 3));
|
final response = await http.get(uri).timeout(const Duration(seconds: 3));
|
||||||
return response.statusCode >= 100 && response.statusCode < 600;
|
return response.statusCode >= 100 && response.statusCode < 600;
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:eatvio_app/core/app_initializer.dart';
|
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/app_config_registration.dart';
|
||||||
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
import 'package:eatvio_app/core/di/cubit_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/external_registrations.dart';
|
import 'package:eatvio_app/core/di/external_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/repository_registrations.dart';
|
import 'package:eatvio_app/core/di/repository_registrations.dart';
|
||||||
import 'package:eatvio_app/core/di/service_registrations.dart';
|
import 'package:eatvio_app/core/di/service_registrations.dart';
|
||||||
|
import 'package:eatvio_app/core/di/session_registrations.dart';
|
||||||
import 'package:eatvio_app/core/prefs.dart';
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
@@ -21,6 +23,8 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
|
|
||||||
// Ожидание инициализации асинхронных зависимостей
|
// Ожидание инициализации асинхронных зависимостей
|
||||||
await sl.getAsync<Prefs>();
|
await sl.getAsync<Prefs>();
|
||||||
|
// До создания Dio и любых запросов к бэкенду.
|
||||||
|
applyCachedBaseUrl();
|
||||||
|
|
||||||
// Регистрация сервисов
|
// Регистрация сервисов
|
||||||
ServiceRegistrations.registerAll();
|
ServiceRegistrations.registerAll();
|
||||||
@@ -32,5 +36,9 @@ Future<void> init({required AppBuildType buildType}) async {
|
|||||||
CubitRegistrations.registerAll();
|
CubitRegistrations.registerAll();
|
||||||
|
|
||||||
// Регистрация WebSocket менеджера
|
// Регистрация WebSocket менеджера
|
||||||
sl.registerLazySingleton<WsManager>(() => WsManager(wsService: sl()));
|
sl.registerLazySingleton<WsManager>(
|
||||||
|
() => WsManager(wsService: sl(), errorReporter: sl()),
|
||||||
|
);
|
||||||
|
|
||||||
|
SessionRegistrations.registerAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 +1,25 @@
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:eatvio_app/core/app_router.dart';
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
/// Отвечает за навигацию по диплинкам, пришедшим в пуш-уведомлениях.
|
|
||||||
class PushNavigationHandler {
|
class PushNavigationHandler {
|
||||||
static bool _initialized = false;
|
static bool _initialized = false;
|
||||||
static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push');
|
static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push');
|
||||||
|
|
||||||
/// Инициализация обработчиков Firebase Messaging.
|
|
||||||
/// Должна вызываться один раз после старта приложения.
|
|
||||||
static Future<void> initialize() async {
|
static Future<void> initialize() async {
|
||||||
if (_initialized) return;
|
if (_initialized) return;
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
|
||||||
// Сообщение, по которому запустили приложение из "убитого" состояния.
|
|
||||||
try {
|
try {
|
||||||
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||||
if (initialMessage != null) {
|
if (initialMessage != null) {
|
||||||
@@ -25,18 +28,11 @@ class PushNavigationHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log(
|
developer.log('PushNavigationHandler: error while getting initialMessage: $e', name: 'PushNavigationHandler');
|
||||||
'PushNavigationHandler: error while getting initialMessage: $e',
|
|
||||||
name: 'PushNavigationHandler',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тап по уведомлению, когда приложение было в фоне.
|
FirebaseMessaging.onMessageOpenedApp.listen((message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'));
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen(
|
|
||||||
(message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Подписка на события от native-кода (HMS через MethodChannel).
|
|
||||||
_hmsChannel.setMethodCallHandler((call) async {
|
_hmsChannel.setMethodCallHandler((call) async {
|
||||||
if (call.method == 'openActionUrl') {
|
if (call.method == 'openActionUrl') {
|
||||||
final url = call.arguments as String? ?? '';
|
final url = call.arguments as String? ?? '';
|
||||||
@@ -45,47 +41,69 @@ class PushNavigationHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Входная точка для навигации из native-кода (например, HMS через MethodChannel).
|
|
||||||
static void handleActionUrlFromNative(String url) {
|
static void handleActionUrlFromNative(String url) {
|
||||||
if (url.isEmpty) {
|
if (url.isEmpty) {
|
||||||
developer.log(
|
developer.log('PushNavigationHandler.handleActionUrlFromNative: empty url', name: 'PushNavigationHandler');
|
||||||
'PushNavigationHandler.handleActionUrlFromNative: empty url',
|
|
||||||
name: 'PushNavigationHandler',
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log(
|
developer.log('PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url', name: 'PushNavigationHandler');
|
||||||
'PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url',
|
|
||||||
name: 'PushNavigationHandler',
|
|
||||||
);
|
|
||||||
|
|
||||||
AppRouter.navigateByDeepLinkUrl(url);
|
AppRouter.navigateByDeepLinkUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Публичный метод для единичной обработки сообщения (если понадобится где-то ещё).
|
|
||||||
static void handleMessage(RemoteMessage message) {
|
static void handleMessage(RemoteMessage message) {
|
||||||
_handleMessageInternal(message, source: 'manualHandle');
|
_handleMessageInternal(message, source: 'manualHandle');
|
||||||
}
|
}
|
||||||
|
|
||||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||||
final data = message.data;
|
final data = Map<String, dynamic>.from(message.data);
|
||||||
final actionUrl = data['action_url'] as String?;
|
|
||||||
|
|
||||||
if (actionUrl == null || actionUrl.isEmpty) {
|
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||||
|
if (endedPayload != null) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'PushNavigationHandler: no action_url in message data, source=$source, data=$data',
|
'PushNavigationHandler: dismiss incoming call UI, source=$source '
|
||||||
|
'call_id=${endedPayload.callId ?? '-'} '
|
||||||
|
'reason=${endedPayload.reason?.name ?? '-'}',
|
||||||
name: 'PushNavigationHandler',
|
name: 'PushNavigationHandler',
|
||||||
);
|
);
|
||||||
|
unawaited(
|
||||||
|
VideoCallEndedPushHandler.dismissIncomingCallUi(
|
||||||
|
conversationUuid: endedPayload.conversationUuid,
|
||||||
|
callId: endedPayload.callId,
|
||||||
|
reason: endedPayload.reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
developer.log(
|
final videoCallPayload = VideoCallPushPayload.tryParse(data);
|
||||||
'PushNavigationHandler: navigating by action_url=$actionUrl, source=$source',
|
if (videoCallPayload != null) {
|
||||||
name: 'PushNavigationHandler',
|
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');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
developer.log('PushNavigationHandler: navigating by action_url=$actionUrl, source=$source', name: 'PushNavigationHandler');
|
||||||
|
|
||||||
AppRouter.navigateByDeepLinkUrl(actionUrl);
|
AppRouter.navigateByDeepLinkUrl(actionUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
enum PrefsKeys {
|
||||||
|
hasAnonymousTokenBeenSent,
|
||||||
|
welcomeScreenWasShowed,
|
||||||
|
registrationDate,
|
||||||
|
marketingBottomSheetHasBeenShown,
|
||||||
|
isEmailVerificationSkipped,
|
||||||
|
customBaseUrl,
|
||||||
|
apiBaseUrl,
|
||||||
|
}
|
||||||
|
|||||||
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';
|
||||||
|
}
|
||||||
@@ -10,17 +10,14 @@ import 'package:eatvio_app/features/registration/presentation/pages/verify_email
|
|||||||
import 'package:eatvio_app/features/registration_v2/presentation/pages/registration_v2_page.dart';
|
import 'package:eatvio_app/features/registration_v2/presentation/pages/registration_v2_page.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
/// Маршруты для аутентификации
|
|
||||||
class AuthRoutes {
|
class AuthRoutes {
|
||||||
/// Создание маршрутов аутентификации
|
|
||||||
static List<RouteBase> createRoutes() {
|
static List<RouteBase> createRoutes() {
|
||||||
return [
|
return [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.authScreen,
|
path: RouteConstants.authScreen,
|
||||||
name: 'authScreen',
|
name: 'authScreen',
|
||||||
builder: (context, state) => const AuthScreen(),
|
builder: (context, state) => const AuthScreen(),
|
||||||
// registerAnonymousPushToken вызывается в AuthCubit.initializeUser()
|
|
||||||
// и не должен блокировать навигацию
|
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
|
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
|
||||||
GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
|
GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
|
||||||
@@ -37,7 +34,7 @@ class AuthRoutes {
|
|||||||
name: 'verifyScreen',
|
name: 'verifyScreen',
|
||||||
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
builder: (context, state) => VerifyScreen(passKey: state.extra as String),
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => const VerifyEmailScreen()),
|
GoRoute(path: RouteConstants.verifyEmailScreen, name: 'verifyEmailScreen', builder: (context, state) => VerifyEmailScreen(returnOnSuccess: state.extra == true)),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.registerViaSocialScreen,
|
path: RouteConstants.registerViaSocialScreen,
|
||||||
name: 'registerViaSocialScreen',
|
name: 'registerViaSocialScreen',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
|
||||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
|
||||||
@@ -24,19 +23,12 @@ class MainNavigationRoutes {
|
|||||||
return [
|
return [
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
|
|
||||||
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
||||||
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
// Вызываем getMyProfile() только если состояние loading (первый запуск)
|
profileAvatarCubit.ensureFreshForCurrentSession();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
});
|
||||||
profileAvatarCubit.getMyProfile();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return _MainShellWithNavScope(
|
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
|
||||||
currentLocation: state.matchedLocation,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -49,7 +41,14 @@ class MainNavigationRoutes {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.feedScreen,
|
path: RouteConstants.feedScreen,
|
||||||
name: 'feedScreen',
|
name: 'feedScreen',
|
||||||
pageBuilder: (context, state) => const NoTransitionPage(child: FeedScreen()),
|
pageBuilder: (context, state) {
|
||||||
|
final initialTabIndexFromQuery = int.tryParse(state.uri.queryParameters['initialTabIndex'] ?? '');
|
||||||
|
final extra = state.extra;
|
||||||
|
if (extra is int) {
|
||||||
|
return NoTransitionPage(child: FeedScreen(initialTabIndex: extra));
|
||||||
|
}
|
||||||
|
return NoTransitionPage(child: FeedScreen(initialTabIndex: initialTabIndexFromQuery));
|
||||||
|
},
|
||||||
redirect: (context, state) async {
|
redirect: (context, state) async {
|
||||||
try {
|
try {
|
||||||
await sl<WsManager>().initialize();
|
await sl<WsManager>().initialize();
|
||||||
@@ -85,16 +84,7 @@ class MainNavigationRoutes {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.menuScreen,
|
path: RouteConstants.menuScreen,
|
||||||
name: 'menuScreen',
|
name: 'menuScreen',
|
||||||
pageBuilder: (context, state) => NoTransitionPage(
|
pageBuilder: (context, state) => const NoTransitionPage(child: MenuScreen()),
|
||||||
child: BlocBuilder<ProfileAvatarCubit, ProfileAvatarState>(
|
|
||||||
builder: (BuildContext context, ProfileAvatarState state) {
|
|
||||||
if (state is ProfileAvatarStateLoaded) {
|
|
||||||
return MenuScreen(profileModel: state.profile);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -105,10 +95,7 @@ class MainNavigationRoutes {
|
|||||||
/// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications)
|
/// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications)
|
||||||
/// can switch tabs via [BottomNavController.switchToRoute] instead of pushing.
|
/// can switch tabs via [BottomNavController.switchToRoute] instead of pushing.
|
||||||
class _MainShellWithNavScope extends StatefulWidget {
|
class _MainShellWithNavScope extends StatefulWidget {
|
||||||
const _MainShellWithNavScope({
|
const _MainShellWithNavScope({required this.child, required this.currentLocation});
|
||||||
required this.child,
|
|
||||||
required this.currentLocation,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final String currentLocation;
|
final String currentLocation;
|
||||||
@@ -124,10 +111,7 @@ class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BottomNavScope(
|
return BottomNavScope(
|
||||||
controller: _navController,
|
controller: _navController,
|
||||||
child: BottomNavigationBarWidget(
|
child: BottomNavigationBarWidget(currentLocation: widget.currentLocation, child: widget.child),
|
||||||
currentLocation: widget.currentLocation,
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
|||||||
import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||||
@@ -22,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_plan/presentation/create_plan_screen.dart';
|
||||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_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/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/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||||
@@ -156,10 +159,7 @@ class OtherRoutes {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state.extra is Map<String, dynamic>) {
|
if (state.extra is Map<String, dynamic>) {
|
||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
return RecipeCreateScreen(
|
return RecipeCreateScreen(recipe: data['recipe'] as Recipe?, autoValidate: data['autoValidate'] as bool? ?? false);
|
||||||
recipe: data['recipe'] as Recipe?,
|
|
||||||
autoValidate: data['autoValidate'] as bool? ?? false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null);
|
return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null);
|
||||||
},
|
},
|
||||||
@@ -184,7 +184,14 @@ class OtherRoutes {
|
|||||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
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: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||||
@@ -212,10 +219,24 @@ class OtherRoutes {
|
|||||||
path: RouteConstants.chatScreen,
|
path: RouteConstants.chatScreen,
|
||||||
name: 'chatScreen',
|
name: 'chatScreen',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
final uuidFromQuery = state.uri.queryParameters['uuid'];
|
||||||
|
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 data = state.extra as Map<String, dynamic>;
|
||||||
final uuid = data['uuid'] as String;
|
final uuid = data['uuid'] as String?;
|
||||||
|
final orderId = data['orderId'] as String?;
|
||||||
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
final sendInitialMessage = data['sendInitialMessage'] as bool?;
|
||||||
return ChatScreen(uuid: uuid, sendInitialMessage: sendInitialMessage);
|
return ChatScreen(
|
||||||
|
uuid: uuid,
|
||||||
|
orderId: orderId,
|
||||||
|
sendInitialMessage: sendInitialMessage,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@@ -223,13 +244,58 @@ class OtherRoutes {
|
|||||||
name: 'emptyChatScreen',
|
name: 'emptyChatScreen',
|
||||||
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
|
builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: RouteConstants.videoCallScreen,
|
||||||
|
name: VideoCallScreen.name,
|
||||||
|
builder: (context, state) {
|
||||||
|
final extra = state.extra;
|
||||||
|
if (extra is Map<String, dynamic>) {
|
||||||
|
return VideoCallScreen(
|
||||||
|
conversationUuid: extra['conversationUuid'] as String,
|
||||||
|
counterpartName: extra['counterpartName'] as String?,
|
||||||
|
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()?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final uuid = state.uri.queryParameters['uuid'];
|
||||||
|
if (uuid != null) {
|
||||||
|
return VideoCallScreen(conversationUuid: uuid);
|
||||||
|
}
|
||||||
|
throw ArgumentError('VideoCallScreen: pass extra Map with conversationUuid or query uuid');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Consultant routes
|
// 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(
|
GoRoute(
|
||||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||||
name: 'stepperBecomeConsultantScreen',
|
name: 'stepperBecomeConsultantScreen',
|
||||||
builder: (context, state) => StepperBecomeConsultantScreen(slug: state.extra == null ? null : state.extra as String),
|
builder: (context, state) {
|
||||||
|
final data = state.extra as Map<String, dynamic>;
|
||||||
|
final slug = data['slug'] as String?;
|
||||||
|
final isCanceled = data['isCanceled'] as bool?;
|
||||||
|
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()),
|
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||||
GoRoute(path: RouteConstants.supportScreen, name: 'supportScreen', builder: (context, state) => const SupportScreen()),
|
GoRoute(path: RouteConstants.supportScreen, name: 'supportScreen', builder: (context, state) => const SupportScreen()),
|
||||||
@@ -262,6 +328,11 @@ class OtherRoutes {
|
|||||||
path: InfoByOrderScreen.path,
|
path: InfoByOrderScreen.path,
|
||||||
name: InfoByOrderScreen.name,
|
name: InfoByOrderScreen.name,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
final queryParams = state.uri.queryParameters;
|
||||||
|
final planIdFromQuery = int.tryParse(queryParams['planId'] ?? '');
|
||||||
|
if (planIdFromQuery != null) {
|
||||||
|
return InfoByOrderScreen(planId: planIdFromQuery, successfulPayment: null, customerSlug: null, isConsultantView: true);
|
||||||
|
}
|
||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
final planId = data['planId'] as int;
|
final planId = data['planId'] as int;
|
||||||
final successfulPayment = data['successfulPayment'] as bool?;
|
final successfulPayment = data['successfulPayment'] as bool?;
|
||||||
@@ -296,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class RouteConstants {
|
|||||||
static const String photoViewScreen = '/photo-view-screen';
|
static const String photoViewScreen = '/photo-view-screen';
|
||||||
static const String chatScreen = '/chat-screen';
|
static const String chatScreen = '/chat-screen';
|
||||||
static const String emptyChatScreen = '/empty-chat-screen';
|
static const String emptyChatScreen = '/empty-chat-screen';
|
||||||
|
static const String videoCallScreen = '/video-call-screen';
|
||||||
static const String glossaryScreen = '/glossary-screen';
|
static const String glossaryScreen = '/glossary-screen';
|
||||||
static const String blockedUsersScreen = '/blocked-users-screen';
|
static const String blockedUsersScreen = '/blocked-users-screen';
|
||||||
static const String notificationsSettingsScreen = '/notifications-settings-screen';
|
static const String notificationsSettingsScreen = '/notifications-settings-screen';
|
||||||
|
|||||||
122
lib/core/session/session_lifecycle_service.dart
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/app_router.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
|
import 'package:eatvio_app/core/firebase_api.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/routing/route_constants.dart';
|
||||||
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
|
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||||
|
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_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/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
enum SessionEndReason { logout, unauthorized, accountDeleted }
|
||||||
|
|
||||||
|
class SessionLifecycleService {
|
||||||
|
SessionLifecycleService({
|
||||||
|
required WsManager wsManager,
|
||||||
|
required ErrorReporter errorReporter,
|
||||||
|
}) : _wsManager = wsManager,
|
||||||
|
_errorReporter = errorReporter;
|
||||||
|
|
||||||
|
final WsManager _wsManager;
|
||||||
|
final ErrorReporter _errorReporter;
|
||||||
|
|
||||||
|
int? _authenticatedFingerprint;
|
||||||
|
bool _endingSession = false;
|
||||||
|
|
||||||
|
Future<void> endSession({required SessionEndReason reason}) async {
|
||||||
|
if (_endingSession) return;
|
||||||
|
_endingSession = true;
|
||||||
|
try {
|
||||||
|
await _run(() => _wsManager.clear());
|
||||||
|
await _run(TokenStorage.removeTokenInStorage);
|
||||||
|
await _run(SocketIdStorage.removeSocketIdInStorage);
|
||||||
|
await _run(() => sl<ProfileAvatarCubit>().reset());
|
||||||
|
await _run(() => sl<ChatsCubit>().reset());
|
||||||
|
await _run(() => sl<NotificationsCubit>().reset());
|
||||||
|
await _run(() => sl<NewMessagesCubit>().reset());
|
||||||
|
await _run(() => sl<FeedCubit>().reset());
|
||||||
|
await _run(() async => ProfileCubit.clearMyProfileReference());
|
||||||
|
await _run(() => sl<VideoCallSessionCubit>().endCall());
|
||||||
|
await _run(() async => AppMetrica.setUserProfileID(null));
|
||||||
|
await _run(
|
||||||
|
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
|
||||||
|
);
|
||||||
|
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
|
||||||
|
_authenticatedFingerprint = null;
|
||||||
|
await _run(() async {
|
||||||
|
final context = AppRouter.navigatorKey.currentContext;
|
||||||
|
if (context == null) return;
|
||||||
|
final path =
|
||||||
|
AppRouter.appRouter.routeInformationProvider.value.uri.path;
|
||||||
|
if (path != RouteConstants.authScreen) {
|
||||||
|
context.go(RouteConstants.authScreen);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
_endingSession = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onAuthenticated() async {
|
||||||
|
String? token;
|
||||||
|
try {
|
||||||
|
token = await TokenStorage.getTokenFromStorage();
|
||||||
|
} catch (e, st) {
|
||||||
|
await _report(e, st);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (token == null || token.isEmpty) return;
|
||||||
|
final fingerprint = token.hashCode;
|
||||||
|
if (_authenticatedFingerprint == fingerprint) return;
|
||||||
|
_authenticatedFingerprint = fingerprint;
|
||||||
|
|
||||||
|
await _run(() async {
|
||||||
|
await _wsManager.clear();
|
||||||
|
await _wsManager.initialize();
|
||||||
|
});
|
||||||
|
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
|
||||||
|
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
|
||||||
|
await _run(() => sl<NotificationsCubit>().getNotifications());
|
||||||
|
await _run(() => sl<FirebaseApi>().registerPushToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> logout() async {
|
||||||
|
try {
|
||||||
|
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
|
||||||
|
await _run(
|
||||||
|
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await endSession(reason: SessionEndReason.logout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run(FutureOr<void> Function() action) async {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} catch (e, st) {
|
||||||
|
await _report(e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _report(Object error, StackTrace stackTrace) {
|
||||||
|
return _errorReporter.report(
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
origin: 'SessionLifecycleService',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,48 @@ class CustomTextStyles {
|
|||||||
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
|
||||||
|
|
||||||
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 32 / 24,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
fontSize: 16,
|
||||||
|
height: 24 / 16,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 24 / 13,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: appTheme.black900,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 24 / 14,
|
||||||
|
color: appTheme.gray600,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
height: 1.35,
|
||||||
|
color: appTheme.red,
|
||||||
|
);
|
||||||
|
|
||||||
|
static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on TextStyle {
|
extension on TextStyle {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
|
||||||
/// Helper class for managing themes and colors.
|
/// Helper class for managing themes and colors.
|
||||||
class ThemeHelper {
|
class ThemeHelper {
|
||||||
// The current app theme
|
// The current app theme
|
||||||
@@ -200,6 +199,12 @@ class PrimaryColors {
|
|||||||
Color get grey7B => const Color(0xFF7B7B7B);
|
Color get grey7B => const Color(0xFF7B7B7B);
|
||||||
|
|
||||||
Color get greyD9 => const Color(0xFFD9D9D9);
|
Color get greyD9 => const Color(0xFFD9D9D9);
|
||||||
|
|
||||||
|
Color get videoCallControlOffBackground => const Color(0xFFED2F2F);
|
||||||
|
|
||||||
|
Color get videoCallPipIconBackground => const Color(0xFF565656);
|
||||||
|
|
||||||
|
Color get videoCallNoticeBannerBackground => const Color(0xFF9D9D9D);
|
||||||
}
|
}
|
||||||
|
|
||||||
PrimaryColors get appTheme => ThemeHelper().themeColor();
|
PrimaryColors get appTheme => ThemeHelper().themeColor();
|
||||||
|
|||||||
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
@@ -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';
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/core/views/cubit/views_state.dart';
|
import 'package:eatvio_app/core/views/cubit/views_state.dart';
|
||||||
import 'package:eatvio_app/core/views/repository/views_repository.dart';
|
import 'package:eatvio_app/core/views/repository/views_repository.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
@@ -86,9 +88,13 @@ class ViewsCubit extends Cubit<ViewsCubitState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _viewContent({required ViewableContentType type, required List<int> ids}) async {
|
Future<void> _viewContent({required ViewableContentType type, required List<int> ids}) async {
|
||||||
await viewsRepository.viewContent(
|
try {
|
||||||
viewModel: ViewModel(type: type.getStringifiedVersion(), ids: ids),
|
await viewsRepository.viewContent(
|
||||||
);
|
viewModel: ViewModel(type: type.getStringifiedVersion(), ids: ids),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scheduleDebouncedSend() {
|
void _scheduleDebouncedSend() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:eatvio_app/core/views/service/views_service.dart';
|
import 'package:eatvio_app/core/views/service/views_service.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
|
||||||
|
|
||||||
class ViewsRepository {
|
class ViewsRepository {
|
||||||
final ViewsService viewsService;
|
final ViewsService viewsService;
|
||||||
@@ -8,11 +7,7 @@ class ViewsRepository {
|
|||||||
ViewsRepository({required this.viewsService});
|
ViewsRepository({required this.viewsService});
|
||||||
|
|
||||||
Future<void> viewContent({required ViewModel viewModel}) async {
|
Future<void> viewContent({required ViewModel viewModel}) async {
|
||||||
try {
|
await viewsService.viewContent(viewModel: viewModel);
|
||||||
await viewsService.viewContent(viewModel: viewModel);
|
|
||||||
} catch (e, s) {
|
|
||||||
await Sentry.captureException(e, stackTrace: s);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,14 +9,16 @@ import 'package:eatvio_app/core/const.dart';
|
|||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/websockets/ws_service.dart';
|
import 'package:eatvio_app/core/websockets/ws_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||||
|
|
||||||
class WsManager {
|
class WsManager {
|
||||||
final WsService wsService;
|
final WsService wsService;
|
||||||
|
final ErrorReporter _errorReporter;
|
||||||
|
|
||||||
WsManager({required this.wsService});
|
WsManager({required this.wsService, required ErrorReporter errorReporter})
|
||||||
|
: _errorReporter = errorReporter;
|
||||||
|
|
||||||
PusherChannelsClient? client;
|
PusherChannelsClient? client;
|
||||||
int? userId;
|
int? userId;
|
||||||
@@ -24,20 +26,65 @@ class WsManager {
|
|||||||
PrivateChannel? _privateNotificationChannel;
|
PrivateChannel? _privateNotificationChannel;
|
||||||
PrivateChannel? _privateMessagesChannel;
|
PrivateChannel? _privateMessagesChannel;
|
||||||
PrivateChannel? _privateDirectChatChannel;
|
PrivateChannel? _privateDirectChatChannel;
|
||||||
|
PresenceChannel? _presenceDirectChatChannel;
|
||||||
|
|
||||||
|
StreamSubscription? _presenceMemberAddedSubscription;
|
||||||
|
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||||
|
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||||
|
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||||
|
bool _presenceChannelEventsBound = false;
|
||||||
|
|
||||||
StreamSubscription? _lifecycleSubscription;
|
StreamSubscription? _lifecycleSubscription;
|
||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
StreamSubscription? _connectionEstablishedSubscription;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||||
|
///
|
||||||
|
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||||
|
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||||
|
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||||
|
StreamController<void> _reconnectController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream =>
|
||||||
|
_privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
/// The callee rejected the ring. Broadcast to the caller and the callee.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-declined');
|
||||||
|
|
||||||
|
/// The call was cancelled or hung up. Same channel the web client uses.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallEndedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-ended');
|
||||||
|
|
||||||
|
/// Another device accepted the ring. Ringing on this device must stop,
|
||||||
|
/// except on the device that accepted.
|
||||||
|
Stream<ChannelReadEvent>? get videoCallAcceptedStream =>
|
||||||
|
_privateMessagesChannel?.bind('video-call-accepted');
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get newNotificationsStream =>
|
||||||
|
_privateNotificationChannel?.bind(
|
||||||
|
'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated',
|
||||||
|
);
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||||
|
|
||||||
|
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||||
|
///
|
||||||
|
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||||
|
/// сообщения (S5).
|
||||||
|
Stream<void> get reconnected => _reconnectController.stream;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
if (_reconnectController.isClosed) {
|
||||||
|
_reconnectController = StreamController<void>.broadcast();
|
||||||
|
}
|
||||||
|
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
try {
|
try {
|
||||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||||
@@ -57,15 +104,9 @@ class WsManager {
|
|||||||
connectionErrorHandler: (exception, trace, refresh) {
|
connectionErrorHandler: (exception, trace, refresh) {
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
minimumReconnectDelayDuration: const Duration(
|
minimumReconnectDelayDuration: const Duration(seconds: 1),
|
||||||
seconds: 1,
|
defaultActivityDuration: const Duration(seconds: 30),
|
||||||
),
|
waitForPongDuration: const Duration(seconds: 30),
|
||||||
defaultActivityDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
waitForPongDuration: const Duration(
|
|
||||||
seconds: 30,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String? extractSocketId(String? data) {
|
String? extractSocketId(String? data) {
|
||||||
@@ -78,7 +119,9 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
|
_connectionEstablishedSubscription = client?.eventStream.listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
if (event.name == 'pusher:connection_established') {
|
if (event.name == 'pusher:connection_established') {
|
||||||
final data = event.data;
|
final data = event.data;
|
||||||
final socketId = extractSocketId(data);
|
final socketId = extractSocketId(data);
|
||||||
@@ -95,9 +138,9 @@ class WsManager {
|
|||||||
await _initPrivateMessagesChannel();
|
await _initPrivateMessagesChannel();
|
||||||
|
|
||||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data ==
|
||||||
_unsubscribe();
|
PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_subscribe();
|
handleConnectionEstablished();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -114,7 +157,7 @@ class WsManager {
|
|||||||
_subscribe();
|
_subscribe();
|
||||||
await _connectCubitsToStreams();
|
await _connectCubitsToStreams();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
await _errorReporter.report(e, st, origin: 'WsManager');
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +173,7 @@ class WsManager {
|
|||||||
await sl<NotificationsCubit>().connectToWebSocket();
|
await sl<NotificationsCubit>().connectToWebSocket();
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
log('WsManager._connectCubitsToStreams: $e', stackTrace: st);
|
log('WsManager._connectCubitsToStreams: $e', stackTrace: st);
|
||||||
await Sentry.captureException(e, stackTrace: st);
|
await _errorReporter.report(e, st, origin: 'WsManager');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +181,13 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateNotificationChannel = client?.privateChannel(
|
_privateNotificationChannel = client?.privateChannel(
|
||||||
'$newNotificationsChannelName.$userId',
|
'$newNotificationsChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,26 +195,145 @@ class WsManager {
|
|||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateMessagesChannel = client?.privateChannel(
|
_privateMessagesChannel = client?.privateChannel(
|
||||||
'$newMessagesChannelName.$userId',
|
'$newMessagesChannelName.$userId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
),
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
authorizationDelegate:
|
||||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
EndpointAuthorizableChannelTokenAuthorizationDelegate.forPrivateChannel(
|
||||||
headers: {
|
authorizationEndpoint: Uri.parse(
|
||||||
'Authorization': 'Bearer $token',
|
'${sl<AppConfig>().baseUrl}/broadcasting/auth',
|
||||||
},
|
),
|
||||||
|
headers: {'Authorization': 'Bearer $token'},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_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() {
|
void _subscribe() {
|
||||||
@@ -183,12 +346,40 @@ class WsManager {
|
|||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||||
|
///
|
||||||
|
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||||
|
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||||
|
/// Pusher-клиента.
|
||||||
|
void handleConnectionEstablished() {
|
||||||
|
_unsubscribe();
|
||||||
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
|
_reconnectController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
void subscribeDirectChatChannel() {
|
void subscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.subscribe();
|
_privateDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_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 {
|
Future<void> clear() async {
|
||||||
@@ -202,10 +393,23 @@ class WsManager {
|
|||||||
client = null;
|
client = null;
|
||||||
userId = null;
|
userId = null;
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
unsubscribePresenceDirectChatChannel();
|
||||||
_privateMessagesChannel?.unsubscribe();
|
_privateMessagesChannel?.unsubscribe();
|
||||||
_privateNotificationChannel?.unsubscribe();
|
_privateNotificationChannel?.unsubscribe();
|
||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = 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/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/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/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/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_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/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/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:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class CustomPopup extends StatelessWidget {
|
class CustomPopup extends StatelessWidget {
|
||||||
final bool isAuthor;
|
final bool isAuthor;
|
||||||
|
final bool isAuthorAdmin;
|
||||||
final ComplainedContent? complainedContentType;
|
final ComplainedContent? complainedContentType;
|
||||||
final String path;
|
final String path;
|
||||||
final String? slug;
|
final String? slug;
|
||||||
@@ -29,11 +41,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final FeedBlogPostModel? feedBlogPostModel;
|
final FeedBlogPostModel? feedBlogPostModel;
|
||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
|
final PlanModel? plan;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
required this.isAuthor,
|
required this.isAuthor,
|
||||||
|
this.isAuthorAdmin = false,
|
||||||
this.complainedContentType,
|
this.complainedContentType,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.id,
|
this.id,
|
||||||
@@ -41,9 +56,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final s = S.of(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>[];
|
final entries = <PopupMenuEntry>[];
|
||||||
|
|
||||||
void addItem(String icon, String text, Function() onTap) {
|
void addItem(String icon, String text, Function() onTap) {
|
||||||
@@ -100,11 +216,9 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (_showsEditAction()) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
_openEditor(context);
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
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);
|
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSuccessToast('Пост удален, обновите ленту');
|
onPostsChanged?.call();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
|
if (context.mounted) {
|
||||||
|
try {
|
||||||
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
showSuccessToast('Пост удален');
|
||||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||||
if (!result && context.mounted) {
|
if (!result && context.mounted) {
|
||||||
@@ -154,19 +275,21 @@ class CustomPopup extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
if (!_isAdminAuthor) {
|
||||||
if (complainedContentType != null) {
|
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||||
showModalBottomSheet(
|
if (complainedContentType != null) {
|
||||||
context: context,
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
builder: (context) => ComplainingBottomSheet(
|
isScrollControlled: true,
|
||||||
complainedContentType: complainedContentType!,
|
builder: (context) => ComplainingBottomSheet(
|
||||||
id: id,
|
complainedContentType: complainedContentType!,
|
||||||
slug: slug,
|
id: id,
|
||||||
),
|
slug: slug,
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
addItem(
|
addItem(
|
||||||
'assets/icons/complain.svg',
|
'assets/icons/complain.svg',
|
||||||
'Заблокировать автора',
|
'Заблокировать автора',
|
||||||
|
|||||||
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) {
|
if (isDrafted == true) {
|
||||||
text = 'cохранена в черновиках';
|
text = 'cохранена в черновиках';
|
||||||
} else if (isUpdated == true) {
|
} else if (isUpdated == true) {
|
||||||
text = 'успешно обновлена';
|
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||||
} else if (isPlan == true) {
|
} else if (isPlan == true) {
|
||||||
text = 'опубликован';
|
text = 'опубликован';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import 'dart:developer';
|
|||||||
import 'package:eatvio_app/core/firebase_api.dart';
|
import 'package:eatvio_app/core/firebase_api.dart';
|
||||||
import 'package:eatvio_app/core/prefs.dart';
|
import 'package:eatvio_app/core/prefs.dart';
|
||||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||||
|
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||||
import 'package:eatvio_app/core/token_storage.dart';
|
import 'package:eatvio_app/core/token_storage.dart';
|
||||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
||||||
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
|
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
|
||||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||||
|
|
||||||
class AuthCubit extends Cubit<AuthState> {
|
class AuthCubit extends Cubit<AuthState> {
|
||||||
@@ -16,17 +18,29 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
final ProfileRepository profileRepository;
|
final ProfileRepository profileRepository;
|
||||||
final FirebaseApi firebaseApi;
|
final FirebaseApi firebaseApi;
|
||||||
final Prefs prefs;
|
final Prefs prefs;
|
||||||
|
final SessionLifecycleService sessionLifecycleService;
|
||||||
|
|
||||||
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
|
AuthCubit({
|
||||||
|
required this.authRepository,
|
||||||
|
required this.prefs,
|
||||||
|
required this.profileRepository,
|
||||||
|
required this.firebaseApi,
|
||||||
|
required this.sessionLifecycleService,
|
||||||
|
}) : super(const AuthState());
|
||||||
|
|
||||||
Future<bool> getTokenByCredentials(String email, String password) async {
|
Future<bool> getTokenByCredentials(String email, String password) async {
|
||||||
emit(const AuthState.loading());
|
emit(const AuthState.loading());
|
||||||
try {
|
try {
|
||||||
final token = (await authRepository.getTokenByCredentials(email, password)).token;
|
final token = (await authRepository.getTokenByCredentials(
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
)).token;
|
||||||
emit(AuthState.loaded(token: token));
|
emit(AuthState.loaded(token: token));
|
||||||
await TokenStorage.saveTokenInStorage(token);
|
await TokenStorage.saveTokenInStorage(token);
|
||||||
|
await sessionLifecycleService.onAuthenticated();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
showErrorToast('Неверный адрес электронной почты или пароль');
|
showErrorToast('Неверный адрес электронной почты или пароль');
|
||||||
emit(AuthState.error(errorMessage: '$e'));
|
emit(AuthState.error(errorMessage: '$e'));
|
||||||
@@ -38,21 +52,30 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
try {
|
try {
|
||||||
await registerAnonymousPushToken();
|
await registerAnonymousPushToken();
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
} catch (e) {
|
} catch (e, st) {
|
||||||
log('Error in initializeUser: $e');
|
log('Error in initializeUser: $e', stackTrace: st);
|
||||||
FlutterNativeSplash.remove();
|
FlutterNativeSplash.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerAnonymousPushToken() async {
|
Future<void> registerAnonymousPushToken() async {
|
||||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
try {
|
||||||
if (!hasAnonymousTokenBeenSent) {
|
final hasAnonymousTokenBeenSent =
|
||||||
await firebaseApi.registerAnonymousPushToken();
|
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
if (!hasAnonymousTokenBeenSent) {
|
||||||
|
await firebaseApi.registerAnonymousPushToken();
|
||||||
|
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerPushToken() async {
|
Future<void> registerPushToken() async {
|
||||||
await firebaseApi.registerPushToken();
|
try {
|
||||||
|
await firebaseApi.registerPushToken();
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||