forked from eatvio/eatvio_app
Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3892bf00aa | ||
|
|
89268e1132 | ||
| 60753a13d2 | |||
|
|
08eeea6ba4 | ||
|
|
58d99424a0 | ||
|
|
c6cdb5bf01 | ||
|
|
50deaeddd1 | ||
|
|
56fb2f1bac | ||
|
|
24185cc875 | ||
|
|
9e7bf258d0 | ||
|
|
098633b3ac | ||
|
|
c3aa77b02d | ||
|
|
2d5384f2ec | ||
|
|
1c7e494f90 | ||
|
|
8bbe2613d2 | ||
|
|
5f6ceab6a1 | ||
|
|
250a3f5d73 | ||
|
|
3e0d8b6c27 | ||
|
|
9ed3b37272 | ||
|
|
ae628a6ecc | ||
|
|
88d8ec52d9 | ||
|
|
937921e26f | ||
|
|
d324bd6540 | ||
|
|
5766e216c8 | ||
|
|
ee9b97b694 | ||
|
|
31eec6c9d7 | ||
|
|
5fae3eb5e8 | ||
|
|
b0a167b9e9 | ||
|
|
0b2e026d1d | ||
|
|
0641513ebe | ||
|
|
8b1d08b2aa | ||
|
|
5167766dbf | ||
| cc79e40df9 | |||
|
|
cdc5936d3c | ||
|
|
67e770f5d0 | ||
|
|
6f666a8859 | ||
|
|
c540cbe6df | ||
|
|
6c4104e9ea | ||
|
|
6d1bcb85fb | ||
|
|
9753644570 | ||
|
|
75983818d1 | ||
|
|
5b149e2fd2 | ||
|
|
699148b202 | ||
|
|
6e7894b911 | ||
|
|
d4f41fe6cf | ||
|
|
bb97c395dd | ||
|
|
b7804db49d | ||
|
|
e90a466a5b | ||
|
|
5f30aaeb2a | ||
|
|
516dfa4158 | ||
|
|
ae07851396 | ||
|
|
34eccb7db5 | ||
|
|
042cc0ea20 | ||
|
|
f81aebe9d8 | ||
|
|
d70aaf955f | ||
|
|
c91e83adaf | ||
|
|
531f732b1a | ||
|
|
d10f2f1356 | ||
|
|
bf110b70c1 | ||
|
|
97418e11d4 | ||
|
|
225077907f | ||
|
|
296adc8229 | ||
|
|
7dd7e3b488 | ||
|
|
a496edaf4c | ||
|
|
400cc004ef | ||
|
|
beda152df0 | ||
|
|
42ffde820d | ||
|
|
adb7b0bd74 | ||
|
|
a768eefed0 | ||
|
|
0ac50d6d18 | ||
|
|
a0d8fda50d | ||
|
|
1d286fa8d9 | ||
|
|
c2a5e7305e | ||
|
|
bbb1b28692 | ||
|
|
21a65caea9 | ||
|
|
eac366e9ae | ||
|
|
278f4d7e10 | ||
|
|
ac5504a514 | ||
|
|
843e1ce4dd | ||
|
|
db6db89567 | ||
|
|
044d86e710 | ||
|
|
4ff880e079 | ||
|
|
05b9ca17c7 | ||
|
|
a1c0c2694b | ||
|
|
2d7407ce99 | ||
| 62fe5ebfa0 | |||
|
|
3dc36ab52d | ||
| 190e212eb4 | |||
|
|
cf3d1a945c | ||
|
|
c54915f45d | ||
|
|
b188570882 | ||
|
|
d0794670f6 | ||
|
|
3b53ed6f7d | ||
|
|
a003814265 | ||
|
|
e99465a550 | ||
|
|
a01f3914c4 | ||
|
|
15f9149cfd | ||
|
|
79df8c5f9a | ||
|
|
a93da1b505 | ||
|
|
8144013747 | ||
|
|
7bac2a13ac | ||
|
|
29589ef6fd | ||
|
|
97a6fe57e9 | ||
|
|
8309498d9e | ||
|
|
aa098b31bc | ||
|
|
7a85c65918 | ||
|
|
6ff35fd162 | ||
|
|
6811b325be | ||
|
|
92205c09d6 | ||
| 008dba9fed | |||
|
|
55ca558bfb | ||
|
|
91499ae207 | ||
|
|
4bacdf5e76 | ||
|
|
e76c1aed20 | ||
|
|
de8f879f59 | ||
|
|
f6809a06b3 | ||
|
|
775d4fb80f | ||
|
|
64e9df69f7 | ||
|
|
6ccfcdedda | ||
|
|
d3c8034b8f | ||
|
|
44ede73ceb | ||
| 2c63990afc | |||
|
|
eb69b253bc | ||
|
|
f26886a7c5 | ||
|
|
13ccf56df0 | ||
|
|
c0032f7a95 | ||
|
|
4852d2cf10 | ||
|
|
41e96c63c6 | ||
|
|
c786821275 | ||
|
|
7b425e56f1 | ||
|
|
bf2ba1fbda | ||
|
|
d206e7ec12 | ||
|
|
a778257215 | ||
|
|
497ff19cfa | ||
|
|
bb59552e3e | ||
|
|
55b2bb0436 | ||
|
|
90746d0be7 | ||
|
|
d70d172c69 | ||
|
|
4e23992d22 | ||
|
|
7a846ed218 | ||
|
|
fcc2936c22 | ||
|
|
b11cf2c0b0 | ||
|
|
0ead14bca5 | ||
|
|
08e56d9777 | ||
|
|
99659f7ea1 | ||
|
|
e158d812e4 | ||
|
|
3d774267ca | ||
|
|
aeb89a9aba | ||
|
|
a4f5bfdb4d | ||
|
|
5b705dfb72 | ||
|
|
a792dcd163 | ||
|
|
9c7ca14017 | ||
|
|
38118055a5 | ||
|
|
5d6e25858b | ||
|
|
42b443c3ba | ||
|
|
21f7c40cde | ||
|
|
f56092d88f | ||
|
|
2b97c0f57d | ||
|
|
a1304e4612 | ||
|
|
7b534cf891 | ||
|
|
22b2e5f249 | ||
|
|
5dfdbe4543 | ||
|
|
5c9a9c22ba | ||
|
|
4f920359c8 | ||
|
|
2ab3bc1cf0 | ||
|
|
91bd8f9d68 | ||
| 9c6f6bdfb1 | |||
|
|
fe1e81e455 | ||
|
|
b7e064a560 | ||
|
|
f1b7a24044 | ||
|
|
cb441b4027 | ||
| 7282fea59a | |||
|
|
4adec4dbbc | ||
|
|
d5b0e972a5 | ||
|
|
391efeb8dd | ||
| d115af6f8e | |||
|
|
2408443a26 | ||
|
|
c378c7fc16 | ||
|
|
070a3e39e7 | ||
|
|
d1753e99f8 | ||
|
|
960afbd6ed | ||
|
|
70e4a42e98 | ||
|
|
04061cb4c1 | ||
|
|
edd064d20d | ||
|
|
cdb7378265 | ||
|
|
746d366c18 | ||
|
|
c2a506cdbf | ||
|
|
abe4985f0e | ||
|
|
ca06b2bd0f | ||
|
|
04c932646d | ||
|
|
3d4363185b | ||
|
|
953230b387 | ||
|
|
9d845e1264 | ||
|
|
32de60742e | ||
|
|
3923b61c67 | ||
|
|
0cd8e662d9 | ||
| 89ea73fc59 | |||
|
|
9e33063c30 | ||
|
|
f3d4478c65 | ||
| 673aa86370 | |||
|
|
1738d27b82 | ||
|
|
d0becb9e11 | ||
|
|
965ae2f02b | ||
|
|
9fcfac87d4 | ||
|
|
2a25be5aa4 | ||
| b420d11996 | |||
|
|
cbeb91a9f0 | ||
|
|
9ca09e8137 | ||
|
|
8917f54e7b | ||
|
|
626f74d20d | ||
|
|
7df32dabd2 | ||
|
|
2e57cd9519 | ||
|
|
58bb766721 | ||
|
|
0be7392a39 | ||
|
|
4deda92c5e | ||
| e5029f581d | |||
| fc0e9b1063 | |||
|
|
6fa944b3df | ||
|
|
c31411c4a2 | ||
|
|
6214d19a14 | ||
|
|
db5dddff86 |
@@ -3,19 +3,19 @@ name: Eatvio App Android Test
|
||||
on: [ pull_request ]
|
||||
|
||||
env:
|
||||
flutter_version: "3.32.0"
|
||||
flutter_version: "3.41.2"
|
||||
java_version: "21.x"
|
||||
flutter_channel: "stable"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [ dev-office ]
|
||||
runs-on: [ ubuntu-latest ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: https://github.com/actions/checkout@v3
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: https://github.com/actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: ${{ env.java_version }}
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y openjdk-11-jdk wget
|
||||
apt-get install -y openjdk-11-jdk wget xz-utils tar
|
||||
wget https://dl.google.com/android/repository/commandlinetools-linux-7583922_latest.zip -O commandlinetools.zip
|
||||
unzip commandlinetools.zip
|
||||
mkdir -p $HOME/Android/Sdk/cmdline-tools/latest
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
apt-get install -y jq
|
||||
|
||||
- id: flutter-action
|
||||
uses: actions/flutter-action@v2
|
||||
uses: https://github.com/subosito/flutter-action@v2
|
||||
with:
|
||||
channel: ${{ env.flutter_channel }}
|
||||
flutter-version: ${{ env.flutter_version }}
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: https://github.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: ${{ env.APK_NAME }}
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Eatvio App Android Test on custom docker
|
||||
on: [ pull_request ]
|
||||
|
||||
env:
|
||||
flutter_version: "3.32.0"
|
||||
flutter_version: "3.41.2"
|
||||
java_version: "21.x"
|
||||
flutter_channel: "stable"
|
||||
my_local_cache_dir: "/opt/hostedtoolcache/"
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: [ dev-office-flutter ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: https://github.com/actions/checkout@v3
|
||||
|
||||
- name: Set up Android environment variables
|
||||
id: "android-env-setup"
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
# echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
|
||||
|
||||
# - name: Upload APK artifact
|
||||
# uses: actions/upload-artifact@v3
|
||||
# uses: https://github.com/actions/upload-artifact@v3
|
||||
# with:
|
||||
# name: ${{ env.APK_NAME }}
|
||||
# path: ${{ env.APK_NAME }}
|
||||
|
||||
@@ -9,10 +9,10 @@ jobs:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
- name: Checkout env repository
|
||||
uses: actions/checkout@v2
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
with:
|
||||
repository: mso7nueh/eatvio_app_env
|
||||
path: env_repo
|
||||
@@ -54,11 +54,13 @@ jobs:
|
||||
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter upgrade
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub upgrade
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
# Configure Flutter for release mode
|
||||
pod repo update
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod update
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Eatvio App Android Test
|
||||
on: [ pull_request ]
|
||||
|
||||
env:
|
||||
flutter_version: "3.35.1"
|
||||
flutter_version: "3.41.2"
|
||||
java_version: "21.x"
|
||||
flutter_channel: "stable"
|
||||
my_local_cache_dir: "/opt/hostedtoolcache/"
|
||||
@@ -11,13 +11,23 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [ dev-office ]
|
||||
runs-on: [ ubuntu-latest ]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: https://github.com/actions/checkout@v3
|
||||
|
||||
- name: Set proxy server
|
||||
env:
|
||||
PROXY_USER: ${{ secrets.PROXY_USER }}
|
||||
PROXY_PASSWORD: ${{ secrets.PROXY_PASSWORD }}
|
||||
run: |
|
||||
echo "Setting up proxy..."
|
||||
cat <<EOF >/etc/apt/apt.conf.d/proxy.conf
|
||||
Acquire::http::Proxy "socks5h://${PROXY_USER}:${PROXY_PASSWORD}@10.55.5.202:19843/";
|
||||
EOF
|
||||
|
||||
- name: Checkout env repository
|
||||
uses: actions/checkout@v3
|
||||
uses: https://github.com/actions/checkout@v3
|
||||
with:
|
||||
repository: mso7nueh/eatvio_app_env
|
||||
path: env_repo
|
||||
@@ -56,13 +66,13 @@ jobs:
|
||||
|
||||
- name: Cache Android SDK
|
||||
id: cache-sdk
|
||||
uses: "https://gitea.com/actions/cache@v4"
|
||||
uses: "https://github.com/actions/cache@v4"
|
||||
with:
|
||||
path: ${{ env.my_local_cache_dir }}
|
||||
key: ${{ runner.os }}-cache-sdk
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: https://github.com/actions/setup-java@v3
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: ${{ env.java_version }}
|
||||
@@ -71,7 +81,7 @@ jobs:
|
||||
- name: Install utils for Android SDK
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y openjdk-11-jdk wget jq clang cmake ninja-build libgtk-3-dev
|
||||
apt-get install -y openjdk-17-jdk wget jq clang cmake ninja-build libgtk-3-dev
|
||||
|
||||
- name: Install Android SDK
|
||||
if: steps.cache-sdk.outputs.cache-hit != 'true'
|
||||
@@ -99,16 +109,23 @@ jobs:
|
||||
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
|
||||
|
||||
- id: flutter-action
|
||||
uses: actions/flutter-action@v2
|
||||
with:
|
||||
channel: ${{ env.flutter_channel }}
|
||||
flutter-version: ${{ env.flutter_version }}
|
||||
cache: true
|
||||
cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache
|
||||
cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path
|
||||
pub-cache-key: "flutter-pub:os:-:channel:-:version:-:arch:-:hash:" # optional, change this to force refresh cache of dart pub get dependencies
|
||||
pub-cache-path: "${{ runner.tool_cache }}/flutter/:channel:-:version:-:arch:" # optional, change this to specify the cache path
|
||||
|
||||
- name: Set up Flutter
|
||||
run: |
|
||||
FLUTTER_DIR="${{ env.my_local_cache_dir }}flutter"
|
||||
if [ ! -f "$FLUTTER_DIR/bin/flutter" ]; then
|
||||
echo "Downloading Flutter ${{ env.flutter_version }}..."
|
||||
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/${{ env.flutter_channel }}/linux/flutter_linux_${{ env.flutter_version }}-${{ env.flutter_channel }}.tar.xz"
|
||||
echo "Extracting Flutter..."
|
||||
mkdir -p "${{ env.my_local_cache_dir }}"
|
||||
tar xf /tmp/flutter.tar.xz -C "${{ env.my_local_cache_dir }}"
|
||||
rm /tmp/flutter.tar.xz
|
||||
else
|
||||
echo "Flutter already cached at $FLUTTER_DIR"
|
||||
fi
|
||||
echo "$FLUTTER_DIR/bin" >> $GITHUB_PATH
|
||||
echo "$FLUTTER_DIR/bin/cache/dart-sdk/bin" >> $GITHUB_PATH
|
||||
echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH
|
||||
|
||||
# - name: Print flutter-action outputs
|
||||
# shell: bash
|
||||
@@ -126,7 +143,9 @@ jobs:
|
||||
|
||||
- name: Fix dir permissions
|
||||
run: |
|
||||
git config --global --add safe.directory /opt/hostedtoolcache/flutter/${{ env.flutter_channel }}-${{ env.flutter_version }}-${{ steps.flutter-action.outputs.ARCHITECTURE }}
|
||||
ARCH=$(uname -m)
|
||||
if [ "$ARCH" = "x86_64" ]; then ARCH="x64"; fi
|
||||
git config --global --add safe.directory "${{ env.my_local_cache_dir }}flutter"
|
||||
flutter config --android-sdk="$ANDROID_HOME"
|
||||
flutter doctor -v
|
||||
|
||||
@@ -144,14 +163,62 @@ jobs:
|
||||
|
||||
- name: Test coverage
|
||||
run: |
|
||||
echo "📊 Analyzing test coverage..."
|
||||
echo ""
|
||||
awk -F: '
|
||||
/^LF:/ { total += $2 }
|
||||
/^LH:/ { covered += $2 }
|
||||
END {
|
||||
if (total == 0) {
|
||||
print "Coverage: 0%"
|
||||
/^SF:/ {
|
||||
file = $0
|
||||
sub(/^SF:/, "", file)
|
||||
sub(/^[[:space:]]+/, "", file)
|
||||
is_generated = 0
|
||||
if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else if (file ~ /\/(build|\.dart_tool)\//) {
|
||||
is_generated = 1
|
||||
generated_files++
|
||||
} else {
|
||||
printf "Coverage: %.2f%%\n", (covered / total) * 100
|
||||
source_files++
|
||||
}
|
||||
}
|
||||
/^LF:/ {
|
||||
if (!is_generated) {
|
||||
total += $2
|
||||
} else {
|
||||
generated_lines += $2
|
||||
}
|
||||
}
|
||||
/^LH:/ {
|
||||
if (!is_generated) {
|
||||
covered += $2
|
||||
} else {
|
||||
generated_covered += $2
|
||||
}
|
||||
}
|
||||
END {
|
||||
print "📁 Files:"
|
||||
printf " Source files: %d\n", source_files
|
||||
printf " Generated files: %d (excluded)\n", generated_files
|
||||
print ""
|
||||
print "📝 Lines:"
|
||||
printf " Total lines: %d\n", total
|
||||
printf " Covered lines: %d\n", covered
|
||||
printf " Uncovered lines: %d\n", total - covered
|
||||
print ""
|
||||
if (total == 0) {
|
||||
print "⚠️ Coverage: 0%"
|
||||
exit 1
|
||||
} else {
|
||||
percentage = (covered / total) * 100
|
||||
printf "✅ Coverage: %.2f%%\n", percentage
|
||||
print ""
|
||||
if (percentage < 60) {
|
||||
printf "⚠️ Warning: Coverage is below 60%%\n"
|
||||
} else if (percentage < 80) {
|
||||
printf "💛 Good: Coverage is above 60%%\n"
|
||||
} else {
|
||||
printf "💚 Excellent: Coverage is above 80%%\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
' coverage/lcov.info
|
||||
@@ -172,7 +239,7 @@ jobs:
|
||||
echo "APK_NAME=$APK_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: https://github.com/actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: ${{ env.APK_NAME }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -19,4 +19,9 @@ ios/XCBuildData/
|
||||
env/.env
|
||||
.env
|
||||
android/key.properties
|
||||
app/upload-keystore.jks
|
||||
app/upload-keystore.jks
|
||||
coverage/
|
||||
backend/
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/eatvio
|
||||
5
Makefile
5
Makefile
@@ -7,4 +7,7 @@ run-analyse:
|
||||
run-checks: run-analyse run-tests
|
||||
|
||||
build-generated-files:
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
|
||||
clean-coverage:
|
||||
@./scripts/remove_generated_from_coverage.sh
|
||||
55
analyze_coverage.py
Normal file
55
analyze_coverage.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
|
||||
def analyze_coverage():
|
||||
files = {}
|
||||
current_file = None
|
||||
|
||||
with open('coverage/lcov.info', 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if line.startswith('SF:'):
|
||||
current_file = line[3:]
|
||||
files[current_file] = {'total': 0, 'covered': 0}
|
||||
i += 1
|
||||
elif line.startswith('LF:') and current_file:
|
||||
files[current_file]['total'] = int(line[3:])
|
||||
i += 1
|
||||
elif line.startswith('LH:') and current_file:
|
||||
files[current_file]['covered'] = int(line[3:])
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Filter out generated files
|
||||
filtered = []
|
||||
for f, d in files.items():
|
||||
if not f.endswith('.g.dart') and not 'generated' in f.lower() and not f.endswith('l10n.dart'):
|
||||
filtered.append((f, d))
|
||||
|
||||
# Find files with less than 50% coverage
|
||||
low_coverage = []
|
||||
for f, d in filtered:
|
||||
if d['total'] > 0:
|
||||
pct = d['covered']/d['total']*100
|
||||
if pct < 50:
|
||||
low_coverage.append((f, d))
|
||||
|
||||
# Sort by coverage percentage (lowest first)
|
||||
sorted_files = sorted(
|
||||
low_coverage,
|
||||
key=lambda x: (x[1]['covered']/x[1]['total']*100 if x[1]['total'] > 0 else 0)
|
||||
)
|
||||
|
||||
print('Files with less than 50% coverage:')
|
||||
print('=' * 80)
|
||||
for f, d in sorted_files[:50]:
|
||||
pct = d['covered']/d['total']*100
|
||||
print(f'{pct:6.1f}% ({d["covered"]:4d}/{d["total"]:4d}) - {f}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
analyze_coverage()
|
||||
|
||||
52
analyze_coverage_by_uncovered.py
Normal file
52
analyze_coverage_by_uncovered.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
|
||||
def analyze_coverage_by_uncovered():
|
||||
files = {}
|
||||
current_file = None
|
||||
|
||||
with open('coverage/lcov.info', 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if line.startswith('SF:'):
|
||||
current_file = line[3:]
|
||||
files[current_file] = {'total': 0, 'covered': 0}
|
||||
i += 1
|
||||
elif line.startswith('LF:') and current_file:
|
||||
files[current_file]['total'] = int(line[3:])
|
||||
i += 1
|
||||
elif line.startswith('LH:') and current_file:
|
||||
files[current_file]['covered'] = int(line[3:])
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Filter out generated files and UI screens/widgets (hard to test)
|
||||
filtered = []
|
||||
exclude_patterns = ['.g.dart', 'generated', 'l10n.dart', '/pages/', '/widgets/', '_screen.dart', '_widget.dart']
|
||||
|
||||
for f, d in files.items():
|
||||
if d['total'] > 0:
|
||||
# Skip UI files
|
||||
should_exclude = any(pattern in f for pattern in exclude_patterns)
|
||||
if not should_exclude:
|
||||
uncovered = d['total'] - d['covered']
|
||||
pct = d['covered']/d['total']*100
|
||||
filtered.append((f, d, uncovered, pct))
|
||||
|
||||
# Sort by uncovered lines (most uncovered first)
|
||||
sorted_files = sorted(filtered, key=lambda x: x[2], reverse=True)
|
||||
|
||||
print('Files sorted by uncovered lines (excluding UI screens/widgets):')
|
||||
print('=' * 100)
|
||||
print(f'{"Uncovered":>10} {"Covered":>8} {"Total":>8} {"%":>7} {"File":<60}')
|
||||
print('=' * 100)
|
||||
for f, d, uncovered, pct in sorted_files[:30]:
|
||||
print(f'{uncovered:>10} {d["covered"]:>8} {d["total"]:>8} {pct:>6.1f}% {f}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
analyze_coverage_by_uncovered()
|
||||
|
||||
52
analyze_output.txt
Normal file
52
analyze_output.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
Files with less than 50% coverage:
|
||||
================================================================================
|
||||
0.0% ( 0/ 37) - lib/core/firebase_api.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_confirm_request.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_confirm_response.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_request_response.dart
|
||||
0.0% ( 0/ 342) - lib/features/dev_tools/test_cases.dart
|
||||
0.9% ( 2/ 220) - lib/features/dev_tools/presentation/dev_tools_screen.dart
|
||||
2.4% ( 1/ 42) - lib/features/auth/presentation/pages/scan_qr_code_screen.dart
|
||||
2.9% ( 1/ 34) - lib/features/diary/presentation/pages/barcode_scanner_screen.dart
|
||||
3.6% ( 1/ 28) - lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart
|
||||
3.8% ( 1/ 26) - lib/features/update_screen/presentation/update_screen.dart
|
||||
4.4% ( 4/ 90) - lib/core/widgets/custom_popup.dart
|
||||
5.9% ( 1/ 17) - lib/features/recipe/presentation/pages/my_recipes_screen.dart
|
||||
6.2% ( 2/ 32) - lib/features/consultations/presentation/pages/consultations_screen.dart
|
||||
7.1% ( 1/ 14) - lib/features/menu/presentation/pages/blocked_users_screen.dart
|
||||
7.7% ( 1/ 13) - lib/features/registration_v2/presentation/pages/registration_v2_page.dart
|
||||
8.3% ( 18/ 217) - lib/features/blog_post/presentation/pages/blog_post_screen.dart
|
||||
11.1% ( 1/ 9) - lib/features/registration_v2/presentation/widgets/step_progress.dart
|
||||
11.8% ( 14/ 119) - lib/features/auth/sdk/social_auth_facade.dart
|
||||
13.0% ( 3/ 23) - lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart
|
||||
14.5% ( 11/ 76) - lib/core/app_router.dart
|
||||
19.3% ( 11/ 57) - lib/features/create_recipe/presentation/widgets/image_widget.dart
|
||||
19.6% ( 10/ 51) - lib/features/create_recipe/entities/recipe_create_entity.dart
|
||||
19.7% ( 12/ 61) - lib/features/create_recipe/presentation/widgets/step_image_widget.dart
|
||||
20.0% ( 6/ 30) - lib/core/image_compressing/image_compress_utility.dart
|
||||
20.0% ( 57/ 285) - lib/features/complaining/presentation/complaining_bottom_sheet.dart
|
||||
21.9% ( 7/ 32) - lib/core/routing/content_routes.dart
|
||||
25.6% ( 46/ 180) - lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart
|
||||
26.8% ( 11/ 41) - lib/features/create_recipe/reporitories/recipe_repository.dart
|
||||
27.4% ( 96/ 351) - lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart
|
||||
28.0% ( 28/ 100) - lib/core/utils/image_constant.dart
|
||||
28.4% ( 23/ 81) - lib/core/websockets/ws_manager.dart
|
||||
29.0% ( 157/ 541) - lib/features/profile/presentation/pages/profile_screen.dart
|
||||
29.3% ( 137/ 468) - lib/features/notifications/models/notification_model.dart
|
||||
33.1% ( 47/ 142) - lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart
|
||||
33.3% ( 9/ 27) - lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart
|
||||
35.2% ( 58/ 165) - lib/features/registration/presentation/pages/registration_screen.dart
|
||||
35.6% ( 36/ 101) - lib/features/chat/presentation/pages/empty_chat_screen.dart
|
||||
36.0% ( 9/ 25) - lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
|
||||
36.7% ( 11/ 30) - lib/core/routing/main_navigation_routes.dart
|
||||
37.5% ( 3/ 8) - lib/core/permission_handler/permission_handler.dart
|
||||
38.6% ( 22/ 57) - lib/features/registration/presentation/pages/date_of_birth_screen.dart
|
||||
38.9% ( 35/ 90) - lib/core/routing/other_routes.dart
|
||||
39.0% ( 30/ 77) - lib/features/create_diet/presentation/widgets/diet_image_picker_widget.dart
|
||||
39.3% ( 119/ 303) - lib/features/settings/presentation/pages/settings_screen.dart
|
||||
39.3% ( 22/ 56) - lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart
|
||||
40.0% ( 4/ 10) - lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart
|
||||
40.0% ( 4/ 10) - lib/features/registration/services/registration_analytics_service.dart
|
||||
41.5% ( 95/ 229) - lib/features/create_diet/presentation/cubit/create_diet_cubit.dart
|
||||
41.9% ( 31/ 74) - lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart
|
||||
42.7% ( 67/ 157) - lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart
|
||||
@@ -1 +1,94 @@
|
||||
{"agcgw":{"url":"connect-drru.hispace.dbankcloud.ru","backurl":"connect-drru.hispace.dbankcloud.cn","websocketurl":"connect-ws-drru.hispace.dbankcloud.ru","websocketbackurl":"connect-ws-drru.hispace.dbankcloud.cn"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"20007000036693451","product_id":"461323198429446894","client_id":"1595824543740483264","client_secret":"2A1727B0D31AA9BE1F5D9E8A5CDA1A67B9EF084AC9FB68D6C633A3CCB08ECDEE","project_id":"461323198429446894","app_id":"113238569","api_key":"DQEDAJNRITp9M3uc1rPHyT8PzLc7bnVcKJAeavZZk0TQNMx9ifvBebr+JOmZe9tEvjnLwv7c1hAHXmPGR/gWG86iABDrCVotq+NGnw==","package_name":"com.eatvioapp.app"},"oauth_client":{"client_id":"113238569","client_type":1},"app_info":{"app_id":"113238569","package_name":"com.eatvioapp.app"},"service":{"analytics":{"collector_url":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drru.ai.dbankcloud.ru,ml-api-drru.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drru.cloud.huawei.ru"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"RU","configuration_version":"3.0","appInfos":[{"package_name":"com.eatvioapp.app","client":{"app_id":"113238569"},"app_info":{"package_name":"com.eatvioapp.app","app_id":"113238569"},"oauth_client":{"client_type":1,"client_id":"113238569"}}]}
|
||||
{
|
||||
"agcgw" : {
|
||||
"url" : "connect-drru.hispace.dbankcloud.ru",
|
||||
"backurl" : "connect-drru.hispace.dbankcloud.cn",
|
||||
"websocketurl" : "connect-ws-drru.hispace.dbankcloud.ru",
|
||||
"websocketbackurl" : "connect-ws-drru.hispace.dbankcloud.cn"
|
||||
},
|
||||
"agcgw_all" : {
|
||||
"SG" : "connect-dra.dbankcloud.cn",
|
||||
"SG_back" : "connect-dra.hispace.hicloud.com",
|
||||
"CN" : "connect-drcn.dbankcloud.cn",
|
||||
"CN_back" : "connect-drcn.hispace.hicloud.com",
|
||||
"RU" : "connect-drru.hispace.dbankcloud.ru",
|
||||
"RU_back" : "connect-drru.hispace.dbankcloud.cn",
|
||||
"DE" : "connect-dre.dbankcloud.cn",
|
||||
"DE_back" : "connect-dre.hispace.hicloud.com"
|
||||
},
|
||||
"websocketgw_all" : {
|
||||
"SG" : "connect-ws-dra.hispace.dbankcloud.cn",
|
||||
"SG_back" : "connect-ws-dra.hispace.dbankcloud.com",
|
||||
"CN" : "connect-ws-drcn.hispace.dbankcloud.cn",
|
||||
"CN_back" : "connect-ws-drcn.hispace.dbankcloud.com",
|
||||
"RU" : "connect-ws-drru.hispace.dbankcloud.ru",
|
||||
"RU_back" : "connect-ws-drru.hispace.dbankcloud.cn",
|
||||
"DE" : "connect-ws-dre.hispace.dbankcloud.cn",
|
||||
"DE_back" : "connect-ws-dre.hispace.dbankcloud.com"
|
||||
},
|
||||
"client" : {
|
||||
"cp_id" : "20007000036693451",
|
||||
"product_id" : "461323198429446894",
|
||||
"client_id" : "1595824543740483264",
|
||||
"client_secret" : "2A1727B0D31AA9BE1F5D9E8A5CDA1A67B9EF084AC9FB68D6C633A3CCB08ECDEE",
|
||||
"project_id" : "461323198429446894",
|
||||
"app_id" : "113238569",
|
||||
"api_key" : "DQEDAJNRITp9M3uc1rPHyT8PzLc7bnVcKJAeavZZk0TQNMx9ifvBebr+JOmZe9tEvjnLwv7c1hAHXmPGR/gWG86iABDrCVotq+NGnw==",
|
||||
"package_name" : "com.eatvioapp.app"
|
||||
},
|
||||
"oauth_client" : {
|
||||
"client_id" : "113238569",
|
||||
"client_type" : 1
|
||||
},
|
||||
"app_info" : {
|
||||
"app_id" : "113238569",
|
||||
"package_name" : "com.eatvioapp.app"
|
||||
},
|
||||
"service" : {
|
||||
"analytics" : {
|
||||
"collector_url" : "datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
|
||||
"collector_url_cn" : "datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn",
|
||||
"collector_url_de" : "datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
|
||||
"collector_url_ru" : "datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
|
||||
"collector_url_sg" : "datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn",
|
||||
"resource_id" : "p1",
|
||||
"channel_id" : ""
|
||||
},
|
||||
"ml" : {
|
||||
"mlservice_url" : "ml-api-drru.ai.dbankcloud.ru,ml-api-drru.ai.dbankcloud.cn"
|
||||
},
|
||||
"cloudstorage" : {
|
||||
"storage_url" : "https://agc-storage-drcn.platform.dbankcloud.cn",
|
||||
"storage_url_ru" : "https://agc-storage-drru.cloud.huawei.ru",
|
||||
"storage_url_sg" : "https://ops-dra.agcstorage.link",
|
||||
"storage_url_de" : "https://ops-dre.agcstorage.link",
|
||||
"storage_url_cn" : "https://agc-storage-drcn.platform.dbankcloud.cn",
|
||||
"storage_url_ru_back" : "https://agc-storage-drru.cloud.huawei.ru",
|
||||
"storage_url_sg_back" : "https://agc-storage-dra.cloud.huawei.asia",
|
||||
"storage_url_de_back" : "https://agc-storage-dre.cloud.huawei.eu",
|
||||
"storage_url_cn_back" : "https://agc-storage-drcn.cloud.huawei.com.cn"
|
||||
},
|
||||
"search" : {
|
||||
"url" : "https://search-drru.cloud.huawei.ru"
|
||||
},
|
||||
"edukit" : {
|
||||
"edu_url" : "edukit.cloud.huawei.com.cn",
|
||||
"dh_url" : "edukit.cloud.huawei.com.cn"
|
||||
}
|
||||
},
|
||||
"region" : "RU",
|
||||
"configuration_version" : "3.0",
|
||||
"appInfos" : [ {
|
||||
"package_name" : "com.eatvioapp.app",
|
||||
"client" : {
|
||||
"app_id" : "113238569"
|
||||
},
|
||||
"app_info" : {
|
||||
"package_name" : "com.eatvioapp.app",
|
||||
"app_id" : "113238569"
|
||||
},
|
||||
"oauth_client" : {
|
||||
"client_type" : 1,
|
||||
"client_id" : "113238569"
|
||||
}
|
||||
} ]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ plugins {
|
||||
id 'com.google.gms.google-services'
|
||||
// END: FlutterFire Configuration
|
||||
id "kotlin-android"
|
||||
id "kotlin-parcelize"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id 'com.huawei.agconnect'
|
||||
}
|
||||
@@ -68,10 +69,10 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.1.301'
|
||||
implementation 'com.huawei.hms:push:6.12.0.300'
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||
implementation 'com.huawei.hms:hianalytics:6.12.0.300'
|
||||
implementation 'com.huawei.hms:hianalytics:6.12.0.301'
|
||||
// BouncyCastle provider required by Huawei secure utils used transitively by HMS
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@
|
||||
<meta-data
|
||||
android:name="push_kit_auto_init_enabled"
|
||||
android:value="true" />
|
||||
<meta-data
|
||||
android:name="com.huawei.hms.client.appid"
|
||||
android:value="appid=113238569" />
|
||||
<meta-data
|
||||
android:name="com.huawei.hms.client.cpid"
|
||||
android:value="cpid=20007000036693451" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.huawei.hms.push.HmsMessageService
|
||||
import com.huawei.hms.push.RemoteMessage
|
||||
|
||||
@@ -13,6 +17,45 @@ class HmsPushService : HmsMessageService() {
|
||||
override fun onMessageReceived(message: RemoteMessage?) {
|
||||
super.onMessageReceived(message)
|
||||
Log.i("HMS", "onMessageReceived: ${message?.data}")
|
||||
|
||||
if (message == null) return
|
||||
|
||||
// Берём action_url из data-поля, если оно есть.
|
||||
val actionUrl = message.dataOfMap["action_url"]
|
||||
|
||||
// Строим системное уведомление, по тапу по которому откроется MainActivity с action_url.
|
||||
showNotification(message, actionUrl)
|
||||
}
|
||||
|
||||
private fun showNotification(message: RemoteMessage, actionUrl: String?) {
|
||||
val context = applicationContext
|
||||
|
||||
// Интент для запуска Flutter-активити.
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
if (!actionUrl.isNullOrBlank()) {
|
||||
putExtra("action_url", actionUrl)
|
||||
}
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
val channelId = "hms_push_channel"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения
|
||||
.setContentTitle(message.notification?.title ?: context.getString(R.string.app_name))
|
||||
.setContentText(message.notification?.body ?: "")
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.eatvioapp.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -14,12 +14,13 @@ import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
private val channelName = "com.eatvioapp/hms_push"
|
||||
private var methodChannel: MethodChannel? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
@@ -47,17 +48,21 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
|
||||
if (appId.isNullOrBlank()) {
|
||||
android.util.Log.e("HMS", "Missing Huawei appId. Ensure agconnect-services.json or manifest meta-data is configured.")
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "Missing Huawei appId. Ensure agconnect-services.json or manifest meta-data is configured.", null)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
android.util.Log.i("HMS", "Getting token for appId: $appId")
|
||||
val token = HmsInstanceId.getInstance(applicationContext)
|
||||
.getToken(appId, "HCM")
|
||||
android.util.Log.i("HMS", "Token retrieved successfully")
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
@@ -67,5 +72,33 @@ class MainActivity: FlutterActivity() {
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления).
|
||||
handleIntentForActionUrl(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Обработка нового интента, когда Activity уже запущена (тап по уведомлению).
|
||||
handleIntentForActionUrl(intent)
|
||||
}
|
||||
|
||||
private fun handleIntentForActionUrl(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// Вариант 1: action_url лежит в extras.
|
||||
val fromExtras = intent.extras?.getString("action_url")
|
||||
|
||||
// Вариант 2: action_url передан как data Uri.
|
||||
val dataUri = intent.data
|
||||
val fromData = dataUri?.toString()
|
||||
|
||||
val actionUrl = fromExtras ?: fromData
|
||||
if (actionUrl.isNullOrBlank()) return
|
||||
|
||||
android.util.Log.i("HMS", "MainActivity.handleIntentForActionUrl: $actionUrl")
|
||||
|
||||
// Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам.
|
||||
methodChannel?.invokeMethod("openActionUrl", actionUrl)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
assets/fonts/Mulish-Black.ttf
Normal file
BIN
assets/fonts/Mulish-Black.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-BlackItalic.ttf
Normal file
BIN
assets/fonts/Mulish-BlackItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-Bold.ttf
Normal file
BIN
assets/fonts/Mulish-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-BoldItalic.ttf
Normal file
BIN
assets/fonts/Mulish-BoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-ExtraBold.ttf
Normal file
BIN
assets/fonts/Mulish-ExtraBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-ExtraBoldItalic.ttf
Normal file
BIN
assets/fonts/Mulish-ExtraBoldItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-ExtraLight.ttf
Normal file
BIN
assets/fonts/Mulish-ExtraLight.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-ExtraLightItalic.ttf
Normal file
BIN
assets/fonts/Mulish-ExtraLightItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-Italic.ttf
Normal file
BIN
assets/fonts/Mulish-Italic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-Light.ttf
Normal file
BIN
assets/fonts/Mulish-Light.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-LightItalic.ttf
Normal file
BIN
assets/fonts/Mulish-LightItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-Medium.ttf
Normal file
BIN
assets/fonts/Mulish-Medium.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-MediumItalic.ttf
Normal file
BIN
assets/fonts/Mulish-MediumItalic.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-Regular.ttf
Normal file
BIN
assets/fonts/Mulish-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-SemiBold.ttf
Normal file
BIN
assets/fonts/Mulish-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Mulish-SemiBoldItalic.ttf
Normal file
BIN
assets/fonts/Mulish-SemiBoldItalic.ttf
Normal file
Binary file not shown.
30635
coverage/lcov.info
30635
coverage/lcov.info
File diff suppressed because it is too large
Load Diff
@@ -388,7 +388,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 83;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
@@ -539,7 +539,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 83;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
@@ -578,7 +578,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: ITVIO, OOO (VL68C36HYS)";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = 83;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = VL68C36HYS;
|
||||
ENABLE_BITCODE = NO;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>83</string>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
@@ -48,8 +48,33 @@ platform :ios do
|
||||
UI.message "Configuring Flutter for release mode..."
|
||||
sh("cd .. && flutter build ios --config-only --release")
|
||||
|
||||
# Read build number from Flutter Generated.xcconfig
|
||||
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
|
||||
flutter_build_number = nil
|
||||
|
||||
if File.exist?(generated_xcconfig_path)
|
||||
File.foreach(generated_xcconfig_path) do |line|
|
||||
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
|
||||
if matches
|
||||
flutter_build_number = matches[1].strip
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if flutter_build_number.nil?
|
||||
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
|
||||
end
|
||||
|
||||
UI.message "Flutter build number: #{flutter_build_number}"
|
||||
|
||||
match(type: "appstore", readonly: true)
|
||||
increment_build_number(xcodeproj: "Runner.xcodeproj")
|
||||
|
||||
# Set build number explicitly instead of incrementing
|
||||
increment_build_number(
|
||||
xcodeproj: "Runner.xcodeproj",
|
||||
build_number: flutter_build_number
|
||||
)
|
||||
|
||||
build_app(
|
||||
workspace: "Runner.xcworkspace",
|
||||
@@ -74,7 +99,7 @@ platform :ios do
|
||||
skip_submission: true,
|
||||
distribute_external: false,
|
||||
notify_external_testers: false,
|
||||
changelog: "TestFlight build #{get_build_number(xcodeproj: 'Runner.xcodeproj')}",
|
||||
changelog: "TestFlight build #{flutter_build_number}",
|
||||
verbose: true
|
||||
)
|
||||
rescue => ex
|
||||
|
||||
@@ -33,15 +33,16 @@ class AppConfig {
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<NewMessagesCubit>(create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(create: (context) => sl<NotificationsCubit>()..getNotifications()),
|
||||
BlocProvider<FeedCubit>(create: (context) => sl<FeedCubit>()..loadFeedBlogPostList()),
|
||||
BlocProvider<ProfileAvatarCubit>(create: (context) => sl<ProfileAvatarCubit>()..getMyProfile()),
|
||||
BlocProvider<ChatsCubit>(create: (context) => sl<ChatsCubit>()..getChatRoomsWithLoading()),
|
||||
BlocProvider<ViewsCubit>(create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(create: (context) => sl<MarketingCubit>()),
|
||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
@@ -37,9 +39,19 @@ class AppInitializer {
|
||||
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем работу
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +80,10 @@ class AppInitializer {
|
||||
options.tracesSampleRate = 1.0;
|
||||
options.debug = false;
|
||||
}, appRunner: appRunner);
|
||||
FlutterError.onError = (details) async {
|
||||
if (kDebugMode) return;
|
||||
await Sentry.captureException(details.exception, stackTrace: details.stack);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:developer' as developer;
|
||||
import 'package:eatvio_app/core/routing/auth_routes.dart';
|
||||
import 'package:eatvio_app/core/routing/content_routes.dart';
|
||||
import 'package:eatvio_app/core/routing/main_navigation_routes.dart';
|
||||
@@ -13,6 +14,9 @@ import 'package:go_router/go_router.dart';
|
||||
class AppRouter {
|
||||
static GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
// Домены для deep links (в т.ч. ссылки из пушей dev2.eatvio.com)
|
||||
static const List<String> _deepLinkHosts = ['eatvio.ru', 'www.eatvio.ru', 'dev2.eatvio.com', 'www.dev2.eatvio.com'];
|
||||
|
||||
static GoRouter appRouter = GoRouter(
|
||||
initialLocation: RouteConstants.updateScreen,
|
||||
navigatorKey: navigatorKey,
|
||||
@@ -26,52 +30,180 @@ class AppRouter {
|
||||
],
|
||||
);
|
||||
|
||||
/// Обработка редиректов для deep links
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
final segments = state.uri.pathSegments;
|
||||
static String? _resolveDeepLinkUri(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
final segments = uri.pathSegments;
|
||||
|
||||
final isDeepLink = _deepLinkHosts.contains(host);
|
||||
|
||||
if (!isDeepLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
developer.log('Deep link detected: host=$host, path=${uri.path}, segments=$segments', name: 'AppRouter');
|
||||
|
||||
if (segments.isEmpty) {
|
||||
developer.log('Empty segments in deep link, redirecting to feed', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return RouteConstants.feedScreen;
|
||||
}
|
||||
|
||||
if (segments.length >= 4 &&
|
||||
segments[0] == 'api' &&
|
||||
segments[1] == 'v2' &&
|
||||
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;
|
||||
}
|
||||
|
||||
if (segments.length == 4) {
|
||||
final type = segments[1];
|
||||
switch (type) {
|
||||
case 'diary':
|
||||
FlutterNativeSplash.remove();
|
||||
return '/diary-screen';
|
||||
|
||||
if (type == 'diary') {
|
||||
developer.log('Deep link: diary, redirecting to diary-screen', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return RouteConstants.diaryScreen;
|
||||
}
|
||||
}
|
||||
if (segments.length == 3) {
|
||||
if (type == 'consultations' && segments[2] == 'status') {
|
||||
final consultationId = segments[3];
|
||||
developer.log('Deep link: consultations/status/$consultationId', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '${RouteConstants.consultationsScreen}?id=$consultationId';
|
||||
}
|
||||
|
||||
developer.log('Unknown deep link type for 4 segments: $type, redirecting to feed', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return RouteConstants.feedScreen;
|
||||
} else if (segments.length == 3) {
|
||||
final type = segments[1];
|
||||
final contentIdentifier = segments[2];
|
||||
|
||||
switch (type) {
|
||||
case 'recipe':
|
||||
developer.log('Deep link: recipe/$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/about-recipes-screen?slug=$contentIdentifier';
|
||||
case 'blog_post':
|
||||
developer.log('Deep link: blog_post/$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/blog-post-screen?id=$contentIdentifier';
|
||||
case 'plan':
|
||||
developer.log('Deep link: plan/$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/about-plan-screen?slug=$contentIdentifier';
|
||||
case 'diet':
|
||||
case 'diet': {
|
||||
final dietId = int.tryParse(contentIdentifier);
|
||||
if (dietId != null) {
|
||||
developer.log('Deep link: diet/id=$dietId', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/about-diet-screen?id=$dietId';
|
||||
}
|
||||
developer.log('Deep link: diet/slug=$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/about-diet-screen?slug=$contentIdentifier';
|
||||
}
|
||||
case 'chat':
|
||||
developer.log('Deep link: chat/$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/chat-select-screen';
|
||||
return RouteConstants.chatSelectScreen;
|
||||
case 'profile':
|
||||
// В диплинке передан slug — открываем профиль пользователя по slug.
|
||||
developer.log('Deep link: profile/$contentIdentifier', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/profile-screen?slug=$contentIdentifier';
|
||||
}
|
||||
}
|
||||
if (segments.length == 2) {
|
||||
final type = segments[1];
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
case 'badge':
|
||||
// В badge нет slug, открываем вкладку «Мой профиль».
|
||||
developer.log('Deep link: badge → my-profile tab', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return '/chat-select-screen';
|
||||
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;
|
||||
}
|
||||
|
||||
return null;
|
||||
developer.log('Unknown deep link format: ${segments.length} segments, redirecting to feed', name: 'AppRouter');
|
||||
FlutterNativeSplash.remove();
|
||||
return RouteConstants.feedScreen;
|
||||
}
|
||||
|
||||
static String? _handleRedirect(BuildContext context, GoRouterState state) {
|
||||
return _resolveDeepLinkUri(state.uri);
|
||||
}
|
||||
|
||||
static void navigateByDeepLinkUrl(String url) {
|
||||
if (url.isEmpty) {
|
||||
developer.log('navigateByDeepLinkUrl: empty url, redirecting to feed', name: 'AppRouter');
|
||||
appRouter.go(RouteConstants.feedScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
Uri? uri;
|
||||
try {
|
||||
uri = Uri.tryParse(url);
|
||||
} catch (e) {
|
||||
developer.log('navigateByDeepLinkUrl: failed to parse url=$url, error=$e, redirecting to feed', name: 'AppRouter');
|
||||
appRouter.go(RouteConstants.feedScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri == null) {
|
||||
developer.log('navigateByDeepLinkUrl: uri is null for url=$url, redirecting to feed', name: 'AppRouter');
|
||||
appRouter.go(RouteConstants.feedScreen);
|
||||
return;
|
||||
}
|
||||
|
||||
if (uri.host.isEmpty && uri.pathSegments.isNotEmpty) {
|
||||
uri = Uri(
|
||||
scheme: 'https',
|
||||
host: _deepLinkHosts.first,
|
||||
pathSegments: uri.pathSegments,
|
||||
queryParameters: uri.queryParameters.isEmpty ? null : uri.queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
final target = _resolveDeepLinkUri(uri);
|
||||
|
||||
if (target != null) {
|
||||
appRouter.go(target);
|
||||
} else {
|
||||
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}) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/comments/models/comment_update_model.dart';
|
||||
import 'package:eatvio_app/core/comments/services/comments_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class CommentsRepository {
|
||||
final CommentsService service;
|
||||
@@ -9,7 +10,8 @@ class CommentsRepository {
|
||||
Future<void> deleteComment({required int id}) async {
|
||||
try {
|
||||
return service.deleteComment(id);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +19,8 @@ class CommentsRepository {
|
||||
Future<void> updateComment({required int id, required String body}) async {
|
||||
try {
|
||||
return service.updateComment(id, CommentUpdateModel(body: body));
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,4 +69,9 @@ class AppConstants {
|
||||
static const String usersEndpoint = '/users';
|
||||
static const String postsEndpoint = '/posts';
|
||||
static const String commentsEndpoint = '/comments';
|
||||
|
||||
// Delay of pending requests
|
||||
/// 30 секунд стоит потому что запрос может идти 12-15 секунд
|
||||
/// Особенно если производится отладка на сервере разработчика
|
||||
static const Duration pendingDelay = Duration(seconds: 30);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class StateUtils {
|
||||
if (isError(state)) {
|
||||
// Попытка извлечь сообщение об ошибке из состояния
|
||||
final stateString = state.toString();
|
||||
final errorMatch = RegExp(r'error[:\s]+([^,}]+)').firstMatch(stateString);
|
||||
final errorMatch = RegExp(r'error[:\s]+([^,}]+)', caseSensitive: false).firstMatch(stateString);
|
||||
return errorMatch?.group(1)?.trim();
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -45,7 +45,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_
|
||||
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/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recieps_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/my_recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_cubit.dart';
|
||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||
@@ -76,10 +76,10 @@ class CubitRegistrations {
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
static void _registerFactoryCubits() {
|
||||
// Auth related cubits
|
||||
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl()));
|
||||
sl.registerFactory<AuthCubit>(() => AuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<RegistrationCubit>(() => RegistrationCubit(registrationRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<ResetPasswordCubit>(() => ResetPasswordCubit(resetPasswordRepository: sl()));
|
||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl()));
|
||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
|
||||
// Feed related cubits
|
||||
@@ -114,7 +114,7 @@ class CubitRegistrations {
|
||||
// User content cubits
|
||||
sl.registerFactory<MyDietsCubit>(() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()));
|
||||
sl.registerFactory<MyPlansCubit>(() => MyPlansCubit(myPlansRepository: sl()));
|
||||
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl()));
|
||||
sl.registerFactory<MyRecipesCubit>(() => MyRecipesCubit(myRecipesRepository: sl(), aboutRecipeRepository: sl(), recipeRepository: sl()));
|
||||
|
||||
// Other feature cubits
|
||||
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/constants/app_constants.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/prefs.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sentry_dio/sentry_dio.dart';
|
||||
|
||||
/// Регистрация внешних зависимостей в системе внедрения зависимостей
|
||||
class ExternalRegistrations {
|
||||
@@ -19,14 +21,21 @@ class ExternalRegistrations {
|
||||
/// Регистрация Dio HTTP клиента
|
||||
static void _registerDio() {
|
||||
sl.registerLazySingleton(
|
||||
() =>
|
||||
Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl, headers: {'accept': 'application/json', 'content-type': 'application/json'}))
|
||||
..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()]),
|
||||
() => Dio(
|
||||
BaseOptions(
|
||||
baseUrl: sl<AppConfig>().baseUrl,
|
||||
headers: {'accept': 'application/json', 'content-type': 'application/json'},
|
||||
connectTimeout: AppConstants.pendingDelay,
|
||||
receiveTimeout: AppConstants.pendingDelay,
|
||||
sendTimeout: AppConstants.pendingDelay,
|
||||
),
|
||||
)..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()])
|
||||
..addSentry(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Prefs
|
||||
static void _registerPrefs() {
|
||||
sl.registerSingletonAsync<Prefs>(()async => Prefs(await SharedPreferences.getInstance()));
|
||||
sl.registerSingletonAsync<Prefs>(() async => Prefs(await SharedPreferences.getInstance()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:eatvio_app/features/diary/repositories/diary_repository.dart';
|
||||
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
||||
import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart';
|
||||
import 'package:eatvio_app/features/barcode/repositories/barcode_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/repositories/blocked_users_repository.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/features/my_diets/repositories/my_diets_repository.dart';
|
||||
@@ -55,6 +56,7 @@ class RepositoryRegistrations {
|
||||
sl.registerLazySingleton<ConsultantRepository>(() => ConsultantRepository(consultantService: sl()));
|
||||
sl.registerLazySingleton<DiaryRepository>(() => DiaryRepository(diaryService: sl()));
|
||||
sl.registerLazySingleton<GlossaryRepository>(() => GlossaryRepository(glossaryService: sl()));
|
||||
sl.registerLazySingleton<BarcodeRepository>(() => BarcodeRepository(barcodeService: sl(), dio: sl()));
|
||||
sl.registerLazySingleton<DietRepository>(() => DietRepository(dietsService: sl()));
|
||||
sl.registerLazySingleton<PlansRepository>(() => PlansRepository(plansService: sl()));
|
||||
sl.registerLazySingleton<PlanRepository>(() => PlanRepository(planService: sl()));
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:eatvio_app/features/diary/services/diary_service.dart';
|
||||
import 'package:eatvio_app/features/diets/services/diet_service.dart';
|
||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||
import 'package:eatvio_app/features/glossary/services/glossary_service.dart';
|
||||
import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
|
||||
import 'package:eatvio_app/features/menu/services/blocked_users_service.dart';
|
||||
import 'package:eatvio_app/features/menu/services/notification_settings_service.dart';
|
||||
import 'package:eatvio_app/features/my_diets/services/my_diets_service.dart';
|
||||
@@ -56,6 +57,7 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<ConsultantService>(() => ConsultantService(sl()));
|
||||
sl.registerLazySingleton<DiaryService>(() => DiaryService(sl()));
|
||||
sl.registerLazySingleton<GlossaryService>(() => GlossaryService(sl()));
|
||||
sl.registerLazySingleton<BarcodeService>(() => BarcodeService(sl()));
|
||||
sl.registerLazySingleton<DietService>(() => DietService(sl()));
|
||||
sl.registerLazySingleton<PlansService>(() => PlansService(sl()));
|
||||
sl.registerLazySingleton<PlanService>(() => PlanService(sl()));
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||
@@ -19,12 +20,25 @@ class FirebaseApi {
|
||||
String? token;
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative();
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
await _firebaseMessaging.requestPermission();
|
||||
try {
|
||||
await _firebaseMessaging.requestPermission().timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
token = await _firebaseMessaging.getToken();
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -35,13 +49,20 @@ class FirebaseApi {
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerAnonymousPushToken timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +78,16 @@ class FirebaseApi {
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,10 +112,12 @@ class FirebaseApi {
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -27,6 +30,17 @@ abstract class ImageCompressUtility {
|
||||
}
|
||||
|
||||
static Future<File> compressFile(File file) async {
|
||||
// Skip compression in test environment to avoid hangs
|
||||
try {
|
||||
final appConfig = sl<AppConfig>();
|
||||
if (appConfig.buildType == AppBuildType.tests) {
|
||||
// In tests, return the original file without compression
|
||||
return file;
|
||||
}
|
||||
} catch (_) {
|
||||
// If AppConfig is not available, proceed with compression
|
||||
}
|
||||
|
||||
final image = img.decodeImage(file.readAsBytesSync())!;
|
||||
int width = image.width;
|
||||
int height = image.height;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_router.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:go_router/go_router.dart';
|
||||
|
||||
class TokenInterceptor extends Interceptor {
|
||||
@@ -23,6 +25,7 @@ class TokenInterceptor extends Interceptor {
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/di/cubit_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/service_registrations.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
@@ -18,6 +19,9 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
// Регистрация внешних зависимостей (Dio, etc.)
|
||||
ExternalRegistrations.registerAll();
|
||||
|
||||
// Ожидание инициализации асинхронных зависимостей
|
||||
await sl.getAsync<Prefs>();
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
|
||||
|
||||
91
lib/core/notifications/push_navigation_handler.dart
Normal file
91
lib/core/notifications/push_navigation_handler.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Отвечает за навигацию по диплинкам, пришедшим в пуш-уведомлениях.
|
||||
class PushNavigationHandler {
|
||||
static bool _initialized = false;
|
||||
static const MethodChannel _hmsChannel = MethodChannel('com.eatvioapp/hms_push');
|
||||
|
||||
/// Инициализация обработчиков Firebase Messaging.
|
||||
/// Должна вызываться один раз после старта приложения.
|
||||
static Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
// Сообщение, по которому запустили приложение из "убитого" состояния.
|
||||
try {
|
||||
final initialMessage = await FirebaseMessaging.instance.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleMessageInternal(initialMessage, source: 'initialMessage');
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: error while getting initialMessage: $e',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
}
|
||||
|
||||
// Тап по уведомлению, когда приложение было в фоне.
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(
|
||||
(message) => _handleMessageInternal(message, source: 'onMessageOpenedApp'),
|
||||
);
|
||||
|
||||
// Подписка на события от native-кода (HMS через MethodChannel).
|
||||
_hmsChannel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'openActionUrl') {
|
||||
final url = call.arguments as String? ?? '';
|
||||
handleActionUrlFromNative(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Входная точка для навигации из native-кода (например, HMS через MethodChannel).
|
||||
static void handleActionUrlFromNative(String url) {
|
||||
if (url.isEmpty) {
|
||||
developer.log(
|
||||
'PushNavigationHandler.handleActionUrlFromNative: empty url',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'PushNavigationHandler.handleActionUrlFromNative: navigating by url=$url',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
|
||||
AppRouter.navigateByDeepLinkUrl(url);
|
||||
}
|
||||
|
||||
/// Публичный метод для единичной обработки сообщения (если понадобится где-то ещё).
|
||||
static void handleMessage(RemoteMessage message) {
|
||||
_handleMessageInternal(message, source: 'manualHandle');
|
||||
}
|
||||
|
||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||
final data = message.data;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/auth_screen.dart';
|
||||
@@ -21,10 +19,8 @@ class AuthRoutes {
|
||||
path: RouteConstants.authScreen,
|
||||
name: 'authScreen',
|
||||
builder: (context, state) => const AuthScreen(),
|
||||
redirect: (context, state) {
|
||||
sl<FirebaseApi>().registerAnonymousPushToken();
|
||||
return null;
|
||||
},
|
||||
// registerAnonymousPushToken вызывается в AuthCubit.initializeUser()
|
||||
// и не должен блокировать навигацию
|
||||
routes: [
|
||||
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
|
||||
GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
|
||||
|
||||
@@ -17,12 +17,20 @@ class ContentRoutes {
|
||||
name: 'aboutPlanScreen',
|
||||
builder: (context, state) {
|
||||
final slug = state.uri.queryParameters['slug'];
|
||||
final commentIdStr = state.uri.queryParameters['commentId'];
|
||||
final commentId = int.tryParse(commentIdStr ?? '');
|
||||
|
||||
if (slug != null) {
|
||||
return AboutPlanScreen(slug: slug, navigateToComments: false, index: null);
|
||||
return AboutPlanScreen(slug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId);
|
||||
}
|
||||
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return AboutPlanScreen(slug: data['slug'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
|
||||
return AboutPlanScreen(
|
||||
slug: data['slug'] as String,
|
||||
index: data['index'] as int?,
|
||||
navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null),
|
||||
highlightedCommentId: data['commentId'] as int?,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -30,12 +38,20 @@ class ContentRoutes {
|
||||
name: 'aboutRecipesScreen',
|
||||
builder: (context, state) {
|
||||
final slug = state.uri.queryParameters['slug'];
|
||||
final commentIdStr = state.uri.queryParameters['commentId'];
|
||||
final commentId = int.tryParse(commentIdStr ?? '');
|
||||
|
||||
if (slug != null) {
|
||||
return AboutRecipeScreen(recipeSlug: slug, navigateToComments: false, index: null);
|
||||
return AboutRecipeScreen(recipeSlug: slug, navigateToComments: commentId != null, index: null, highlightedCommentId: commentId);
|
||||
}
|
||||
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return AboutRecipeScreen(recipeSlug: data['recipe'] as String, index: data['index'] as int?, navigateToComments: data['navigateToComments'] as bool);
|
||||
return AboutRecipeScreen(
|
||||
recipeSlug: data['recipe'] as String,
|
||||
index: data['index'] as int?,
|
||||
navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null),
|
||||
highlightedCommentId: data['commentId'] as int?,
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -43,16 +59,28 @@ class ContentRoutes {
|
||||
name: 'aboutDietScreen',
|
||||
builder: (context, state) {
|
||||
final slug = state.uri.queryParameters['slug'];
|
||||
final idStr = state.uri.queryParameters['id'];
|
||||
final id = int.tryParse(idStr ?? '');
|
||||
final commentIdStr = state.uri.queryParameters['commentId'];
|
||||
final commentId = int.tryParse(commentIdStr ?? '');
|
||||
|
||||
if (slug != null) {
|
||||
return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: false);
|
||||
return AboutDietScreen(id: null, slug: slug, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId);
|
||||
}
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return AboutDietScreen(
|
||||
id: data['id'] as int?,
|
||||
slug: data['slug'] as String?,
|
||||
index: data['index'] as int?,
|
||||
navigateToComments: data['navigateToComments'] as bool,
|
||||
);
|
||||
if (id != null && id > 0) {
|
||||
return AboutDietScreen(id: id, slug: null, index: null, navigateToComments: commentId != null, highlightedCommentId: commentId);
|
||||
}
|
||||
final data = state.extra as Map<String, dynamic>?;
|
||||
if (data != null) {
|
||||
return AboutDietScreen(
|
||||
id: data['id'] as int?,
|
||||
slug: data['slug'] as String?,
|
||||
index: data['index'] as int?,
|
||||
navigateToComments: data['navigateToComments'] as bool? ?? (data['commentId'] != null),
|
||||
highlightedCommentId: data['commentId'] as int?,
|
||||
);
|
||||
}
|
||||
return const AboutDietScreen(id: null, slug: null, index: null, navigateToComments: false, highlightedCommentId: null);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@@ -65,12 +93,20 @@ class ContentRoutes {
|
||||
name: 'blogPostScreen',
|
||||
builder: (context, state) {
|
||||
final id = state.uri.queryParameters['id'];
|
||||
final commentIdStr = state.uri.queryParameters['commentId'];
|
||||
final commentId = int.tryParse(commentIdStr ?? '');
|
||||
final parsedId = int.tryParse(id ?? '');
|
||||
|
||||
if (parsedId != null) {
|
||||
return BlogPostScreen(id: parsedId);
|
||||
return BlogPostScreen(id: parsedId, highlightedCommentId: commentId);
|
||||
}
|
||||
|
||||
return BlogPostScreen(id: state.extra as int);
|
||||
if (state.extra is int) {
|
||||
return BlogPostScreen(id: state.extra as int);
|
||||
}
|
||||
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return BlogPostScreen(id: data['id'] as int, highlightedCommentId: data['commentId'] as int?);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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_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_navigation_bar_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart';
|
||||
import 'package:eatvio_app/features/consultations/presentation/pages/consultations_screen.dart';
|
||||
@@ -23,13 +24,18 @@ class MainNavigationRoutes {
|
||||
return [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
return BlocBuilder<ProfileAvatarCubit, ProfileAvatarState>(
|
||||
builder: (BuildContext context, ProfileAvatarState state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
return BottomNavigationBarWidget(profileModel: state.profile, child: child);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
|
||||
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
||||
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
|
||||
// Вызываем getMyProfile() только если состояние loading (первый запуск)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.getMyProfile();
|
||||
});
|
||||
}
|
||||
|
||||
return _MainShellWithNavScope(
|
||||
currentLocation: state.matchedLocation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
@@ -56,7 +62,7 @@ class MainNavigationRoutes {
|
||||
GoRoute(
|
||||
path: RouteConstants.diaryScreen,
|
||||
name: 'diaryScreen',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(child: DiaryScreen()),
|
||||
pageBuilder: (context, state) => NoTransitionPage(child: DiaryScreen(date: state.extra as DateTime?)),
|
||||
),
|
||||
GoRoute(
|
||||
path: ConsultationsScreen.path,
|
||||
@@ -66,7 +72,10 @@ class MainNavigationRoutes {
|
||||
GoRoute(
|
||||
path: RouteConstants.chatSelectScreen,
|
||||
name: 'chatSelectScreen',
|
||||
pageBuilder: (context, state) => const NoTransitionPage(child: ChatsScreen()),
|
||||
pageBuilder: (context, state) {
|
||||
final initialSearchQuery = state.extra is String ? state.extra as String : null;
|
||||
return NoTransitionPage(child: ChatsScreen(initialSearchQuery: initialSearchQuery));
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: RouteConstants.notificationsScreen,
|
||||
@@ -92,3 +101,33 @@ class MainNavigationRoutes {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the shell with [BottomNavScope] so descendants (e.g. notifications)
|
||||
/// can switch tabs via [BottomNavController.switchToRoute] instead of pushing.
|
||||
class _MainShellWithNavScope extends StatefulWidget {
|
||||
const _MainShellWithNavScope({
|
||||
required this.child,
|
||||
required this.currentLocation,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<_MainShellWithNavScope> createState() => _MainShellWithNavScopeState();
|
||||
}
|
||||
|
||||
class _MainShellWithNavScopeState extends State<_MainShellWithNavScope> {
|
||||
final BottomNavController _navController = BottomNavController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BottomNavScope(
|
||||
controller: _navController,
|
||||
child: BottomNavigationBarWidget(
|
||||
currentLocation: widget.currentLocation,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
@@ -6,6 +7,7 @@ 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/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/calendar/presentation/pages/calendar_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/chat_screen.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
||||
@@ -58,24 +60,33 @@ class OtherRoutes {
|
||||
builder: (context, state) => const WelcomeScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final prefs = sl<Prefs>();
|
||||
final firebaseApi = sl<FirebaseApi>();
|
||||
// Prefs уже инициализирован в locator_service, но на всякий случай проверяем
|
||||
final prefs = await sl.getAsync<Prefs>();
|
||||
final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
|
||||
await firebaseApi.registerPushToken();
|
||||
if (!welcomeScreenWasShowed) {
|
||||
return null;
|
||||
} else if (await TokenStorage.haveTokenInStorage()) {
|
||||
return '/feed-screen';
|
||||
} else {
|
||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
if (!hasAnonymousTokenBeenSent) {
|
||||
await firebaseApi.registerAnonymousPushToken();
|
||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
}
|
||||
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
final profileRepository = sl<ProfileRepository>();
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
|
||||
final firebaseApi = sl<FirebaseApi>();
|
||||
await firebaseApi.registerPushToken();
|
||||
|
||||
if (profile.emailVerifiedAt != null || (prefs.getBool(PrefsKeys.isEmailVerificationSkipped) ?? false)) {
|
||||
return RouteConstants.feedScreen;
|
||||
}
|
||||
return RouteConstants.verifyEmailScreen;
|
||||
} catch (e) {
|
||||
return '/auth-screen';
|
||||
}
|
||||
} else {
|
||||
return '/auth-screen';
|
||||
}
|
||||
} catch (e) {
|
||||
return '/feed-screen';
|
||||
return '/auth-screen';
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -85,7 +96,27 @@ class OtherRoutes {
|
||||
builder: (context, state) => const UpdateScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final versions = (await sl<Dio>().get('${sl<AppConfig>().baseUrl}/api/app_versions')).data;
|
||||
// Проверка версии с таймаутом, чтобы не блокировать запуск приложения
|
||||
final versions = await sl<Dio>()
|
||||
.get('${sl<AppConfig>().baseUrl}/api/app_versions')
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
// При таймауте пропускаем проверку и идем дальше
|
||||
throw TimeoutException('Version check timeout');
|
||||
},
|
||||
)
|
||||
.then((response) => response.data)
|
||||
.catchError((e) {
|
||||
// При любой ошибке пропускаем проверку версии
|
||||
return null;
|
||||
});
|
||||
|
||||
if (versions == null) {
|
||||
// Если проверка не удалась, пропускаем экран обновления
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
String version = packageInfo.version;
|
||||
Version currentVersion = Version.parse(version);
|
||||
@@ -96,6 +127,7 @@ class OtherRoutes {
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
} catch (e) {
|
||||
// При любой ошибке пропускаем экран обновления
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
},
|
||||
@@ -121,22 +153,35 @@ class OtherRoutes {
|
||||
GoRoute(
|
||||
path: RecipeCreateScreen.path,
|
||||
name: RecipeCreateScreen.name,
|
||||
builder: (context, state) => RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null),
|
||||
builder: (context, state) {
|
||||
if (state.extra is Map<String, dynamic>) {
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
return RecipeCreateScreen(
|
||||
recipe: data['recipe'] as Recipe?,
|
||||
autoValidate: data['autoValidate'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
return RecipeCreateScreen(recipe: state.extra != null ? state.extra as Recipe : null);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: CreateDietScreen.path,
|
||||
name: CreateDietScreen.name,
|
||||
builder: (context, state) {
|
||||
final diet = state.extra as Diet?;
|
||||
return CreateDietScreen(diet: diet);
|
||||
final data = state.extra as Map<String, dynamic>?;
|
||||
final diet = data?['diet'] as Diet?;
|
||||
final isEditing = data?['isEditing'] as bool?;
|
||||
return CreateDietScreen(diet: diet, isEditing: isEditing);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: DietPreviewScreen.path,
|
||||
name: DietPreviewScreen.name,
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Diet;
|
||||
return DietPreviewScreen(diet: data);
|
||||
final data = state.extra as Map<String, dynamic>;
|
||||
final diet = data['diet'] as Diet;
|
||||
final isImageInBase64 = data['isImageInBase64'] as bool;
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
@@ -159,7 +204,7 @@ class OtherRoutes {
|
||||
GoRoute(
|
||||
path: '${RouteConstants.subsScreen}/:slug',
|
||||
name: 'subsScreen',
|
||||
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug']!, subType: state.extra as SubType),
|
||||
builder: (context, state) => SubsScreen(slug: state.pathParameters['slug'], subType: state.extra as SubType),
|
||||
),
|
||||
|
||||
// Chat routes
|
||||
@@ -240,14 +285,13 @@ class OtherRoutes {
|
||||
builder: (context, state) {
|
||||
final data = state.extra as Map<String, int>;
|
||||
final diaryId = data['diaryId'] as int;
|
||||
final currentPlanDay = data['currentDiaryDay'] as int;
|
||||
return UserDiaryScreen(diaryId: diaryId, currentPlanDay: currentPlanDay);
|
||||
return UserDiaryScreen(diaryId: diaryId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: PersonalDietSuggestionScreen.path,
|
||||
name: PersonalDietSuggestionScreen.name,
|
||||
builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int,),
|
||||
builder: (context, state) => PersonalDietSuggestionScreen(dietId: state.extra as int),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
|
||||
/// Helper class for managing themes and colors.
|
||||
class ThemeHelper {
|
||||
@@ -34,7 +34,8 @@ class ThemeHelper {
|
||||
|
||||
var colorScheme = _supportedColorScheme[_appTheme] ?? ColorSchemes.primaryColorScheme;
|
||||
return ThemeData(
|
||||
textTheme: GoogleFonts.mulishTextTheme(),
|
||||
fontFamily: 'Mulish-Local',
|
||||
textTheme: ThemeData.light().textTheme.apply(fontFamily: 'Mulish-Local'),
|
||||
visualDensity: VisualDensity.standard,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: colorScheme.onPrimary,
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class TokenStorage {
|
||||
static SharedPreferences? _cachedPrefs;
|
||||
|
||||
static Future<SharedPreferences> _getPrefs() async {
|
||||
_cachedPrefs ??= await SharedPreferences.getInstance();
|
||||
return _cachedPrefs!;
|
||||
}
|
||||
|
||||
/// Clears the cached SharedPreferences instance.
|
||||
/// This is primarily used for testing to ensure fresh state between tests.
|
||||
@visibleForTesting
|
||||
static void clearCache() {
|
||||
_cachedPrefs = null;
|
||||
}
|
||||
|
||||
static Future<bool> haveTokenInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
try {
|
||||
final sharedPreferences = await _getPrefs();
|
||||
if (sharedPreferences.getString('token') == null) {
|
||||
return false;
|
||||
} else {
|
||||
@@ -15,25 +30,25 @@ abstract class TokenStorage {
|
||||
}
|
||||
|
||||
static Future<String?> getTokenFromStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
return sharedPreferences.getString('token');
|
||||
}
|
||||
|
||||
static Future<void> saveTokenInStorage(String token) async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
await sharedPreferences.setString('token', token);
|
||||
}
|
||||
|
||||
static Future<void> removeTokenInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
await sharedPreferences.remove('token');
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SocketIdStorage {
|
||||
static Future<bool> haveSocketIdInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
try {
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
if (sharedPreferences.getString('socketId') == null) {
|
||||
return false;
|
||||
} else {
|
||||
@@ -45,17 +60,17 @@ abstract class SocketIdStorage {
|
||||
}
|
||||
|
||||
static Future<String?> getSocketIdFromStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
return sharedPreferences.getString('socketId');
|
||||
}
|
||||
|
||||
static Future<void> saveSocketIdInStorage(String socketId) async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
await sharedPreferences.setString('socketId', socketId);
|
||||
}
|
||||
|
||||
static Future<void> removeSocketIdInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
await sharedPreferences.remove('socketId');
|
||||
}
|
||||
}
|
||||
|
||||
67
lib/core/utils/markdown_utils.dart
Normal file
67
lib/core/utils/markdown_utils.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
/// Утилиты для обработки markdown текста
|
||||
class MarkdownUtils {
|
||||
/// Обрабатывает markdown текст:
|
||||
/// - Исправляет неправильный синтаксис markdown ссылок
|
||||
/// - Автоматически преобразует URL в markdown ссылки
|
||||
/// - Преобразует **текст** в <b>текст</b> для надежного отображения жирного текста
|
||||
static String processMarkdown(String text) {
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
// Например: [http://example.com](url) -> [http://example.com](http://example.com)
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
text = text.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
// Находим все существующие markdown ссылки и их позиции
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(text)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
|
||||
// Регулярное выражение для поиска URL (http://, https://, www.)
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
|
||||
// Заменяем URL на markdown ссылки, если они еще не являются ссылками
|
||||
String result = text;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(text)) {
|
||||
// Проверяем, не находится ли URL внутри существующей markdown ссылки
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Гарантируем, что жирный текст будет распознан
|
||||
// Заменяем **текст** на <b>текст</b> вручную для надежности
|
||||
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
|
||||
result = result.replaceAllMapped(boldRegex, (match) {
|
||||
return '<b>${match.group(1)}</b>';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Вспомогательный класс для хранения диапазона позиций ссылки
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
extension Capitalization on String {
|
||||
String capitalize() {
|
||||
if (isEmpty) return '';
|
||||
return "${this[0].toUpperCase()}${substring(1).toLowerCase()}";
|
||||
}
|
||||
|
||||
String parseGender() {
|
||||
if (this == 'male') {
|
||||
return 'Мужской';
|
||||
return 'Мужской';
|
||||
} else if (this == 'female') {
|
||||
return 'Женский';
|
||||
}
|
||||
|
||||
@@ -16,6 +16,21 @@ class ValidationUtils {
|
||||
return regex.hasMatch(phone);
|
||||
}
|
||||
|
||||
/// Нормализация номера телефона (+79991234455)
|
||||
static String normalizePhone(String phone) {
|
||||
if (phone.isEmpty) return '';
|
||||
final digits = phone.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digits.isEmpty) return '';
|
||||
|
||||
String normalized = digits;
|
||||
if (digits.startsWith('8')) {
|
||||
normalized = '7${digits.substring(1)}';
|
||||
} else if (digits.length == 10) {
|
||||
normalized = '7$digits';
|
||||
}
|
||||
return '+$normalized';
|
||||
}
|
||||
|
||||
/// Валидация URL
|
||||
static bool isValidUrl(String url) {
|
||||
if (url.isEmpty) return false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/views/service/views_service.dart';
|
||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class ViewsRepository {
|
||||
final ViewsService viewsService;
|
||||
@@ -9,7 +10,8 @@ class ViewsRepository {
|
||||
Future<void> viewContent({required ViewModel viewModel}) async {
|
||||
try {
|
||||
await viewsService.viewContent(viewModel: viewModel);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
@@ -8,7 +9,9 @@ import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.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';
|
||||
|
||||
class WsManager {
|
||||
final WsService wsService;
|
||||
@@ -22,6 +25,10 @@ class WsManager {
|
||||
PrivateChannel? _privateMessagesChannel;
|
||||
PrivateChannel? _privateDirectChatChannel;
|
||||
|
||||
StreamSubscription? _lifecycleSubscription;
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||
@@ -33,7 +40,7 @@ class WsManager {
|
||||
Future<void> initialize() async {
|
||||
if (client == null) {
|
||||
try {
|
||||
await TokenStorage.haveTokenInStorage();
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
final wsUrl = await wsService.getWebsocket();
|
||||
|
||||
final options = PusherChannelsOptions.fromHost(
|
||||
@@ -71,7 +78,7 @@ class WsManager {
|
||||
}
|
||||
}
|
||||
|
||||
client?.eventStream.listen((event) async {
|
||||
_connectionEstablishedSubscription = client?.eventStream.listen((event) async {
|
||||
if (event.name == 'pusher:connection_established') {
|
||||
final data = event.data;
|
||||
final socketId = extractSocketId(data);
|
||||
@@ -87,26 +94,46 @@ class WsManager {
|
||||
await _initPrivateNotificationsChannel();
|
||||
await _initPrivateMessagesChannel();
|
||||
|
||||
client?.lifecycleStream.listen((data) {
|
||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
}
|
||||
});
|
||||
client?.eventStream.listen((event) {
|
||||
EasyDebounce.debounce(
|
||||
'searchChatRoom',
|
||||
const Duration(milliseconds: 300),
|
||||
() => sl<ChatsCubit>().getChatRooms(),
|
||||
);
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
log('WsManager received event: ${event.name}');
|
||||
// Только если это не системные сообщения Pusher, обновляем список чатов
|
||||
if (!event.name.startsWith('pusher:')) {
|
||||
EasyDebounce.debounce(
|
||||
'searchChatRoom',
|
||||
const Duration(milliseconds: 300),
|
||||
() => sl<ChatsCubit>().getChatRooms(),
|
||||
);
|
||||
}
|
||||
});
|
||||
_subscribe();
|
||||
await _connectCubitsToStreams();
|
||||
} catch (e, st) {
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Подключает ChatsCubit и NotificationsCubit к стримам сокета.
|
||||
/// Вызывается после успешного initialize(), чтобы уведомления и чаты работали
|
||||
/// и при входе уже залогиненного пользователя (редирект на feed), не только после экрана логина.
|
||||
Future<void> _connectCubitsToStreams() async {
|
||||
try {
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
} catch (e, st) {
|
||||
log('WsManager._connectCubitsToStreams: $e', stackTrace: st);
|
||||
await Sentry.captureException(e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initPrivateNotificationsChannel() async {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateNotificationChannel = client?.privateChannel(
|
||||
@@ -166,6 +193,12 @@ class WsManager {
|
||||
|
||||
Future<void> clear() async {
|
||||
await client?.disconnect();
|
||||
await _lifecycleSubscription?.cancel();
|
||||
await _eventSubscription?.cancel();
|
||||
await _connectionEstablishedSubscription?.cancel();
|
||||
_lifecycleSubscription = null;
|
||||
_eventSubscription = null;
|
||||
_connectionEstablishedSubscription = null;
|
||||
client = null;
|
||||
userId = null;
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
|
||||
@@ -35,7 +35,7 @@ class CustomOutlinedButton extends BaseButton {
|
||||
margin: margin,
|
||||
decoration: decoration,
|
||||
child: OutlinedButton(
|
||||
style: buttonStyle!,
|
||||
style: buttonStyle ?? OutlinedButton.styleFrom(),
|
||||
onPressed: isDisabled ?? false ? null : onPressed ?? () {},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -59,7 +59,7 @@ class CustomOutlinedButton extends BaseButton {
|
||||
margin: margin,
|
||||
decoration: decoration,
|
||||
child: OutlinedButton(
|
||||
style: buttonStyle!,
|
||||
style: buttonStyle ?? OutlinedButton.styleFrom(),
|
||||
onPressed: isDisabled ?? false ? null : onPressed ?? () {},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -27,7 +27,7 @@ class FeedIconButton extends StatelessWidget {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (svgIcon != null) svgIcon!,
|
||||
?svgIcon,
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
|
||||
@@ -12,15 +12,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
class InfoRow extends StatelessWidget {
|
||||
const InfoRow({
|
||||
required this.type,
|
||||
super.key,
|
||||
this.diet,
|
||||
this.itemsBackgroundColor,
|
||||
this.disableDescriptionOfTypes = false,
|
||||
this.plan,
|
||||
this.recipe,
|
||||
});
|
||||
const InfoRow({required this.type, super.key, this.diet, this.itemsBackgroundColor, this.disableDescriptionOfTypes = false, this.plan, this.recipe});
|
||||
final ProductType type;
|
||||
final Color? itemsBackgroundColor;
|
||||
final Diet? diet;
|
||||
@@ -50,44 +42,13 @@ class InfoRow extends StatelessWidget {
|
||||
InfoBouble(
|
||||
backgroundColor: itemsBackgroundColor,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/icons/time2.svg',
|
||||
width: 13.3,
|
||||
height: 13.3,
|
||||
),
|
||||
SvgPicture.asset('assets/icons/time2.svg', width: 13.3, height: 13.3),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
switch (type) {
|
||||
ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}',
|
||||
ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}',
|
||||
ProductType.recipe => timeTitle ?? '',
|
||||
},
|
||||
style: TextStyles.textXSSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InfoBouble(
|
||||
backgroundColor: itemsBackgroundColor,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
switch (type) {
|
||||
ProductType.diet => 'assets/icons/people.svg',
|
||||
ProductType.plan => 'assets/icons/people.svg',
|
||||
ProductType.recipe => 'assets/icons/calories.svg',
|
||||
},
|
||||
width: 13.3,
|
||||
height: 13.3,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
switch (type) {
|
||||
ProductType.diet => '80 человек прошли',
|
||||
ProductType.plan => '80 человек прошли',
|
||||
ProductType.recipe => '${recipe?.calories.toStringAsFixed(0) ?? '0'} ккал',
|
||||
},
|
||||
style: TextStyles.textXSSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(switch (type) {
|
||||
ProductType.diet => '${diet?.duration} ${getDayForm(diet?.duration ?? 7, s)}',
|
||||
ProductType.plan => '${plan?.duration} ${getDayForm(plan?.duration ?? 7, s)}',
|
||||
ProductType.recipe => timeTitle ?? '',
|
||||
}, style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -95,11 +56,17 @@ class InfoRow extends StatelessWidget {
|
||||
InfoBouble(
|
||||
backgroundColor: itemsBackgroundColor,
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/icons/steps.svg',
|
||||
width: 13.3,
|
||||
height: 13.3,
|
||||
),
|
||||
SvgPicture.asset('assets/icons/calories.svg', width: 13.3, height: 13.3),
|
||||
const SizedBox(width: 4),
|
||||
Text('${(recipe?.calories ?? 0).toStringAsFixed(0)} ккал', style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
if (type == ProductType.recipe)
|
||||
InfoBouble(
|
||||
backgroundColor: itemsBackgroundColor,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/steps.svg', width: 13.3, height: 13.3),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${recipe?.steps?.length ?? 0} ${getStepForm(recipe?.steps?.length ?? 0)}',
|
||||
@@ -110,13 +77,7 @@ class InfoRow extends StatelessWidget {
|
||||
if (plan?.type.hasAtLeastOneType() == true || diet?.type.hasAtLeastOneType() == true)
|
||||
InfoBouble(
|
||||
backgroundColor: itemsBackgroundColor,
|
||||
children: [
|
||||
TypeBadges(
|
||||
type: diet?.type,
|
||||
planType: plan?.type,
|
||||
disableDescriptionOfTypes: disableDescriptionOfTypes,
|
||||
),
|
||||
],
|
||||
children: [TypeBadges(type: diet?.type, planType: plan?.type, disableDescriptionOfTypes: disableDescriptionOfTypes)],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
124
lib/core/widgets/markdown_html_widget.dart
Normal file
124
lib/core/widgets/markdown_html_widget.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:eatvio_app/core/utils/markdown_utils.dart';
|
||||
|
||||
/// Переиспользуемый виджет для отображения HTML из markdown текста
|
||||
class MarkdownHtmlWidget extends StatelessWidget {
|
||||
/// Markdown текст для отображения
|
||||
final String markdownText;
|
||||
|
||||
/// Ширина изображений (по умолчанию ширина экрана минус 32)
|
||||
final double? imageWidth;
|
||||
|
||||
/// Цвет текста для body и параграфов
|
||||
final Color? textColor;
|
||||
|
||||
/// Размер шрифта (по умолчанию 16)
|
||||
final double? fontSize;
|
||||
|
||||
/// Callback для обработки нажатий на ссылки
|
||||
final OnTap? onLinkTap;
|
||||
|
||||
const MarkdownHtmlWidget({
|
||||
super.key,
|
||||
required this.markdownText,
|
||||
this.imageWidth,
|
||||
this.textColor,
|
||||
this.fontSize,
|
||||
this.onLinkTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Обрабатываем markdown текст
|
||||
final processedText = MarkdownUtils.processMarkdown(markdownText);
|
||||
|
||||
// Конвертируем markdown в HTML
|
||||
final htmlData = md.markdownToHtml(
|
||||
processedText,
|
||||
extensionSet: md.ExtensionSet.gitHubFlavored,
|
||||
);
|
||||
|
||||
final screenWidth = MediaQuery.maybeOf(context)?.size.width ?? 400.0;
|
||||
final defaultImageWidth = imageWidth ?? (screenWidth - 32);
|
||||
final defaultTextColor = textColor ?? Colors.black87;
|
||||
final defaultFontSize = fontSize ?? 16.0;
|
||||
|
||||
return Html(
|
||||
data: htmlData,
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(defaultFontSize),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"p": Style(
|
||||
margin: Margins.only(bottom: 12),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"b, strong": Style(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
"h1, h2, h3, h4, h5, h6": Style(
|
||||
margin: Margins.only(top: 16, bottom: 8),
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(
|
||||
margin: Margins.only(bottom: 12),
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
),
|
||||
"li": Style(
|
||||
margin: Margins.only(bottom: 4),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"strong, b": Style(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.all(12),
|
||||
margin: Margins.only(bottom: 12),
|
||||
),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(
|
||||
color: Colors.blue,
|
||||
textDecoration: TextDecoration.underline,
|
||||
),
|
||||
"img": Style(width: Width(defaultImageWidth)),
|
||||
},
|
||||
onLinkTap: onLinkTap ?? ((url, attributes, element) async {
|
||||
if (url != null) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error launching URL: $e');
|
||||
}
|
||||
}
|
||||
}) as OnTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
Future<void> showErrorDialog(BuildContext context, {String? title, String? description, String? buttonTitle, bool? needToOpenSettings}) async {
|
||||
@@ -45,7 +43,7 @@ Future<void> showErrorDialog(BuildContext context, {String? title, String? descr
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
Navigator.of(context).pop();
|
||||
if (needToOpenSettings ?? false) {
|
||||
openAppSettings();
|
||||
}
|
||||
|
||||
@@ -8,32 +8,33 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false});
|
||||
const SuccessPublishedDietOrPlanBottomSheet({super.key, required this.title, this.isPlan = false, this.isDrafted, this.isUpdated});
|
||||
final String title;
|
||||
final bool isPlan;
|
||||
final bool? isDrafted;
|
||||
final bool? isUpdated;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final String text;
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
text = 'опубликованна';
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(
|
||||
isPlan ? 'опубликован' : 'опубликованна',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
Text(text, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: CustomOutlinedButton(
|
||||
@@ -43,15 +44,10 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_verification_confirm_request.freezed.dart';
|
||||
part 'oauth_verification_confirm_request.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthVerificationConfirmRequest with _$OAuthVerificationConfirmRequest {
|
||||
const factory OAuthVerificationConfirmRequest({
|
||||
@JsonKey(name: 'access_token') required String accessToken,
|
||||
@JsonKey(name: 'refresh_token') required String refreshToken,
|
||||
@JsonKey(name: 'client_id') required String clientId,
|
||||
required String provider,
|
||||
required String id,
|
||||
String? email,
|
||||
String? phone,
|
||||
required String code,
|
||||
@JsonKey(name: 'verification_type') required String verificationType,
|
||||
@JsonKey(name: 'first_name') required String firstName,
|
||||
@JsonKey(name: 'last_name') required String lastName,
|
||||
@JsonKey(name: 'date_of_birth') required String dateOfBirth,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
}) = _OAuthVerificationConfirmRequest;
|
||||
|
||||
factory OAuthVerificationConfirmRequest.fromJson(Map<String, dynamic> json) => _$OAuthVerificationConfirmRequestFromJson(json);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_verification_confirm_response.freezed.dart';
|
||||
part 'oauth_verification_confirm_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthVerificationConfirmResponse with _$OAuthVerificationConfirmResponse {
|
||||
const factory OAuthVerificationConfirmResponse({
|
||||
required bool success,
|
||||
required String message,
|
||||
required String token,
|
||||
required Map<String, dynamic> user,
|
||||
@JsonKey(name: 'is_new_user') required bool isNewUser,
|
||||
}) = _OAuthVerificationConfirmResponse;
|
||||
|
||||
factory OAuthVerificationConfirmResponse.fromJson(Map<String, dynamic> json) => _$OAuthVerificationConfirmResponseFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'oauth_verification_request_response.freezed.dart';
|
||||
part 'oauth_verification_request_response.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class OAuthVerificationRequestResponse with _$OAuthVerificationRequestResponse {
|
||||
const factory OAuthVerificationRequestResponse({required String email, required String status}) = _OAuthVerificationRequestResponse;
|
||||
|
||||
factory OAuthVerificationRequestResponse.fromJson(Map<String, dynamic> json) => _$OAuthVerificationRequestResponseFromJson(json);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/token_storage.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/repositories/auth_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
final AuthRepository authRepository;
|
||||
final ProfileRepository profileRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
final Prefs prefs;
|
||||
|
||||
AuthCubit({required this.authRepository}) : super(const AuthState());
|
||||
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
|
||||
|
||||
Future<bool> getTokenByCredentials(String email, String password) async {
|
||||
emit(const AuthState.loading());
|
||||
@@ -25,4 +33,26 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initializeUser() async {
|
||||
try {
|
||||
await registerAnonymousPushToken();
|
||||
FlutterNativeSplash.remove();
|
||||
} catch (e) {
|
||||
log('Error in initializeUser: $e');
|
||||
FlutterNativeSplash.remove();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
|
||||
if (!hasAnonymousTokenBeenSent) {
|
||||
await firebaseApi.registerAnonymousPushToken();
|
||||
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerPushToken() async {
|
||||
await firebaseApi.registerPushToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
|
||||
import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -28,6 +29,39 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
log('OAuthCubit: Got token model: ${tokenModel.token}');
|
||||
TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
emit(OAuthState.loaded(token: tokenModel.token));
|
||||
} on DioException catch (e) {
|
||||
log('OAuthCubit: Error occurred: $e');
|
||||
// Проверяем, является ли ошибка ошибкой валидации email/phone
|
||||
final errors = e.response?.data['errors'] as Map<String, dynamic>?;
|
||||
final emailErrors = errors?['email'] as List<dynamic>?;
|
||||
final phoneErrors = errors?['phone'] as List<dynamic>?;
|
||||
|
||||
final errorsList = <RegistrationValidationErrors>[];
|
||||
if (emailErrors != null && emailErrors.isNotEmpty) {
|
||||
for (final error in emailErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phoneErrors != null && phoneErrors.isNotEmpty) {
|
||||
for (final error in phoneErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorsList.isNotEmpty) {
|
||||
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList));
|
||||
} else {
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
}
|
||||
} catch (e) {
|
||||
log('OAuthCubit: Error occurred: $e');
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
@@ -77,16 +111,22 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
final dateOfBirth = errors?['date_of_birth'] as List<dynamic>?;
|
||||
final errorsList = <RegistrationValidationErrors>[];
|
||||
if (emailErrors != null && emailErrors.isNotEmpty) {
|
||||
if (emailErrors.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (emailErrors.contains('Такой Эл. почта уже существует.')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
for (final error in emailErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('Некорректный формат email')) {
|
||||
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
|
||||
}
|
||||
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.emailExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (phoneErrors != null && phoneErrors.isNotEmpty) {
|
||||
if (phoneErrors.contains('Такой phone уже существует.')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
for (final error in phoneErrors) {
|
||||
final errorStr = error.toString();
|
||||
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
|
||||
errorsList.add(RegistrationValidationErrors.phoneExist);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
|
||||
@@ -101,6 +141,70 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
log('OAuthCubit: Requesting verification code for email: $email, phone: $phone');
|
||||
final response = await authRepository.requestOAuthVerification(email: email, phone: phone);
|
||||
if (response != null) {
|
||||
log('OAuthCubit: Verification code requested successfully, masked email: ${response.email}');
|
||||
}
|
||||
// Сбрасываем состояние после успешного запроса кода
|
||||
emit(const OAuthState());
|
||||
return response;
|
||||
} catch (e) {
|
||||
log('OAuthCubit: Error requesting verification code: $e');
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> confirmOAuthVerification({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required String clientId,
|
||||
required String id,
|
||||
String? email,
|
||||
String? phone,
|
||||
required String code,
|
||||
required String verificationType,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String dateOfBirth,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
}) async {
|
||||
try {
|
||||
emit(const OAuthState.loading());
|
||||
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code');
|
||||
final response = await authRepository.confirmOAuthVerification(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
clientId: clientId,
|
||||
id: id,
|
||||
email: email,
|
||||
phone: phone,
|
||||
code: code,
|
||||
verificationType: verificationType,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
weight: weight,
|
||||
height: height,
|
||||
);
|
||||
log('OAuthCubit: OAuth verification confirmed successfully, token received');
|
||||
TokenStorage.saveTokenInStorage(response.token);
|
||||
emit(OAuthState.loaded(token: response.token));
|
||||
} catch (e) {
|
||||
log('OAuthCubit: Error confirming OAuth verification: $e');
|
||||
emit(OAuthState.error(errorMessage: e.toString()));
|
||||
rethrow; // Пробрасываем ошибку дальше, чтобы UI мог её обработать
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }
|
||||
|
||||
@@ -10,5 +10,10 @@ abstract class OAuthState with _$OAuthState {
|
||||
|
||||
const factory OAuthState.loaded({required String token}) = OAuthStateLoaded;
|
||||
|
||||
const factory OAuthState.error({required String errorMessage}) = OAuthStateError;
|
||||
const factory OAuthState.error({
|
||||
required String errorMessage,
|
||||
List<RegistrationValidationErrors>? registrationValidationErrors,
|
||||
}) = OAuthStateError;
|
||||
}
|
||||
|
||||
enum RegistrationValidationErrors { incorrectEmailFormat, emailExist, phoneExist, incorrectDateOfBirthFormat }
|
||||
|
||||
@@ -21,14 +21,12 @@ import 'package:eatvio_app/core/di/cubit_registrations.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/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -81,9 +79,6 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
@@ -214,9 +209,6 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
await sl<WsManager>().initialize();
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешный вход');
|
||||
} catch (e) {
|
||||
@@ -232,9 +224,8 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
final bool useMobileLayout = shortestSide < 600;
|
||||
if (!useMobileLayout) isFocused = true;
|
||||
final s = S.of(context);
|
||||
FlutterNativeSplash.remove();
|
||||
return BlocProvider<AuthCubit>(
|
||||
create: (BuildContext context) => authCubit,
|
||||
create: (BuildContext context) => authCubit..initializeUser(),
|
||||
child: BlocBuilder<AuthCubit, AuthState>(
|
||||
builder: (BuildContext context, AuthState state) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -1,166 +1,124 @@
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ForgetPasswordScreen extends StatefulWidget {
|
||||
final bool isAuth;
|
||||
const ForgetPasswordScreen({super.key, required this.isAuth});
|
||||
|
||||
@override
|
||||
State<ForgetPasswordScreen> createState() => _ForgetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgetPasswordScreenState extends State<ForgetPasswordScreen> {
|
||||
final emailController = TextEditingController();
|
||||
final codeController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final passwordConfirmController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ResetPasswordCubit>(
|
||||
create: (BuildContext context) => sl<ResetPasswordCubit>(),
|
||||
child: BlocConsumer<ResetPasswordCubit, ResetPasswordState>(
|
||||
builder: (BuildContext context, ResetPasswordState state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: widget.isAuth ? const Text('Смена пароля') : const Text('Восстановление пароля'),
|
||||
centerTitle: false,
|
||||
),
|
||||
body: state is ResetPasswordStateLoadingResetPassword || state is ResetPasswordStateLoadingSendEmailCode
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state is ResetPasswordStateCodeSendError || state is ResetPasswordStateEmpty) ...[
|
||||
Text(
|
||||
'${widget.isAuth ? '' : 'Забыли пароль? '}Укажите свой email,\nи мы пришлём код для сброса.',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'E-mail',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: emailController,
|
||||
hintText: 'Введите e-mail',
|
||||
),
|
||||
],
|
||||
if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) ...[
|
||||
const Text(
|
||||
'Код',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: codeController,
|
||||
hintText: 'Введите код',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Пароль',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: passwordController,
|
||||
hintText: 'Введите новый пароль',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Подтвердите пароль',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: passwordConfirmController,
|
||||
hintText: 'Введите новый пароль',
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) {
|
||||
context.read<ResetPasswordCubit>().tryResetPassword(
|
||||
emailController.text,
|
||||
codeController.text,
|
||||
passwordController.text,
|
||||
passwordConfirmController.text,
|
||||
);
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().trySendEmailCode(emailController.text);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError
|
||||
? 'Сменить пароль'
|
||||
: 'Сбросить пароль',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
listener: (BuildContext context, ResetPasswordState state) {
|
||||
if (state is ResetPasswordStateCodeSendSuccess) {
|
||||
showSuccessToast('Код на почту успешно отправлен');
|
||||
}
|
||||
if (state is ResetPasswordStateCodeSendError) {
|
||||
showErrorToast('Ошибка отправки кода. Проверьте правильность ввёдной почты');
|
||||
}
|
||||
if (state is ResetPasswordStateResetPasswordError) {
|
||||
showErrorToast('Ошибка смены пароля. Проверьте правильность ввёднного кода и совпадение пароля');
|
||||
}
|
||||
if (state is ResetPasswordStateResetPassowrdSuccess) {
|
||||
GoRouter.of(context).pop();
|
||||
showSuccessToast('Пароль успешно сменён. ${widget.isAuth ? '' : 'Войдите в аккаунт'}');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/reset_password_cubit/reset_password_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ForgetPasswordScreen extends StatefulWidget {
|
||||
final bool isAuth;
|
||||
const ForgetPasswordScreen({super.key, required this.isAuth});
|
||||
|
||||
@override
|
||||
State<ForgetPasswordScreen> createState() => _ForgetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgetPasswordScreenState extends State<ForgetPasswordScreen> {
|
||||
final emailController = TextEditingController();
|
||||
final codeController = TextEditingController();
|
||||
final passwordController = TextEditingController();
|
||||
final passwordConfirmController = TextEditingController();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ResetPasswordCubit>(
|
||||
create: (BuildContext context) => sl<ResetPasswordCubit>(),
|
||||
child: BlocConsumer<ResetPasswordCubit, ResetPasswordState>(
|
||||
builder: (BuildContext context, ResetPasswordState state) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: widget.isAuth ? const Text('Смена пароля') : const Text('Восстановление пароля'), centerTitle: false),
|
||||
body: state is ResetPasswordStateLoadingResetPassword || state is ResetPasswordStateLoadingSendEmailCode
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (state is ResetPasswordStateCodeSendError || state is ResetPasswordStateEmpty) ...[
|
||||
Text(
|
||||
'${widget.isAuth ? '' : 'Забыли пароль? '}Укажите свой email,\nи мы пришлём код для сброса.',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('E-mail', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: emailController, hintText: 'Введите e-mail'),
|
||||
],
|
||||
if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) ...[
|
||||
const Text('Код', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: codeController, hintText: 'Введите код'),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Пароль', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: passwordController,
|
||||
hintText: 'Введите новый пароль',
|
||||
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text('Подтвердите пароль', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: passwordConfirmController,
|
||||
hintText: 'Введите новый пароль',
|
||||
inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'\s'))],
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
if (state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError) {
|
||||
context.read<ResetPasswordCubit>().tryResetPassword(
|
||||
emailController.text,
|
||||
codeController.text,
|
||||
passwordController.text,
|
||||
passwordConfirmController.text,
|
||||
);
|
||||
} else {
|
||||
context.read<ResetPasswordCubit>().trySendEmailCode(emailController.text);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
state is ResetPasswordStateCodeSendSuccess || state is ResetPasswordStateResetPasswordError
|
||||
? 'Сменить пароль'
|
||||
: 'Сбросить пароль',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
listener: (BuildContext context, ResetPasswordState state) {
|
||||
if (state is ResetPasswordStateCodeSendSuccess) {
|
||||
showSuccessToast('Код на почту успешно отправлен');
|
||||
}
|
||||
if (state is ResetPasswordStateCodeSendError) {
|
||||
showErrorToast('Ошибка отправки кода. Проверьте правильность ввёдной почты');
|
||||
}
|
||||
if (state is ResetPasswordStateResetPasswordError) {
|
||||
showErrorToast('Ошибка смены пароля. Проверьте правильность ввёднного кода и совпадение пароля');
|
||||
}
|
||||
if (state is ResetPasswordStateResetPassowrdSuccess) {
|
||||
GoRouter.of(context).pop();
|
||||
showSuccessToast('Пароль успешно сменён. ${widget.isAuth ? '' : 'Войдите в аккаунт'}');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:developer';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -12,9 +13,9 @@ import 'package:eatvio_app/core/websockets/ws_manager.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/create_diet/presentation/widgets/error_widget.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/utils/validation_utils.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -49,6 +50,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
bool weightError = false;
|
||||
bool heightError = false;
|
||||
bool incorrectEmailFormatError = false;
|
||||
bool showVerificationButton = false;
|
||||
String? verificationType; // 'email' or 'phone'
|
||||
|
||||
// Состояние для выбора пола
|
||||
bool isMaleSelected = true;
|
||||
@@ -70,9 +73,6 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
showSuccessToast('Успешная регистрация');
|
||||
} catch (e) {
|
||||
@@ -221,7 +221,28 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
hasError: emailExistError || incorrectEmailFormatError,
|
||||
onChanged: (newValue) {},
|
||||
),
|
||||
if (emailExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такая почта уже существует.')],
|
||||
if (emailExistError) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Такая почта уже существует.'),
|
||||
if (showVerificationButton && verificationType == 'email') ...[
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _handleVerificationRequest(context, 'email'),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Да, это мой аккаунт, подтвердить',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (incorrectEmailFormatError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Некорректный формат email')],
|
||||
const SizedBox(height: 12),
|
||||
const Text('Номер телефона'),
|
||||
@@ -235,7 +256,28 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
hasError: phoneExistError,
|
||||
onChanged: (newValue) {},
|
||||
),
|
||||
if (phoneExistError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Такой номер телефона уже существует.')],
|
||||
if (phoneExistError) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Такой номер телефона уже существует.'),
|
||||
if (showVerificationButton && verificationType == 'phone') ...[
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () => _handleVerificationRequest(context, 'phone'),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Да, это мой аккаунт, подтвердить',
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -289,12 +331,17 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Вес'),
|
||||
const Text('Рост'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: weightController, hintText: 'Введите вес', onChanged: (newValue) {}, hasError: weightError),
|
||||
if (weightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Вес должен быть от 30 до 300 кг')],
|
||||
CustomTextFormField(
|
||||
controller: heightController,
|
||||
hintText: 'Введите рост',
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (newValue) {},
|
||||
hasError: heightError,
|
||||
),
|
||||
if (heightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Рост должен быть от 100 до 250 см')],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -303,11 +350,18 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
width: MediaQuery.of(context).size.width / 2 - 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Рост'),
|
||||
const Text('Вес'),
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(controller: heightController, hintText: 'Введите рост', onChanged: (newValue) {}, hasError: heightError),
|
||||
if (heightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Рост должен быть от 100 до 250 см')],
|
||||
CustomTextFormField(
|
||||
controller: weightController,
|
||||
hintText: 'Введите вес',
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (newValue) {},
|
||||
hasError: weightError,
|
||||
),
|
||||
if (weightError) ...[const SizedBox(height: 4), const ValidationErrorWidget(error: 'Вес должен быть от 30 до 300 кг')],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -481,7 +535,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
if (hasErrorWeightOrHeightError) return;
|
||||
|
||||
// Очищаем номер телефона от маски для отправки
|
||||
final cleanPhone = phoneController.text.replaceAll(RegExp(r'[^\d+]'), '');
|
||||
final cleanPhone = ValidationUtils.normalizePhone(phoneController.text);
|
||||
|
||||
if (clientID == null) return;
|
||||
// Вызываем OAuth регистрацию
|
||||
@@ -501,6 +555,10 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
clientId: clientID,
|
||||
);
|
||||
if (errors != null) {
|
||||
bool hasEmailExist = false;
|
||||
bool hasPhoneExist = false;
|
||||
|
||||
// Сначала определяем, какие ошибки присутствуют
|
||||
for (final error in errors) {
|
||||
switch (error) {
|
||||
case RegistrationValidationErrors.incorrectEmailFormat:
|
||||
@@ -509,14 +567,10 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
});
|
||||
break;
|
||||
case RegistrationValidationErrors.emailExist:
|
||||
setState(() {
|
||||
emailExistError = true;
|
||||
});
|
||||
hasEmailExist = true;
|
||||
break;
|
||||
case RegistrationValidationErrors.phoneExist:
|
||||
setState(() {
|
||||
phoneExistError = true;
|
||||
});
|
||||
hasPhoneExist = true;
|
||||
break;
|
||||
case RegistrationValidationErrors.incorrectDateOfBirthFormat:
|
||||
setState(() {
|
||||
@@ -525,8 +579,236 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Устанавливаем verificationType согласно правилам:
|
||||
// - Если оба (email и phone) существуют -> 'email'
|
||||
// - Если только email -> 'email'
|
||||
// - Если только phone -> 'phone'
|
||||
if (hasEmailExist || hasPhoneExist) {
|
||||
setState(() {
|
||||
emailExistError = hasEmailExist;
|
||||
phoneExistError = hasPhoneExist;
|
||||
showVerificationButton = true;
|
||||
if (hasEmailExist && hasPhoneExist) {
|
||||
verificationType = 'email'; // Если оба существуют, используем email
|
||||
} else if (hasEmailExist) {
|
||||
verificationType = 'email'; // Только email
|
||||
} else {
|
||||
verificationType = 'phone'; // Только phone
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Если есть ошибки, возвращаемся и не показываем toast об успехе
|
||||
return;
|
||||
}
|
||||
// Регистрация успешна только если нет ошибок
|
||||
showSuccessToast('Регистрация успешна');
|
||||
}
|
||||
|
||||
Future<void> _handleVerificationRequest(BuildContext context, String type) async {
|
||||
try {
|
||||
// Используем service locator для доступа к OAuthCubit
|
||||
final oauthCubit = CubitRegistrations.sl<OAuthCubit>();
|
||||
// Всегда передаем и email и phone
|
||||
final String email = emailController.text;
|
||||
final String phone = ValidationUtils.normalizePhone(phoneController.text);
|
||||
|
||||
final response = await oauthCubit.requestOAuthVerification(email: email, phone: phone);
|
||||
|
||||
if (response != null && context.mounted) {
|
||||
// Показываем диалог для ввода кода
|
||||
final displayText = response.email;
|
||||
_showVerificationCodeDialog(context, type, displayText);
|
||||
} else if ((emailController.text.isEmpty || phoneController.text.isEmpty) && context.mounted) {
|
||||
showErrorToast('Пожалуйста, заполните все поля');
|
||||
} else if (context.mounted) {
|
||||
showErrorToast('Не удалось отправить код верификации');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
showErrorToast('Ошибка: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showVerificationCodeDialog(BuildContext context, String verificationType, String displayText) {
|
||||
final List<FocusNode> focusNodes = List.generate(4, (_) => FocusNode());
|
||||
final List<TextEditingController> controllers = List.generate(4, (_) => TextEditingController());
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Подтверждение аккаунта'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Код отправлен на $displayText'),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(4, (index) {
|
||||
return SizedBox(
|
||||
width: 50,
|
||||
child: TextField(
|
||||
controller: controllers[index],
|
||||
focusNode: focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
decoration: const InputDecoration(counterText: '', border: OutlineInputBorder()),
|
||||
onChanged: (value) {
|
||||
if (value.isNotEmpty && index < 3) {
|
||||
focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
if (value.isEmpty && index > 0) {
|
||||
focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
// Проверяем, заполнен ли весь код
|
||||
final code = controllers.map((c) => c.text).join();
|
||||
if (code.length == 4) {
|
||||
// Небольшая задержка перед отправкой, чтобы пользователь увидел последний символ
|
||||
Future.delayed(const Duration(milliseconds: 100), () async {
|
||||
if (dialogContext.mounted && context.mounted) {
|
||||
await _handleVerificationConfirm(context, dialogContext, verificationType, code, controllers, focusNodes);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
for (var controller in controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (var node in focusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleVerificationConfirm(
|
||||
BuildContext widgetContext,
|
||||
BuildContext dialogContext,
|
||||
String verificationType,
|
||||
String code,
|
||||
List<TextEditingController> controllers,
|
||||
List<FocusNode> focusNodes,
|
||||
) async {
|
||||
try {
|
||||
// Используем service locator для доступа к OAuthCubit, так как контекст из диалога может не иметь доступа к провайдеру
|
||||
final oauthCubit = CubitRegistrations.sl<OAuthCubit>();
|
||||
final authResult = widget.authResult;
|
||||
|
||||
if (authResult == null) {
|
||||
showErrorToast('Ошибка: данные авторизации не найдены');
|
||||
return;
|
||||
}
|
||||
|
||||
// Всегда передаем и email и phone (полный набор данных от провайдера)
|
||||
// Используем данные из формы, если они есть, иначе из authResult
|
||||
final String email = emailController.text.isNotEmpty ? emailController.text : (authResult.email ?? '');
|
||||
final String phone = phoneController.text.isNotEmpty ? ValidationUtils.normalizePhone(phoneController.text) : ValidationUtils.normalizePhone(authResult.phone ?? '');
|
||||
|
||||
// Проверяем, что данные есть
|
||||
if (email.isEmpty && phone.isEmpty) {
|
||||
log('OAuth Verification Confirm: Both email and phone are empty');
|
||||
showErrorToast('Ошибка: не указан email или телефон');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что все необходимые данные от провайдера есть
|
||||
if (authResult.accessToken.isEmpty || authResult.clientId.isEmpty || authResult.userId.isEmpty) {
|
||||
log('OAuth Verification Confirm: Missing required OAuth data');
|
||||
showErrorToast('Ошибка: неполные данные авторизации');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что код не пустой
|
||||
if (code.isEmpty || code.length != 4) {
|
||||
showErrorToast('Введите полный код');
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем данные профиля из формы
|
||||
final String firstName = firstNameController.text.isNotEmpty ? firstNameController.text : (authResult.firstName ?? '');
|
||||
final String lastName = lastNameController.text.isNotEmpty ? lastNameController.text : (authResult.lastName ?? '');
|
||||
final String dateOfBirth = dateOfBirthController.text.isNotEmpty ? dateOfBirthController.text : (authResult.birthDate ?? '');
|
||||
final String gender = isMaleSelected ? 'male' : 'female';
|
||||
final int weight = weightController.text.isNotEmpty ? (int.tryParse(weightController.text) ?? 0) : 0;
|
||||
final int height = heightController.text.isNotEmpty ? (int.tryParse(heightController.text) ?? 0) : 0;
|
||||
|
||||
// Проверяем, что все обязательные поля заполнены
|
||||
if (firstName.isEmpty || lastName.isEmpty || dateOfBirth.isEmpty || weight == 0 || height == 0) {
|
||||
log('OAuth Verification Confirm: Missing required profile data');
|
||||
showErrorToast('Ошибка: заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
// Логируем перед отправкой запроса
|
||||
log('OAuth Verification Confirm: provider=${widget.provider}, verificationType=$verificationType, code=$code, email=$email, phone=$phone');
|
||||
|
||||
// Отправляем запрос на подтверждение с полным набором данных от провайдера
|
||||
await oauthCubit.confirmOAuthVerification(
|
||||
provider: widget.provider ?? 'social',
|
||||
accessToken: authResult.accessToken,
|
||||
refreshToken: authResult.refreshToken ?? '',
|
||||
clientId: authResult.clientId,
|
||||
id: authResult.userId,
|
||||
email: email,
|
||||
phone: phone,
|
||||
code: code,
|
||||
verificationType: verificationType,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
weight: weight,
|
||||
height: height,
|
||||
);
|
||||
|
||||
// Закрываем диалог
|
||||
for (var controller in controllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (var node in focusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
|
||||
// Перезагружаем веб-сокет и переходим на главный экран
|
||||
if (widgetContext.mounted) {
|
||||
_reconnectWebSocket(widgetContext);
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);
|
||||
if (widgetContext.mounted) {
|
||||
showErrorToast('Неверный код. Попробуйте снова.');
|
||||
}
|
||||
// Очищаем поля
|
||||
for (var controller in controllers) {
|
||||
controller.clear();
|
||||
}
|
||||
if (focusNodes.isNotEmpty) {
|
||||
focusNodes[0].requestFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.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/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||
@@ -76,9 +75,6 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
|
||||
try {
|
||||
await sl<ProfileAvatarCubit>().getMyProfile();
|
||||
await sl<ChatsCubit>().getChatRoomsWithLoading();
|
||||
await sl<ChatsCubit>().connectToWebSocket();
|
||||
await sl<NotificationsCubit>().getNotifications();
|
||||
await sl<NotificationsCubit>().connectToWebSocket();
|
||||
await sl<FirebaseApi>().registerPushToken();
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import 'dart:developer';
|
||||
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:eatvio_app/features/auth/models/token_model.dart';
|
||||
import 'package:eatvio_app/features/auth/services/auth_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class AuthRepository {
|
||||
final AuthService authService;
|
||||
@@ -14,12 +19,12 @@ class AuthRepository {
|
||||
return await authService.getTokenByCredentials(
|
||||
credentials: CredentialsModel(email: email, password: password),
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<TokenModel> signInViaSocialMedia({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
@@ -30,7 +35,8 @@ class AuthRepository {
|
||||
return await authService.signInViaSocialMedia(
|
||||
body: OAuthLoginRequest(provider: provider, accessToken: accessToken, refreshToken: refreshToken, clientId: clientId),
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +75,73 @@ class AuthRepository {
|
||||
),
|
||||
))['token']!
|
||||
as String;
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
|
||||
try {
|
||||
Map<String, dynamic>? body;
|
||||
if (email != null) {
|
||||
body = {'email': email};
|
||||
} else if (phone != null) {
|
||||
body = {'phone': phone};
|
||||
}
|
||||
if (body != null) {
|
||||
return await authService.requestOAuthVerification(body: body);
|
||||
}
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<OAuthVerificationConfirmResponse> confirmOAuthVerification({
|
||||
required String provider,
|
||||
required String accessToken,
|
||||
required String refreshToken,
|
||||
required String clientId,
|
||||
required String id,
|
||||
String? email,
|
||||
String? phone,
|
||||
required String code,
|
||||
required String verificationType,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String dateOfBirth,
|
||||
required String gender,
|
||||
required int weight,
|
||||
required int height,
|
||||
}) async {
|
||||
try {
|
||||
final request = OAuthVerificationConfirmRequest(
|
||||
provider: provider,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
clientId: clientId,
|
||||
id: id,
|
||||
email: email,
|
||||
phone: phone,
|
||||
code: code,
|
||||
verificationType: verificationType,
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
dateOfBirth: dateOfBirth,
|
||||
gender: gender,
|
||||
weight: weight,
|
||||
height: height,
|
||||
);
|
||||
// Логируем запрос перед отправкой
|
||||
log(
|
||||
'AuthRepository: Sending OAuth verification confirm request: provider=$provider, verificationType=$verificationType, code=$code, email=$email, phone=$phone',
|
||||
);
|
||||
return await authService.confirmOAuthVerification(body: request);
|
||||
} catch (e, s) {
|
||||
log('AuthRepository: Error in confirmOAuthVerification: $e');
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/features/auth/services/reset_password_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class ResetPasswordRepository {
|
||||
final ResetPasswordService resetPasswordService;
|
||||
@@ -20,7 +21,8 @@ class ResetPasswordRepository {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +48,8 @@ class ResetPasswordRepository {
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
import 'package:vk_id/vk_id.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
@@ -40,7 +42,8 @@ class SocialAuthFacade {
|
||||
return null;
|
||||
}
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier, clientId: clientIdStr);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -156,8 +159,9 @@ class SocialAuthFacade {
|
||||
birthDate: yandexProfileData.birthday,
|
||||
clientId: clientId,
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
log(e.toString());
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -204,7 +208,8 @@ class SocialAuthFacade {
|
||||
if (code == null || deviceId == null) return null;
|
||||
|
||||
return await _parseVKAuthResult(redirectParams: redirectParams, controller: controller, codeVerifier: codeVerifier, clientId: clientIdStr);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -255,8 +260,9 @@ class SocialAuthFacade {
|
||||
if (!context.mounted) return null;
|
||||
return showDialog<Map<String, String>>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) {
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
final s = S.of(dialogContext);
|
||||
const borderRadius = BorderRadius.all(Radius.circular(16));
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
@@ -264,7 +270,21 @@ class SocialAuthFacade {
|
||||
elevation: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: SizedBox(height: 560, child: WebViewWidget(controller: controller)),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
title: Text(s.authorization),
|
||||
),
|
||||
body: WebViewWidget(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/auth/models/credentials_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_model.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_login_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:eatvio_app/features/auth/models/token_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
@@ -19,4 +22,10 @@ abstract class AuthService {
|
||||
|
||||
@POST('/api/oauth/login')
|
||||
Future<TokenModel> signInViaSocialMedia({@Body() required OAuthLoginRequest body});
|
||||
|
||||
@POST('/api/oauth/verification/request')
|
||||
Future<OAuthVerificationRequestResponse> requestOAuthVerification({@Body() required Map<String, dynamic> body});
|
||||
|
||||
@POST('/api/oauth/verification/confirm')
|
||||
Future<OAuthVerificationConfirmResponse> confirmOAuthVerification({@Body() required OAuthVerificationConfirmRequest body});
|
||||
}
|
||||
|
||||
29
lib/features/barcode/models/barcode_product_model.dart
Normal file
29
lib/features/barcode/models/barcode_product_model.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'barcode_product_model.freezed.dart';
|
||||
part 'barcode_product_model.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class BarcodeProductModel with _$BarcodeProductModel {
|
||||
const factory BarcodeProductModel({
|
||||
// ignore: non_constant_identifier_names
|
||||
required String GTIN,
|
||||
@JsonKey(name: 'Товарный знак (ru)') String? brandName,
|
||||
@JsonKey(name: 'Описание (ru)') String? description,
|
||||
@JsonKey(name: 'Количество/вес') String? quantity,
|
||||
@JsonKey(name: 'Рынок сбыта') String? market,
|
||||
@JsonKey(name: 'Калорийность') double? calories,
|
||||
@JsonKey(name: 'Белки') double? proteins,
|
||||
@JsonKey(name: 'Жиры') double? fats,
|
||||
@JsonKey(name: 'Углеводы') double? carbohydrates,
|
||||
}) = _BarcodeProductModel;
|
||||
|
||||
factory BarcodeProductModel.fromJson(Map<String, Object?> json) => _$BarcodeProductModelFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class BarcodeTaskStatusModel with _$BarcodeTaskStatusModel {
|
||||
const factory BarcodeTaskStatusModel({String? status, @JsonKey(name: 'task_id') String? taskId, BarcodeProductModel? data}) = _BarcodeTaskStatusModel;
|
||||
|
||||
factory BarcodeTaskStatusModel.fromJson(Map<String, Object?> json) => _$BarcodeTaskStatusModelFromJson(json);
|
||||
}
|
||||
112
lib/features/barcode/repositories/barcode_repository.dart
Normal file
112
lib/features/barcode/repositories/barcode_repository.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/barcode/models/barcode_product_model.dart';
|
||||
import 'package:eatvio_app/features/barcode/services/barcode_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class BarcodeRepository {
|
||||
final BarcodeService barcodeService;
|
||||
final Dio dio;
|
||||
|
||||
BarcodeRepository({
|
||||
required this.barcodeService,
|
||||
required this.dio,
|
||||
});
|
||||
|
||||
/// Получить информацию о продукте по штрихкоду
|
||||
/// Возвращает либо данные продукта, либо статус задачи обработки
|
||||
Future<BarcodeResult> getProductByBarcode(String barcode) async {
|
||||
try {
|
||||
final response = await dio.get('/api/gsone/barcode/$barcode');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Данные найдены в БД
|
||||
final product = BarcodeProductModel.fromJson(response.data as Map<String, dynamic>);
|
||||
return BarcodeResult.success(product);
|
||||
} else if (response.statusCode == 202) {
|
||||
// Запущена задача обработки
|
||||
final taskStatus = BarcodeTaskStatusModel.fromJson(response.data as Map<String, dynamic>);
|
||||
return BarcodeResult.processing(taskStatus.taskId ?? '');
|
||||
}
|
||||
|
||||
throw Exception('Unexpected status code: ${response.statusCode}');
|
||||
} on DioException catch (e, s) {
|
||||
if (e.response?.statusCode == 202) {
|
||||
// Если получен статус 202, значит запущена задача обработки
|
||||
final taskStatus = BarcodeTaskStatusModel.fromJson(e.response!.data as Map<String, dynamic>);
|
||||
return BarcodeResult.processing(taskStatus.taskId ?? '');
|
||||
}
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить статус задачи обработки
|
||||
Future<BarcodeTaskStatus> getTaskStatus(String taskId) async {
|
||||
try {
|
||||
final statusModel = await barcodeService.getTaskStatus(taskId: taskId);
|
||||
|
||||
if (statusModel.status == 'completed' && statusModel.data != null) {
|
||||
return BarcodeTaskStatus.completed(statusModel.data!);
|
||||
} else if (statusModel.status == 'failed') {
|
||||
return BarcodeTaskStatus.failed();
|
||||
} else {
|
||||
return BarcodeTaskStatus.processing();
|
||||
}
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Результат запроса информации о продукте по штрихкоду
|
||||
class BarcodeResult {
|
||||
final BarcodeResultType type;
|
||||
final BarcodeProductModel? product;
|
||||
final String? taskId;
|
||||
|
||||
BarcodeResult._({required this.type, this.product, this.taskId});
|
||||
|
||||
factory BarcodeResult.success(BarcodeProductModel product) {
|
||||
return BarcodeResult._(type: BarcodeResultType.success, product: product);
|
||||
}
|
||||
|
||||
factory BarcodeResult.processing(String taskId) {
|
||||
return BarcodeResult._(type: BarcodeResultType.processing, taskId: taskId);
|
||||
}
|
||||
}
|
||||
|
||||
enum BarcodeResultType {
|
||||
success,
|
||||
processing,
|
||||
}
|
||||
|
||||
/// Статус задачи обработки
|
||||
class BarcodeTaskStatus {
|
||||
final BarcodeTaskStatusType type;
|
||||
final BarcodeProductModel? product;
|
||||
|
||||
BarcodeTaskStatus._({required this.type, this.product});
|
||||
|
||||
factory BarcodeTaskStatus.completed(BarcodeProductModel product) {
|
||||
return BarcodeTaskStatus._(type: BarcodeTaskStatusType.completed, product: product);
|
||||
}
|
||||
|
||||
factory BarcodeTaskStatus.processing() {
|
||||
return BarcodeTaskStatus._(type: BarcodeTaskStatusType.processing);
|
||||
}
|
||||
|
||||
factory BarcodeTaskStatus.failed() {
|
||||
return BarcodeTaskStatus._(type: BarcodeTaskStatusType.failed);
|
||||
}
|
||||
}
|
||||
|
||||
enum BarcodeTaskStatusType {
|
||||
completed,
|
||||
processing,
|
||||
failed,
|
||||
}
|
||||
|
||||
24
lib/features/barcode/services/barcode_service.dart
Normal file
24
lib/features/barcode/services/barcode_service.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/barcode/models/barcode_product_model.dart';
|
||||
import 'package:retrofit/retrofit.dart';
|
||||
|
||||
part 'barcode_service.g.dart';
|
||||
|
||||
@RestApi()
|
||||
abstract class BarcodeService {
|
||||
factory BarcodeService(Dio dio, {String baseUrl}) = _BarcodeService;
|
||||
|
||||
@GET('/api/gsone/barcode/{barcode}')
|
||||
Future<BarcodeProductModel> getProductByBarcode({@Path('barcode') required String barcode});
|
||||
|
||||
@GET('/api/gsone/barcode/status/{task_id}')
|
||||
Future<BarcodeTaskStatusModel> getTaskStatus({@Path('task_id') required String taskId});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
@@ -13,17 +13,17 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id});
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -78,96 +78,100 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - (replyId == null ? 64 : 104),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: 'Комментарий',
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.id.toString(),
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.blogPost,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.id.toString(),
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.blogPost,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
appBar: AppBar(
|
||||
@@ -194,7 +198,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 80),
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -233,34 +237,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Html(
|
||||
data: md.markdownToHtml(blogPostState.blogPostModel!.description.replaceAll('\n', '<br>')),
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -326,7 +304,13 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
const SizedBox(height: 16),
|
||||
if (state is CommentsStateLoading) const Loader(),
|
||||
if (state is CommentsStateLoaded)
|
||||
CommentsWidget(id: widget.id.toString(), commentSourceType: CommentSourceType.blogPost, comment: state.comment, replyId: replyId),
|
||||
CommentsWidget(
|
||||
id: widget.id.toString(),
|
||||
commentSourceType: CommentSourceType.blogPost,
|
||||
comment: state.comment,
|
||||
replyId: replyId,
|
||||
highlightedCommentId: widget.highlightedCommentId,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -568,6 +568,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
CustomTextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, controller: videoUrlController),
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -599,7 +600,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
showErrorDialog(context, title: S.current.could_not_update_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
context.pop();
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final result = await blogPostCubit.createBlogPost(
|
||||
@@ -616,7 +619,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
showErrorDialog(context, title: S.current.could_not_publish_blog_post);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
context.pop();
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class BlogPostRepository {
|
||||
final BlogPostService blogPostService;
|
||||
@@ -29,9 +30,14 @@ class BlogPostRepository {
|
||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
||||
});
|
||||
|
||||
return await blogPostService.createBlogPost(
|
||||
formData: formData,
|
||||
);
|
||||
try {
|
||||
return await blogPostService.createBlogPost(
|
||||
formData: formData,
|
||||
);
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<XFile?> downloadImage(String imageUrl) async {
|
||||
@@ -54,8 +60,9 @@ class BlogPostRepository {
|
||||
log('Ошибка загрузки изображения: ${response.statusCode}');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
log('Произошла ошибка: $e');
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -79,11 +86,23 @@ class BlogPostRepository {
|
||||
: null,
|
||||
});
|
||||
|
||||
return await blogPostService.updateBlogPost(
|
||||
id: id,
|
||||
formData: formData,
|
||||
);
|
||||
try {
|
||||
return await blogPostService.updateBlogPost(
|
||||
id: id,
|
||||
formData: formData,
|
||||
);
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteBlogPost(int id) async => await blogPostService.deleteBlogPost(id: id);
|
||||
Future<void> deleteBlogPost(int id) async {
|
||||
try {
|
||||
await blogPostService.deleteBlogPost(id: id);
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,20 +36,24 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
|
||||
|
||||
Future<void> getMyProfile() async {
|
||||
emit(const ProfileAvatarState.loading());
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
final profileDetails = await profileRepository.getMyProfileDetails();
|
||||
try {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
final profileDetails = await profileRepository.getMyProfileDetails();
|
||||
emit(ProfileAvatarState.loaded(profile: profile));
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
final isNotificationEnabled = notificationStatus.isGranted;
|
||||
AppMetricaUserProfile userProfile = AppMetricaUserProfile([
|
||||
AppMetricaNameAttribute.withValue(profile.name),
|
||||
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female),
|
||||
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled),
|
||||
]);
|
||||
AppMetrica.setUserProfileID(profile.slug);
|
||||
AppMetrica.reportUserProfile(userProfile);
|
||||
try {
|
||||
final notificationStatus = await Permission.notification.status;
|
||||
final isNotificationEnabled = notificationStatus.isGranted;
|
||||
AppMetricaUserProfile userProfile = AppMetricaUserProfile([
|
||||
AppMetricaNameAttribute.withValue(profile.name),
|
||||
AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female),
|
||||
AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)),
|
||||
AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled),
|
||||
]);
|
||||
AppMetrica.setUserProfileID(profile.slug);
|
||||
AppMetrica.reportUserProfile(userProfile);
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов, профиль уже загружен
|
||||
}
|
||||
} catch (e) {
|
||||
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
|
||||
emit(ProfileAvatarState.error(errorMessage: e.toString()));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BottomNavController {
|
||||
BottomNavController();
|
||||
|
||||
void Function(String path, Object? extra)? _onSwitchToRoute;
|
||||
|
||||
void register(void Function(String path, Object? extra) callback) {
|
||||
_onSwitchToRoute = callback;
|
||||
}
|
||||
|
||||
void switchToRoute(String path, [Object? extra]) {
|
||||
_onSwitchToRoute?.call(path, extra);
|
||||
}
|
||||
}
|
||||
|
||||
class BottomNavScope extends InheritedWidget {
|
||||
const BottomNavScope({
|
||||
required this.controller,
|
||||
required super.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BottomNavController controller;
|
||||
|
||||
static BottomNavController? maybeOf(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<BottomNavScope>()?.controller;
|
||||
}
|
||||
|
||||
static BottomNavController of(BuildContext context) {
|
||||
final controller = maybeOf(context);
|
||||
assert(controller != null, 'No BottomNavScope found in context');
|
||||
return controller!;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(BottomNavScope oldWidget) => controller != oldWidget.controller;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.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/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -16,52 +19,36 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BottomNavigationBarWidget extends StatefulWidget {
|
||||
final ProfileModel profileModel;
|
||||
final Widget child;
|
||||
|
||||
const BottomNavigationBarWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.profileModel,
|
||||
required this.currentLocation,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
int currentBottomNavBarIndex = 1;
|
||||
late final List<String> pages;
|
||||
List<String>? pages;
|
||||
int tapsCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.profileModel.roles.contains('consultant')) {
|
||||
pages = [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
} else {
|
||||
pages = [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) async {
|
||||
if (pages.length == 7) {
|
||||
if (pages == null) return;
|
||||
if (pages!.length == 7) {
|
||||
if (index == 6) {
|
||||
tapsCount++;
|
||||
if (tapsCount == 7) {
|
||||
@@ -82,184 +69,223 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
setState(() {
|
||||
currentBottomNavBarIndex = index;
|
||||
});
|
||||
GoRouter.of(context).replace(pages[currentBottomNavBarIndex]);
|
||||
GoRouter.of(context).replace(pages![currentBottomNavBarIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to the tab that corresponds to [path], optionally with [extra]
|
||||
/// (e.g. date for diary). If [path] is not in the current tab list, falls back to push.
|
||||
void _switchToRoute(String path, [Object? extra]) {
|
||||
if (pages == null) return;
|
||||
final index = pages!.indexOf(path);
|
||||
if (index >= 0 && index != currentBottomNavBarIndex) {
|
||||
setState(() {
|
||||
currentBottomNavBarIndex = index;
|
||||
});
|
||||
GoRouter.of(context).replace(path, extra: extra);
|
||||
} else if (index >= 0) {
|
||||
// Same tab but with new extra (e.g. diary with date)
|
||||
GoRouter.of(context).replace(path, extra: extra);
|
||||
} else {
|
||||
GoRouter.of(context).push(path, extra: extra);
|
||||
}
|
||||
}
|
||||
|
||||
static const Color _devAccentColor = Color(0xFFF59E0B); // amber-500
|
||||
|
||||
Widget _buildLogoIcon() {
|
||||
final isProduction = sl<AppConfig>().baseUrl == EnvConfig.baseUrl;
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
),
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
onTap: _onItemTapped,
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
selectedItemColor: appTheme.blueA400,
|
||||
currentIndex: currentBottomNavBarIndex,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
widget.profileModel.profilePhotoUrl.contains('https') ? widget.profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + widget.profileModel.profilePhotoUrl,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.profileModel.roles.contains('consultant'))
|
||||
BottomNavigationBarItem(
|
||||
label: 'Консультации',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages ? Colors.white : Colors.transparent,
|
||||
fontSize: 8,
|
||||
return BlocBuilder<ProfileAvatarCubit, ProfileAvatarState>(
|
||||
builder: (BuildContext context, ProfileAvatarState state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
final profileModel = state.profile;
|
||||
final isConsultant = profileModel.roles.contains('consultant');
|
||||
|
||||
// Инициализируем pages на основе profileModel
|
||||
pages ??= _getPages(isConsultant);
|
||||
|
||||
// Expose tab switching to descendants (e.g. notification widget)
|
||||
final controller = BottomNavScope.maybeOf(context);
|
||||
if (controller != null) {
|
||||
controller.register(_switchToRoute);
|
||||
}
|
||||
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
onTap: _onItemTapped,
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
selectedItemColor: appTheme.blueA400,
|
||||
currentIndex: effectiveIndex,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
child: Padding(
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages[currentBottomNavBarIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Уведомления',
|
||||
icon: BlocBuilder<NotificationsCubit, NotificationsState>(
|
||||
builder: (BuildContext context, NotificationsState state) {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages[currentBottomNavBarIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
BottomNavigationBarItem(
|
||||
label: 'Консультации',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
BottomNavigationBarItem(
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Уведомления',
|
||||
icon: BlocBuilder<NotificationsCubit, NotificationsState>(
|
||||
builder: (BuildContext context, NotificationsState state) {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(label: '', icon: _buildLogoIcon()),
|
||||
],
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Показываем child даже когда профиль еще загружается, чтобы экраны могли инициализироваться
|
||||
return Scaffold(body: widget.child);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/features/calendar/models/calendar_model.dart';
|
||||
import 'package:eatvio_app/features/calendar/services/calendar_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class CalendarRepository {
|
||||
final CalendarService calendarService;
|
||||
@@ -9,7 +10,8 @@ class CalendarRepository {
|
||||
Future<List<CalendarModel>> getCalendar() async {
|
||||
try {
|
||||
return await calendarService.getCalendar();
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/features/cancel_plan_bottom_sheet/models/cancelation_model.dart';
|
||||
import 'package:eatvio_app/features/cancel_plan_bottom_sheet/service/cancel_plan_service.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
class CancelPlanRepository {
|
||||
final CancelPlanService cancelService;
|
||||
@@ -10,7 +11,8 @@ class CancelPlanRepository {
|
||||
}) async {
|
||||
try {
|
||||
await cancelService.cancelChosenPlan(id: id, body: body);
|
||||
} catch (e) {
|
||||
} catch (e, s) {
|
||||
await Sentry.captureException(e, stackTrace: s);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,36 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'chat_message_model.freezed.dart';
|
||||
part 'chat_message_model.g.dart';
|
||||
|
||||
int _parseAuthorId(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is num) return json.toInt();
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime _parseUpdatedAt(dynamic json) {
|
||||
if (json == null) return DateTime.now();
|
||||
if (json is String) return parseDate(json);
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
String _parseData(dynamic json) {
|
||||
if (json == null) return '';
|
||||
return json.toString();
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
const factory ChatMessageModel({
|
||||
required int id,
|
||||
required String data,
|
||||
@JsonKey(name: 'data', fromJson: _parseData) required String data,
|
||||
@JsonKey(name: 'media_data') required List<dynamic>? mediaData,
|
||||
@JsonKey(name: 'author_id') required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: parseDate) required DateTime updatedAt,
|
||||
@JsonKey(name: 'urls_media_info') List<Map<String, dynamic>>? urlsMediaInfo,
|
||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type') String? type,
|
||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||
}) = _ChatMessageModel;
|
||||
|
||||
@@ -31,18 +31,18 @@ abstract class ChatRoom with _$ChatRoom {
|
||||
}
|
||||
|
||||
ChatMessageModel? parseLastMessage(dynamic json) {
|
||||
if (json is List) {
|
||||
if (json == null || json is List) {
|
||||
return null;
|
||||
}
|
||||
return ChatMessageModel.fromJson(json);
|
||||
if (json is Map<String, dynamic>) {
|
||||
return ChatMessageModel.fromJson(json);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class Users with _$Users {
|
||||
const factory Users({
|
||||
@JsonKey(name: "from_chat_user") required ChatUser fromChatUser,
|
||||
@JsonKey(name: "to_chat_user") required ChatUser toChatUser,
|
||||
}) = _Users;
|
||||
const factory Users({@JsonKey(name: "from_chat_user") required ChatUser fromChatUser, @JsonKey(name: "to_chat_user") required ChatUser toChatUser}) = _Users;
|
||||
|
||||
factory Users.fromJson(Map<String, dynamic> json) => _$UsersFromJson(json);
|
||||
}
|
||||
|
||||
@@ -164,9 +164,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
List<ChatMessageModel> messagesList = [message, ...(state as ChatRoomStateLoaded).chatMessages];
|
||||
final currentState = state as ChatRoomStateLoaded;
|
||||
|
||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
||||
// они обрабатываются через ответ API в sendMessage
|
||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
||||
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: messagesList, isLoading: isLoading));
|
||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
||||
});
|
||||
|
||||
@@ -185,6 +191,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
|
||||
// Создаем временное сообщение с состоянием loading
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: [
|
||||
@@ -206,12 +214,25 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
try {
|
||||
await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
return elem.copyWith(messageState: MessageState.sent);
|
||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
||||
return serverMessage.copyWith(
|
||||
uuid: messageUuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
@@ -320,6 +341,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (message != null) {
|
||||
try {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
List<File>? filesToResend;
|
||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||
// Если mediaData содержит File объекты, используем их
|
||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
||||
final firstItem = message.mediaData!.first;
|
||||
if (firstItem is File) {
|
||||
filesToResend = message.mediaData!.cast<File>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: [
|
||||
@@ -341,12 +374,27 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
try {
|
||||
await chatRepository.sendMessage(data: message.data, files: message.mediaData as List<File>?, slug: userSlug);
|
||||
// Отправляем сообщение и получаем ответ от сервера
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: message.data,
|
||||
files: filesToResend,
|
||||
slug: userSlug,
|
||||
);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return elem.copyWith(messageState: MessageState.sent);
|
||||
return serverMessage.copyWith(
|
||||
uuid: uuid,
|
||||
messageState: MessageState.sent,
|
||||
isCurrentUserAuthor: true,
|
||||
isRead: elem.isRead,
|
||||
roomId: elem.roomId ?? serverMessage.roomId,
|
||||
authorId: serverMessage.authorId,
|
||||
updatedAt: serverMessage.updatedAt,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
}).toList(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
|
||||
@@ -221,14 +222,36 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(state.chatMessages[index].updatedAt),
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(state.chatMessages[index].id.toString()),
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!state.chatMessages[index].isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIdsCopy.add((id: state.chatMessages[index].id, index: index));
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
||||
if (!result) return;
|
||||
@@ -242,44 +265,76 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
},
|
||||
child: ChatBubbleWidget(
|
||||
isRead: state.chatMessages[index].isRead,
|
||||
sendTime: state.chatMessages[index].updatedAt,
|
||||
text: state.chatMessages[index].data.trim(),
|
||||
isRead: message.isRead,
|
||||
sendTime: message.updatedAt,
|
||||
text: message.data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: state.chatMessages[index].mediaData ?? [],
|
||||
messageState: state.chatMessages[index].messageState,
|
||||
messageUuid: state.chatMessages[index].uuid ?? '',
|
||||
mediaData: message.mediaData ?? [],
|
||||
messageState: message.messageState,
|
||||
messageUuid: message.uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
messageType: message.type,
|
||||
isConsultant: amIConsultant,
|
||||
urlsMediaInfo: message.urlsMediaInfo,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
return VisibilityDetector(
|
||||
key: Key(state.chatMessages[index].id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!state.chatMessages[index].isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIds.add((id: state.chatMessages[index].id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
||||
unreadMessagesIds.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ChatBubbleWidget(
|
||||
isRead: state.chatMessages[index].isRead,
|
||||
sendTime: state.chatMessages[index].updatedAt,
|
||||
text: state.chatMessages[index].data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: state.chatMessages[index].mediaData ?? [],
|
||||
messageState: state.chatMessages[index].messageState,
|
||||
messageUuid: state.chatMessages[index].uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
),
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIds.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
||||
unreadMessagesIds.clear();
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ChatBubbleWidget(
|
||||
isRead: message.isRead,
|
||||
sendTime: message.updatedAt,
|
||||
text: message.data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: message.mediaData ?? [],
|
||||
messageState: message.messageState,
|
||||
messageUuid: message.uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
messageType: message.type,
|
||||
isConsultant: amIConsultant,
|
||||
urlsMediaInfo: message.urlsMediaInfo,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
||||
@@ -407,34 +462,30 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: TextFormField(
|
||||
expands: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: textEditingController,
|
||||
maxLines: null,
|
||||
minLines: null,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: textEditingController,
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,7 +11,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class ChatsScreen extends StatefulWidget {
|
||||
const ChatsScreen({super.key});
|
||||
const ChatsScreen({super.key, this.initialSearchQuery});
|
||||
|
||||
/// When set (e.g. from notification tap), the search field is pre-filled
|
||||
/// and chat list is filtered by this query.
|
||||
final String? initialSearchQuery;
|
||||
|
||||
@override
|
||||
State<ChatsScreen> createState() => _ChatsScreenState();
|
||||
@@ -20,6 +24,24 @@ class ChatsScreen extends StatefulWidget {
|
||||
class _ChatsScreenState extends State<ChatsScreen> {
|
||||
final searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final query = widget.initialSearchQuery?.trim();
|
||||
if (query != null && query.isNotEmpty) {
|
||||
searchController.text = query;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cubit = context.read<ChatsCubit>();
|
||||
final state = cubit.state;
|
||||
if (state.state == BaseState.initial) {
|
||||
cubit.getChatRoomsWithLoading(query: query?.isNotEmpty == true ? query : null);
|
||||
} else if (query != null && query.isNotEmpty) {
|
||||
cubit.getChatRooms(query: query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatsCubit, ChatsState>(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user