34 Commits

Author SHA1 Message Date
2b2991012d merge upstream 2026-11-02 10:27:35 +00:00
7a456c2d9d Merge pull request 'Фикс перекрытия кнопки регистрации консультанта' (#1111) from alex_chevski/eatvio_app:fix/1107-consultant-bottom-button-inset into main
Reviewed-on: eatvio/eatvio_app#1111
Reviewed-by: misha <misha@noreply.localhost>
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-11-02 10:27:25 +00:00
36079de81c merge upstream 2026-11-02 10:00:33 +00:00
3825a974f9 Merge pull request 'открытие чата из консультации' (#1110) from alex_chevski/eatvio_app:fix/1106-consultation-chat into main
Reviewed-on: eatvio/eatvio_app#1110
Reviewed-by: misha <misha@noreply.localhost>
2026-11-02 10:00:24 +00:00
678d73932c merge upstream 2026-11-02 09:48:20 +00:00
8942e11be9 Merge pull request 'fix: android ci runner changed' (#1118) from misha/eatvio_app:android-ci-fix into main
Reviewed-on: eatvio/eatvio_app#1118
2026-11-02 09:48:08 +00:00
misha
55e1633d1c fix: android ci runner changed 2026-11-02 11:42:14 +03:00
2668907f60 merge upstream 2026-11-02 08:14:53 +00:00
56ab9b65f9 merge upstream 2026-11-02 08:14:46 +00:00
5b2ac4f228 Merge pull request 'Исправлен чужой аватар после смены аккаунта' (#1117) from fix/1113-stale-avatar-account-switch into main
Reviewed-on: eatvio/eatvio_app#1117
2026-11-02 08:14:04 +00:00
51aff6c175 test(session): cover avatar refresh and session teardown
Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency.

Refs: #1113
2026-10-30 15:15:36 +03:00
cba2689406 feat(session): centralize session teardown and refresh
Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session.

Refs: #1113
2026-10-30 15:15:28 +03:00
5f0b7e026b fix(profile): refresh bottom-nav avatar on account switch
Reset ProfileAvatarCubit and user-scoped feed, chat and notification state so stale data from the previous account cannot be rendered. Refresh the avatar when the stored token changes and rebuild nav tabs when the profile identity or consultant role changes.

Refs: #1113
2026-10-30 15:15:21 +03:00
85bc2c6d5c test(consultant): verify bottom action stays above system navigation area 2026-10-30 13:31:07 +03:00
f58892bdeb fix(consultant): keep registration button above system navigation bar 2026-10-30 13:31:05 +03:00
f14f426381 fix(consultations): open chat via consultation chat_id
Use chat_id from the consultations payload for direct navigation and keep
the chat lookup as a fallback for legacy rows without chat_id.

- validate chat id in ChatRepository and make it nullable
- guard repeated taps while opening chat
- handle nullable chat id in ProfileCubit and EmptyChatScreen

related to #1106
2026-10-29 18:09:08 +03:00
6a31880a85 fix(consultations): open chat from consultation card
The chat icon on the consultant consultation card had an empty onTap handler.
Resolve the conversation with the client via the chat API and navigate to it.

related to #1106
2026-10-29 17:27:12 +03:00
3a41a85f9d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-29 14:22:06 +03:00
194b8d5b16 Merge pull request 'feat(video_calls): app opening from terminated state fix' (#1109) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1109
2026-10-29 10:59:30 +00:00
misha
508c321913 version changed 2026-10-29 13:56:06 +03:00
misha
4a5c67632e feat(video_calls): app opening from terminated state fix 2026-10-29 13:55:38 +03:00
Anatoliy
f6f9764811 fix(ios): derive TestFlight build number from App Store Connect
The beta lane took the build number from pubspec (FLUTTER_BUILD_NUMBER).
Re-running CI on the same commit re-submitted an already-taken build
number and failed with 409 ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE
(run 2045: build 157 was already on TestFlight).

Now the lane queries the latest TestFlight build for the current
marketing version (falling back to the pubspec value when nothing was
uploaded yet) and uploads latest + 1, so retries can never collide.
2026-10-28 11:10:22 +03:00
e2d287eb9e Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-27 16:22:59 +03:00
cf5c42ff4b Merge pull request 'feat(video_calls): tokens registration' (#1104) from misha/eatvio_app:issue-1089 into main
Reviewed-on: eatvio/eatvio_app#1104
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-10-27 13:09:45 +00:00
misha
5e6e114d5d version changed 2026-10-27 14:08:05 +03:00
misha
e9cfcb9637 feat(video_calls): video calls tokens registration fix 2026-10-27 14:06:55 +03:00
28c62bf775 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-26 12:26:48 +03:00
dedb55ecca Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 15:17:38 +03:00
04dab02a6d Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-23 12:37:10 +03:00
db293bf13f Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 16:23:38 +03:00
fe54037cef Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-22 13:11:31 +03:00
c70c9beb20 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:55:55 +03:00
37a787c8d0 Merge branch 'main' of ssh://10.55.5.123:222/eatvio/eatvio_app 2026-10-21 17:03:44 +03:00
3b8d1bf651 Merge branch 'issue-1089' of ssh://10.55.5.123:222/misha/eatvio_app 2026-10-21 14:31:39 +03:00
69 changed files with 2079 additions and 5042 deletions

View File

@@ -1,64 +0,0 @@
name: "Contract sync: flutter manifest -> eatvio-web"
# Автоматический перенос манифеста API-контракта из eatvio_app в eatvio-web.
# Триггер: push в main с изменениями api_contract/**.
# Результат: PR в eatvio-web с обновлённым tests/Fixtures/Contract/flutter_model_contract.json.
# Далее CI eatvio-web сам прогонит FlutterApiContractTest и покажет,
# выполняет ли бэкенд требования новых моделей.
on:
push:
branches:
- main
paths:
- "api_contract/**"
workflow_dispatch: # ручной запуск для проверки секрета/прав: Actions → Contract sync → Run workflow
jobs:
sync-manifest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Open PR in eatvio-web with updated manifest
env:
TOKEN: ${{ secrets.CONTRACT_SYNC_TOKEN }} # PAT с write на eatvio/eatvio-web
API: http://gitea.office.local:3000/api/v1
BACKEND_REPO: eatvio/eatvio-web
SRC: api_contract/flutter_model_contract.json
DST: tests/Fixtures/Contract/flutter_model_contract.json
run: |
set -e
SHORT_SHA="${GITHUB_SHA::8}"
BRANCH="contract-sync/manifest-${SHORT_SHA}"
auth="Authorization: token ${TOKEN}"
DEFAULT_BRANCH=$(curl -sf -H "$auth" "${API}/repos/${BACKEND_REPO}" | grep -o '"default_branch":"[^"]*"' | head -1 | cut -d'"' -f4)
# Файл не изменился относительно предыдущего синка — выходим тихо.
REMOTE_SHA=$(curl -sf -H "$auth" "${API}/repos/${BACKEND_REPO}/contents/${DST}?ref=${DEFAULT_BRANCH}" | grep -o '"sha":"[^"]*"' | head -1 | cut -d'"' -f4)
LOCAL_SHA=$(git hash-object -- "${SRC}")
if [ "${REMOTE_SHA}" = "${LOCAL_SHA}" ]; then
echo "Manifest already in sync with ${BACKEND_REPO}."
exit 0
fi
# 1. Ветка от дефолтной (если уже существует — переиспользуем).
curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"new_branch_name\":\"${BRANCH}\",\"old_branch_name\":\"${DEFAULT_BRANCH}\"}" \
"${API}/repos/${BACKEND_REPO}/branches" || echo "branch ${BRANCH} already exists"
# 2. Обновить файл (contents API требует sha текущего файла).
CONTENT_B64=$(base64 -w0 "${SRC}")
curl -sf -X PUT -H "$auth" -H "Content-Type: application/json" \
-d "{\"content\":\"${CONTENT_B64}\",\"message\":\"chore(contract): sync flutter manifest (${SHORT_SHA})\",\"sha\":\"${REMOTE_SHA}\",\"branch\":\"${BRANCH}\"}" \
"${API}/repos/${BACKEND_REPO}/contents/${DST}" > /dev/null
# 3. PR (если уже открыт — пропускаем).
curl -sf -X POST -H "$auth" -H "Content-Type: application/json" \
-d "{\"title\":\"[contract-sync] flutter manifest ${SHORT_SHA}\",\"head\":\"${BRANCH}\",\"base\":\"${DEFAULT_BRANCH}\",\"body\":\"Автоматический синк манифеста из eatvio_app@${GITHUB_SHA}.\\n\\nCI бэкенда прогонит FlutterApiContractTest — если бэкенд не выполняет новые требования моделей, тест упадёт на этом PR.\"}" \
"${API}/repos/${BACKEND_REPO}/pulls" > /dev/null \
|| echo "PR already exists for ${BRANCH}"
echo "Sync PR opened: ${BACKEND_REPO} <- ${BRANCH}"

View File

@@ -14,7 +14,7 @@ env:
jobs:
build:
runs-on: [ubuntu-latest]
runs-on: [debian-12]
steps:
- uses: https://github.com/actions/checkout@v3
@@ -155,19 +155,10 @@ jobs:
- name: Get dependencies
run: |
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
# Контракт манифеста API: манифест обязан быть регенерирован из исходников freezed-моделей.
# Diff после регенерации = PR менял модели, но не обновил api_contract/flutter_model_contract.json.
# Обновлённый манифест нужно также синхронизировать в eatvio-web: tests/Fixtures/Contract/.
- name: API contract manifest check
run: |
dart run tool/export_api_contract.dart api_contract/flutter_model_contract.json
git diff --exit-code api_contract/flutter_model_contract.json \
|| (echo "::error::api_contract/flutter_model_contract.json устарел. Регенерируй манифест (make export-api-contract) и синхронизируй его в eatvio-web: tests/Fixtures/Contract/flutter_model_contract.json" && exit 1)
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
- name: Native Analyze
run: flutter analyze

View File

@@ -1,76 +0,0 @@
# AGENTS.md
Flutter/Dart mobile app (iOS + Android, Russian-market nutrition/consulting product).
Gitea-hosted; CI is Gitea Actions in `.gitea/workflows/` (not GitHub Actions, though it references `github.com/actions/*`).
## Toolchain (trust these over README — README version numbers are stale)
- CI runs **Flutter 3.41.2 stable**, **Java/JDK 21** (`.gitea/workflows/test.yml`).
- `pubspec.yaml` requires **Dart SDK ^3.10.4**; app version is `1.3.23+105` (README claims 1.3.0+79 — wrong).
- Build/test/lint via Flutter directly or the `Makefile`. The repo has a `package.json` **only** to wire a git pre-push hook via `simple-git-hooks`; it is not a Node project.
## CRITICAL: generated code is NOT committed
`.gitignore` excludes `*.g.dart`, `*.freezed.dart`, `*.gr.dart`, `*.mocks.dart`. After any checkout / pull / model change you **must regenerate** before `flutter analyze`, `flutter test`, or any build, or they fail on missing files:
```bash
dart run build_runner build --delete-conflicting-outputs
# or: make build-generated-files
```
Generators in use: `freezed`, `json_serializable`, `retrofit_generator`, `mockito` (`build_runner`).
Exception: `lib/generated/` (l10n output from `intl_utils` / `flutter_intl`) **is** committed — don't regenerate-and-clobber it casually.
## `.env` (required for running the app, NOT for unit tests)
- The app loads `.env` at startup (`lib/core/app_initializer.dart` → `dotenv.load`) and ships it as a Flutter asset (`pubspec.yaml` assets).
- There is **no `env.example` in the repo** despite README/`ENV_SETUP.md` saying `cp env.example .env`. Locally you must obtain `.env` from the team. In CI it is checked out from a **private `eatvio/eatvio_app_env` repo**.
- `flutter test` does **not** need `.env`: tests seed env via `dotenv.loadFromString(...)` (see `test/core/app_config_test.dart`). Access values only through `lib/core/env_config.dart` (`EnvConfig`).
- Build types: `AppBuildType { prod, tests, dev }` (`lib/core/app_initializer.dart`). `prod` → `BASE_URL`; `tests`/`dev` → `DEV_BASE_URL`. `main.dart` hardcodes `prod`.
## Commands
```bash
make run-analyse # flutter analyze
make run-tests # flutter test
make run-checks # analyze THEN test (also the pre-push hook)
make build-generated-files # build_runner --delete-conflicting-outputs
make clean-coverage # strips generated files from coverage/lcov.info
```
Run a single test file / single test:
```bash
flutter test test/core/app_config_test.dart
flutter test test/core/app_config_test.dart --plain-name "creates config with prod build type"
```
Coverage (must be run with the flag): `flutter test --coverage`, then `make clean-coverage`. `analyze_coverage.py` / `analyze_coverage_by_uncovered.py` summarize `coverage/lcov.info`.
## CI gate (binding constraint on PRs)
`.gitea/workflows/test.yml` runs on every PR, in order:
1. `flutter clean` → `flutter pub get` → `dart run build_runner build --delete-conflicting-outputs`
2. `flutter analyze`
3. `flutter test --coverage`
4. **Coverage must be ≥ 70%** (generated files excluded) or the job fails — keep this in mind when adding untested code.
5. `flutter build apk --release --dart-define=HIDE_PLANS=false --dart-define=BUILD_DATE=...`
Note: other workflow files (`build.yml`, `docker-run-tests.yml`) are simpler/older and do **not** run `build_runner`; `test.yml` is the authoritative one.
## Architecture map
- Entry: `lib/main.dart` → `AppInitializer.initialize` (env → DI → Sentry → Firebase → analytics) → runs app under Sentry.
- DI: `get_it` (`sl` global), registered in `lib/core/locator_service.dart` via `lib/core/di/*_registrations.dart` (app_config, external, service, repository, cubit).
- State: `flutter_bloc` (Cubits/Blocs). Global providers declared in `lib/core/app_config.dart` (`globalBlocProviders`).
- Routing: `go_router` (`lib/core/app_router.dart`), with deep-link/push handling in `lib/core/notifications/`.
- Networking: `dio` + `retrofit` (→ `.gr.dart`); auth token interceptor in `lib/core/interceptors/`.
- Models: `freezed` + `json_serializable` (→ `.freezed.dart` / `.g.dart`).
- Realtime: Pusher channels via `lib/core/websockets/` (`WsManager`).
- Features: ~40 self-contained folders under `lib/features/<feature>/`, conventionally `.../presentation/bloc/<name>_cubit/<name>_cubit.dart`. Tests mirror `lib/` under `test/` with fixtures in `test/fixtures/`.
- Localization: Russian only (`ru`); strings via generated `S` class from `lib/generated/l10n.dart`.
## Conventions / gotchas
- `analysis_options.yaml` enables `flutter_lints` + extra `prefer_const_*` rules and **excludes generated + l10n files** from analysis. `dart_code_metrics` is also configured (cyclomatic ≤20, ≤50 lines executable, ≤4 params, nesting ≤5) but metrics exclude `test/**`.
- Don't commit secrets: `.env`, `android/key.properties`, `*.jks` keystores are all gitignored.
- Comments and many code identifiers are in Russian (UI strings, doc comments like `/// Загрузка переменных окружения`); match surrounding style.

View File

@@ -11,14 +11,3 @@ build-generated-files:
clean-coverage:
@./scripts/remove_generated_from_coverage.sh
# Регенерация манифеста API-контракта из исходников freezed-моделей.
# Манифест — источник требований мобильных моделей для eatvio-web
# (tests/Fixtures/Contract/flutter_model_contract.json).
export-api-contract:
dart run tool/export_api_contract.dart api_contract/flutter_model_contract.json
# Проверка актуальности манифеста (для CI): регенерирует и диффает с закоммиченным.
check-api-contract: export-api-contract
@git diff --exit-code api_contract/flutter_model_contract.json \
|| (echo "api_contract/flutter_model_contract.json устарел: регенерируй (make export-api-contract) и синхронизируй в eatvio-web tests/Fixtures/Contract/" && exit 1)

View File

@@ -1,89 +0,0 @@
# Регламент синхронизации API-контракта (eatvio-web ↔ eatvio_app)
## Стороны и артефакты
| Репозиторий | Артефакт | Роль |
|---|---|---|
| eatvio_app | `tool/export_api_contract.dart` | генератор: парсит freezed-декларации из **исходников** (не из `*.g.dart`) |
| eatvio_app | `api_contract/flutter_model_contract.json` | **источник истины**: требования мобильных моделей к JSON (required/optional ключи) |
| eatvio-web | `tests/Fixtures/Contract/flutter_model_contract.json` | синхронизированная копия, по ней CI бэка валидирует API-ресурсы |
| eatvio-web | `tests/Feature/Contract/FlutterApiContractTest.php` | контрактные тесты: worst-case сериализация ресурса ↔ манифест |
Контракт односторонний по данным: бэкенд отдаёт JSON → flutter читает (`fromJson`). Экстремальный случай — nullable-колонка на бэке при required-поле во flutter: ровно он дал продовой баг FLUTTER-1BX (`satiety: null` → `TypeError` в `_$MealModelFromJson`, дневник мобильных клиентов не работал, КБЖУ-информер показал нули).
## Направление 1: backend → frontend (бэк меняет ответ API)
1. Дев меняет API-ресурс в eatvio-web (например, делает поле nullable / удаляет ключ).
2. `FlutterApiContractTest` падает, если новое поведение нарушает манифест — **это и есть фильтр обратной несовместимости**.
3. Дальше два осознанных пути:
- **Сохранить совместимость** (предпочтительно): вернуть значение/дефолт на бэке (`?? 0`, как с `time` → `'00:00:00'`). Манифест не меняется.
- **Ломать сознательно**: мержится mobile-first — сначала в eatvio_app поле становится nullable/опциональным, выходит релиз приложения; затем PR в eatvio-web меняет ресурс и копию манифеста. Пока старые версии приложения в рынке — бэк держит старый формат.
4. Коммит копии манифеста в eatvio-web без изменения ресурса бессмысленен и не проходит ревью: манифест меняется только вместе с ресурсом.
## Направление 2: frontend → backend (фронт ужесточает требования)
1. Дев меняет freezed-модель в eatvio_app (например, добавляет `required int nutrition`).
2. CI eatvio_app (`test.yml`, шаг `API contract manifest check`) регенерирует манифест и диффает: устарел → PR не проходит.
3. Дев обновляет манифест (`make export-api-contract`) и **в том же PR** синхронизирует копию в eatvio-web (`tests/Fixtures/Contract/`).
4. CI eatvio-web прогоняет `FlutterApiContractTest` против новой копии: если бэк не выдаёт новые required-ключи — падает уже там, и бэк добавляет гарантии **до** релиза приложения.
5. Порядок релизов: бэк докручивается первым (он назад-совместим), затем выходит приложение.
## Правила совместимости (шпаргалка)
| Изменение на бэке | Ломает приложение? | Действие |
|---|---|---|
| Поле остаётся non-null | нет | ничего |
| Добавлен новый ключ | нет (flutter игнорирует лишнее) | ничего; добавить в модель отдельно при необходимости |
| non-null → nullable / ключ удалён | **да** | mobile-first: релиз приложения, потом бэк |
| flutter: `int` → `int?` | нет | `make export-api-contract` + синк манифеста |
| flutter: добавлен `required`-ключ | **бэк может не выполнять** | синк манифеста → бэк добавляет ключ/дефолт до релиза |
## Автоматика
| Где | Что | Когда срабатывает |
|---|---|---|
| eatvio_app, CI | шаг `API contract manifest check` (test.yml) | каждый PR: манифест обязан быть регенерирован из исходников |
| eatvio_app, CI | `contract-sync.yml` | push в main с изменениями `api_contract/**` → авто-PR в eatvio-web с новой копией манифеста |
| eatvio_app, локально | `make export-api-contract` / `make check-api-contract` | руками при работе над моделями |
| eatvio-web, CI | `FlutterApiContractTest` (12 тестов) | каждый PR: worst-case сериализация ресурсов ↔ манифест |
| eatvio-web, CI | `contract-drift-check.yaml` | еженощно: сравнение локальной копии манифеста с eatvio_app@main, при дрейфе — issue |
| eatvio-web, локально | `make sync-flutter-contract` | руками, если авто-PR не пришёл |
Секреты: `CONTRACT_SYNC_TOKEN` — PAT с write на eatvio/eatvio-web (для contract-sync) и read eatvio/eatvio_app + issues на eatvio/eatvio-web (для drift-check).
## Как команды держат друг друга в рабочем состоянии (три слоя)
1. **Предотвращение (до мержа).** Контрактные тесты на обеих сторонах: flutter не смержит модели без регенерации манифеста; бэкенд не смержит ресурс, отдающий null/не тот тип там, где приложение требует non-null.
2. **Синхронизация (при мерже).** Авто-PR из `contract-sync.yml` приносит новую копию манифеста в бэкенд; CI бэкенда на этом PR показывает, выполняет ли сервер новые требования. Ночной drift-check ловит всё, что проскочило мимо автоматики.
3. **Обнаружение (после релиза).** Sentry: группа `TypeError ... in _$XxxFromJson` (проект flutter) = нарушение контракта в проде. Такой issue должен ссылаться на контрактную пару и закрываться фиксом ресурса + контрак тестом.
Ритуалы:
- Меняешь модель в приложении → `make export-api-contract` в том же PR; манифест уедет в бэкенд автоматически.
- Меняешь API-ресурс → прогоняй контрактный тест; красный = либо делай совместимо (дефолты), либо mobile-first релиз.
- WARNING генератора о коллизии имён моделей (`Step`, `Users`, ...) — повод переименовать Dart-классы: под одним именем могут скрываться разные контракты.
## Формат манифеста
Каждая модель: `required` / `optional` (JSON-ключи), `types` (Dart-тип поля) и `kinds`
(JSON-вид значения, по которому бэкенд-чекер валидирует payload):
| kind | Dart-типы | Ожидание в JSON |
|---|---|---|
| `number` | `int`, `double`, `num` | число (int или float) |
| `string` | `String` | строка |
| `boolean` | `bool` | строго true/false (int 0/1 — нарушение) |
| `list` | `List<...>` | массив |
| `map` | `Map<...>` | объект |
| `model` | вложенная freezed-модель | объект |
| `enum` | Dart enum | строка-имя или int |
| `any` / `unknown` | `dynamic` и прочее | не проверяется |
Это закрывает второй класс мобильных крашей: жёсткие касты (`json['from_diet'] as bool`,
`json['quantity'] as num`) роняют приложение даже на non-null значениях — например,
если PHP отдаст tinyint `0/1` вместо bool или строку `"150"` вместо числа.
## Границы подхода
- Манифест покрывает **ответы** API (fromJson). Контракт запросов (retrofit → FormRequest) пока не формализован.
- Новый ключ бэкенда не требует изменения манифеста — flutter молча игнорирует лишнее; расширять модели — добровольно.
- `kinds: unknown` не проверяется (неклассифицированные типы), чтобы не давать ложных срабатываний.

File diff suppressed because it is too large Load Diff

View File

@@ -437,73 +437,21 @@ import flutter_foreground_task
return payload
}
private func bringAppToForeground() {
DispatchQueue.main.async {
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.makeKeyAndVisible()
}
}
self.window?.makeKeyAndVisible()
}
}
private func openAppForAcceptedCall(_ call: Call) {
guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else {
recordVoipDiagnostic(
"accepted_call_missing_conversation_uuid",
data: ["callKitId": call.data.uuid]
)
/// The answer callback runs before the scene is visible. A custom-scheme
/// open from here is an untrusted user action and SpringBoard denies it.
/// CallKit foregrounds a video call itself after `fulfill`. Flutter reads
/// the stored payload once that scene is actually active.
func deliverStoredAcceptedCall() {
guard voipDiagnosticsChannel != nil else { return }
guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return
}
var components = URLComponents()
components.scheme = appUrlScheme
components.host = "video-call"
var queryItems = [
URLQueryItem(name: "callKitId", value: call.data.uuid),
URLQueryItem(name: "conversationUuid", value: conversationUuid)
]
let serverCallId = extraServerCallId(call.data.extra)
if !serverCallId.isEmpty {
queryItems.append(URLQueryItem(name: "call_id", value: serverCallId))
}
components.queryItems = queryItems
guard let url = components.url else {
recordVoipDiagnostic(
"accepted_call_url_build_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
]
)
let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey)
return
}
recordVoipDiagnostic(
"accepted_call_open_app_requested",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
"appState": UIApplication.shared.applicationState.rawValue,
]
)
bringAppToForeground()
UIApplication.shared.open(url, options: [:]) { [weak self] success in
self?.recordVoipDiagnostic(
success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed",
data: [
"callKitId": call.data.uuid,
"conversationUuid": conversationUuid,
"url": url.absoluteString,
]
)
}
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload)
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
@@ -1177,6 +1125,8 @@ import flutter_foreground_task
// activation and can start I/O before didActivate.
prepareAvAudioSessionCategory()
storePendingAcceptedCall(call)
let extra = call.data.extra as? [String: Any] ?? [:]
VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle)
recordVoipDiagnostic(
"callkit_accept_native",
data: [
@@ -1185,11 +1135,7 @@ import flutter_foreground_task
"data": call.data.toJSON(),
]
)
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
deliverStoredAcceptedCall()
action.fulfill()
}

View File

@@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate {
override func sceneDidBecomeActive(_ scene: UIScene) {
super.sceneDidBecomeActive(scene)
registerCallChannels()
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
return
}
appDelegate.deliverStoredAcceptedCall()
}
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {

View File

@@ -8,25 +8,37 @@ enum VideoCallDeclineClient {
/// `POST /video-call/decline` from the CallKit callback. The process is
/// awake here even when the user never opens the app afterwards.
static func send(extra: [String: Any], handle: String) {
post(action: "decline", extra: extra, handle: handle)
}
/// `POST /video-call/accept` from the CallKit answer callback. Flutter may
/// still be frozen on the splash snapshot, so the other device must be
/// told from here. A later Dart retry with the same hw_id is idempotent.
static func sendAccept(extra: [String: Any], handle: String) {
post(action: "accept", extra: extra, handle: handle)
}
private static func post(action: String, extra: [String: Any], handle: String) {
let fields = declineFields(extra: extra, handle: handle)
guard let request = makeRequest(
baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"),
token: prefsString("token"),
hwId: UIDevice.current.identifierForVendor?.uuidString,
fields: fields
fields: fields,
action: action
) else {
NSLog(
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
)
return
}
let task = EatvioBackgroundTask()
task.begin(name: "video-call-decline")
task.begin(name: "video-call-\(action)")
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
NSLog(
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
)
task.end()
}
@@ -37,7 +49,8 @@ enum VideoCallDeclineClient {
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields
fields: VideoCallDeclineFields,
action: String = "decline"
) -> URLRequest? {
if fields.endedPush { return nil }
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -50,8 +63,9 @@ enum VideoCallDeclineClient {
guard !conversation.isEmpty, !authToken.isEmpty else { return nil }
guard deviceId.count >= 8, deviceId.count <= 64 else { return nil }
guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil }
guard action == "accept" || action == "decline" else { return nil }
guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline")
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)")
else { return nil }
var body: [String: String] = ["hw_id": deviceId]

View File

@@ -43,37 +43,60 @@ platform :ios do
end
UI.message "Using auth_key_path: #{auth_key_path}"
# Authenticate to App Store Connect early:
# needed here by latest_testflight_build_number, not only by upload_to_testflight.
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
# Configure Flutter for release mode
UI.message "Configuring Flutter for release mode..."
sh("cd .. && flutter build ios --config-only --release")
# Read build number from Flutter Generated.xcconfig
# Read version and build number from Flutter Generated.xcconfig.
# They are only a floor: the actual build number is derived from TestFlight below,
# so repeated CI runs on the same commit can never hit
# ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE ("bundle version must be higher").
generated_xcconfig_path = File.join("..", "Flutter", "Generated.xcconfig")
flutter_build_name = nil
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
if (m = line.match(/FLUTTER_BUILD_NAME=(.*)/))
flutter_build_name = m[1].strip
elsif (m = line.match(/FLUTTER_BUILD_NUMBER=(.*)/))
flutter_build_number = m[1].strip
end
end
end
if flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
if flutter_build_name.nil? || flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NAME/FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end
UI.message "Flutter build number: #{flutter_build_number}"
UI.message "Flutter version from pubspec: #{flutter_build_name} (#{flutter_build_number})"
asc_latest_build_number = latest_testflight_build_number(
app_identifier: "com.eatvioapp.app",
version: flutter_build_name,
initial_build_number: flutter_build_number.to_i
)
new_build_number = [flutter_build_number.to_i, asc_latest_build_number].max + 1
UI.message "Latest TestFlight build number: #{asc_latest_build_number} -> uploading build #{new_build_number}"
match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing
increment_build_number(
xcodeproj: "Runner.xcodeproj",
build_number: flutter_build_number
build_number: new_build_number
)
update_code_signing_settings(
@@ -93,22 +116,13 @@ platform :ios do
export_team_id: "VL68C36HYS"
)
app_store_connect_api_key(
key_id: "325JS9JCZH",
issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3",
key_filepath: auth_key_path,
duration: 1200,
in_house: false
)
begin
upload_to_testflight(
skip_waiting_for_build_processing: true,
skip_submission: true,
distribute_external: false,
notify_external_testers: false,
changelog: "TestFlight build #{flutter_build_number}",
changelog: "TestFlight build #{new_build_number}",
verbose: true
)
rescue => ex

View File

@@ -42,7 +42,8 @@ import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
import 'package:eatvio_app/features/payment/presentation/cubit/consultation_payment_cubit/consultation_payment_cubit.dart';
import 'package:eatvio_app/features/personal_diet_suggestion/cubit/personal_diet_suggestion_cubit.dart';
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart' as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/plans_cubit/plans_cubit.dart'
as profile_plans;
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
@@ -70,69 +71,174 @@ class CubitRegistrations {
/// Регистрация Cubit'ов как синглтонов
static void _registerSingletonCubits() {
sl.registerLazySingleton<NewMessagesCubit>(() => NewMessagesCubit());
sl.registerLazySingleton<NotificationsCubit>(() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()));
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
sl.registerLazySingleton<VideoCallManager>(
() => VideoCallManager(
videoCallRepository: sl(),
sl.registerLazySingleton<NotificationsCubit>(
() => NotificationsCubit(wsManager: sl(), notificationsRepository: sl()),
);
sl.registerLazySingleton<ProfileAvatarCubit>(
() => ProfileAvatarCubit(profileRepository: sl()),
);
sl.registerLazySingleton<ChatsCubit>(
() => ChatsCubit(
chatRepository: sl(),
wsManager: sl(),
profileRepository: sl(),
),
);
sl.registerLazySingleton<InternetConnectionCubit>(
() => InternetConnectionCubit(),
);
sl.registerLazySingleton<VideoCallManager>(
() =>
VideoCallManager(videoCallRepository: sl(), profileRepository: sl()),
);
sl.registerLazySingleton<VideoCallSessionCubit>(
() => VideoCallSessionCubit(
videoCallManager: sl(),
systemPipCoordinator: sl(),
),
);
sl.registerLazySingleton<FeedCubit>(() => FeedCubit(feedRepository: sl()));
}
/// Регистрация Cubit'ов как фабрик
static void _registerFactoryCubits() {
// Auth related cubits
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(), prefs: sl()));
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
sl.registerFactory<AuthCubit>(
() => AuthCubit(
authRepository: sl(),
firebaseApi: sl(),
prefs: sl(),
profileRepository: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubit>(
() => RegistrationCubit(
registrationRepository: sl(),
prefs: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<ResetPasswordCubit>(
() => ResetPasswordCubit(resetPasswordRepository: sl()),
);
sl.registerFactory<VerifyEmailCubit>(
() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()),
);
sl.registerFactory<OAuthCubit>(
() => OAuthCubit(
authRepository: sl(),
firebaseApi: sl(),
prefs: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegistrationCubitV2>(
() => RegistrationCubitV2(
registrationRepository: sl(),
firebaseApi: sl(),
sessionLifecycleService: sl(),
),
);
// Feed related cubits
sl.registerFactory<FeedCubit>(() => FeedCubit(feedRepository: sl()));
sl.registerFactory<RecipesCubit>(() => RecipesCubit(feedRepository: sl(), recipeRepository: sl(), aboutRecipeRepository: sl()));
sl.registerFactory<DietsCubit>(() => DietsCubit(feedRepository: sl(), dietsRepository: sl()));
sl.registerFactory<PlansCubit>(() => PlansCubit(feedRepository: sl(), planRepository: sl()));
sl.registerFactory<ConsultantsCubit>(() => ConsultantsCubit(feedRepository: sl()));
sl.registerFactory<CommentsCubit>(() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()));
sl.registerFactory<FeedProfileCubit>(() => FeedProfileCubit(profileRepository: sl()));
sl.registerFactory<RecipesCubit>(
() => RecipesCubit(
feedRepository: sl(),
recipeRepository: sl(),
aboutRecipeRepository: sl(),
),
);
sl.registerFactory<DietsCubit>(
() => DietsCubit(feedRepository: sl(), dietsRepository: sl()),
);
sl.registerFactory<PlansCubit>(
() => PlansCubit(feedRepository: sl(), planRepository: sl()),
);
sl.registerFactory<ConsultantsCubit>(
() => ConsultantsCubit(feedRepository: sl()),
);
sl.registerFactory<CommentsCubit>(
() => CommentsCubit(feedRepository: sl(), commentsRepository: sl()),
);
sl.registerFactory<FeedProfileCubit>(
() => FeedProfileCubit(profileRepository: sl()),
);
// Chat related cubits
sl.registerFactory<ChatRoomCubit>(() => ChatRoomCubit(cardsRepository: sl(), chatRepository: sl(), wsManager: sl()));
sl.registerFactory<ChatRoomCubit>(
() => ChatRoomCubit(
cardsRepository: sl(),
chatRepository: sl(),
wsManager: sl(),
),
);
// Profile related cubits
sl.registerFactory<ProfileCubit>(() => ProfileCubit(profileRepository: sl(), chatRepository: sl(), consultantRepository: sl()));
sl.registerFactory<profile_plans.PlansCubit>(() => profile_plans.PlansCubit(plansRepository: sl()));
sl.registerFactory<ProfileCubit>(
() => ProfileCubit(
profileRepository: sl(),
chatRepository: sl(),
consultantRepository: sl(),
),
);
sl.registerFactory<profile_plans.PlansCubit>(
() => profile_plans.PlansCubit(plansRepository: sl()),
);
sl.registerFactory<SubCubit>(() => SubCubit(profileRepository: sl()));
// Content creation cubits
sl.registerFactory<CreateDietCubit>(() => CreateDietCubit(profileRepository: sl(), createDietRepository: sl(), glossaryRepository: sl()));
sl.registerFactory<CreatePlanCubit>(() => CreatePlanCubit(createPlanRepository: sl()));
sl.registerFactory<CreateRecipeCubit>(() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()));
sl.registerFactory<BlogPostCreateCubit>(() => BlogPostCreateCubit(blogPostRepository: sl()));
sl.registerFactory<CreateDietCubit>(
() => CreateDietCubit(
profileRepository: sl(),
createDietRepository: sl(),
glossaryRepository: sl(),
),
);
sl.registerFactory<CreatePlanCubit>(
() => CreatePlanCubit(createPlanRepository: sl()),
);
sl.registerFactory<CreateRecipeCubit>(
() => CreateRecipeCubit(recipeRepository: sl(), glossaryRepository: sl()),
);
sl.registerFactory<BlogPostCreateCubit>(
() => BlogPostCreateCubit(blogPostRepository: sl()),
);
// Content viewing cubits
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
sl.registerFactory<AboutDietCubit>(
() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<AboutPlanCubit>(
() => AboutPlanCubit(
planRepository: sl(),
profileRepository: sl(),
dietRepository: sl(),
),
);
sl.registerFactory<AboutRecipeCubit>(
() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
// 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(), recipeRepository: sl()));
sl.registerFactory<MyDietsCubit>(
() => MyDietsCubit(myDietsRepository: sl(), createDietRepository: sl()),
);
sl.registerFactory<MyPlansCubit>(
() => MyPlansCubit(myPlansRepository: sl()),
);
sl.registerFactory<MyRecipesCubit>(
() => MyRecipesCubit(
myRecipesRepository: sl(),
aboutRecipeRepository: sl(),
recipeRepository: sl(),
),
);
// Other feature cubits
sl.registerFactory<CalendarCubit>(() => CalendarCubit(calendarRepository: sl()));
sl.registerFactory<CalendarCubit>(
() => CalendarCubit(calendarRepository: sl()),
);
sl.registerFactory<DiaryCubit>(
() => DiaryCubit(
chatRepository: sl(),
@@ -144,28 +250,74 @@ class CubitRegistrations {
),
);
sl.registerFactory<SettingsCubit>(
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
() => SettingsCubit(
profileRepository: sl(),
settingsRepository: sl(),
consultantRepository: sl(),
consultationsRepository: sl(),
sessionLifecycleService: sl(),
),
);
sl.registerFactory<RegisterConsultantCubit>(
() => RegisterConsultantCubit(consultantRepository: sl()),
);
sl.registerFactory<ComplainingCubit>(
() => ComplainingCubit(repository: sl()),
);
sl.registerFactory<UserCardsCubit>(
() => UserCardsCubit(cardsRepository: sl()),
);
sl.registerFactory<OrderInfoCubit>(
() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()),
);
sl.registerFactory<RegisterConsultantCubit>(() => RegisterConsultantCubit(consultantRepository: sl()));
sl.registerFactory<ComplainingCubit>(() => ComplainingCubit(repository: sl()));
sl.registerFactory<UserCardsCubit>(() => UserCardsCubit(cardsRepository: sl()));
sl.registerFactory<OrderInfoCubit>(() => OrderInfoCubit(chatRepository: sl(), profileRepository: sl()));
sl.registerFactory<ConsultationPaymentCubit>(
() => ConsultationPaymentCubit(paymentRepository: sl(), sbpRepository: sl()),
() => ConsultationPaymentCubit(
paymentRepository: sl(),
sbpRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(
() => ConsultationsCubit(consultationsRepository: sl(), chatRepository: sl()),
);
sl.registerFactory<ConsultantCommentsCubit>(
() => ConsultantCommentsCubit(diaryRepository: sl()),
);
sl.registerFactory<CancelPlanCubit>(
() => CancelPlanCubit(cancelPlanRepository: sl()),
);
sl.registerFactory<ConsultationsArchiveCubit>(
() => ConsultationsArchiveCubit(consultationsRepository: sl()),
);
sl.registerFactory<ConsultantFinancesCubit>(
() => ConsultantFinancesCubit(consultationsRepository: sl()),
);
sl.registerFactory<UserPurchasesCubit>(
() => UserPurchasesCubit(consultationsRepository: sl()),
);
sl.registerFactory<BlockedUsersCubit>(
() => BlockedUsersCubit(blockedUsersRepository: sl()),
);
sl.registerFactory<NotificationSettingsCubit>(
() => NotificationSettingsCubit(notificationSettingsRepository: sl()),
);
sl.registerFactory<ConsultantRatingCubit>(
() => ConsultantRatingCubit(
feedRepository: sl(),
repository: sl(),
profileRepository: sl(),
),
);
sl.registerFactory<ConsultationsCubit>(() => ConsultationsCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantCommentsCubit>(() => ConsultantCommentsCubit(diaryRepository: sl()));
sl.registerFactory<CancelPlanCubit>(() => CancelPlanCubit(cancelPlanRepository: sl()));
sl.registerFactory<ConsultationsArchiveCubit>(() => ConsultationsArchiveCubit(consultationsRepository: sl()));
sl.registerFactory<ConsultantFinancesCubit>(() => ConsultantFinancesCubit(consultationsRepository: sl()));
sl.registerFactory<UserPurchasesCubit>(() => UserPurchasesCubit(consultationsRepository: sl()));
sl.registerFactory<BlockedUsersCubit>(() => BlockedUsersCubit(blockedUsersRepository: sl()));
sl.registerFactory<NotificationSettingsCubit>(() => NotificationSettingsCubit(notificationSettingsRepository: sl()));
sl.registerFactory<ConsultantRatingCubit>(() => ConsultantRatingCubit(feedRepository: sl(), repository: sl(), profileRepository: sl()));
sl.registerFactory<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(() => UserDetailsCubit(profileRepository: sl()));
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), sl()));
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
sl.registerFactory<PersonalDietSuggestionCubit>(
() => PersonalDietSuggestionCubit(
dietRepository: sl(),
personalDietSuggestionService: sl(),
),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
class SessionRegistrations {
static void registerAll() {
sl.registerLazySingleton<SessionLifecycleService>(
() => SessionLifecycleService(
wsManager: sl<WsManager>(),
errorReporter: sl<ErrorReporter>(),
),
);
}
}

View File

@@ -1,9 +1,7 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:go_router/go_router.dart';
class TokenInterceptor extends Interceptor {
@override
@@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor {
final token = await TokenStorage.getTokenFromStorage();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
options.extra['authToken'] = token;
}
super.onRequest(options, handler);
@@ -25,9 +24,18 @@ 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');
final requestCarriedAuth = err.requestOptions.headers.containsKey(
'Authorization',
);
final requestToken = err.requestOptions.extra['authToken'];
final currentToken = await TokenStorage.getTokenFromStorage();
if (requestCarriedAuth &&
requestToken != null &&
requestToken == currentToken) {
await sl<SessionLifecycleService>().endSession(
reason: SessionEndReason.unauthorized,
);
}
}
super.onError(err, handler);
}

View File

@@ -5,6 +5,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/di/session_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';
@@ -38,4 +39,6 @@ Future<void> init({required AppBuildType buildType}) async {
sl.registerLazySingleton<WsManager>(
() => WsManager(wsService: sl(), errorReporter: sl()),
);
SessionRegistrations.registerAll();
}

View File

@@ -1,6 +1,5 @@
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';
@@ -24,14 +23,10 @@ class MainNavigationRoutes {
return [
ShellRoute(
builder: (context, state, child) {
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
// Вызываем getMyProfile() только если состояние loading (первый запуск)
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.getMyProfile();
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
profileAvatarCubit.ensureFreshForCurrentSession();
});
return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child);
},

View File

@@ -0,0 +1,122 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/routing/route_constants.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:go_router/go_router.dart';
enum SessionEndReason { logout, unauthorized, accountDeleted }
class SessionLifecycleService {
SessionLifecycleService({
required WsManager wsManager,
required ErrorReporter errorReporter,
}) : _wsManager = wsManager,
_errorReporter = errorReporter;
final WsManager _wsManager;
final ErrorReporter _errorReporter;
int? _authenticatedFingerprint;
bool _endingSession = false;
Future<void> endSession({required SessionEndReason reason}) async {
if (_endingSession) return;
_endingSession = true;
try {
await _run(() => _wsManager.clear());
await _run(TokenStorage.removeTokenInStorage);
await _run(SocketIdStorage.removeSocketIdInStorage);
await _run(() => sl<ProfileAvatarCubit>().reset());
await _run(() => sl<ChatsCubit>().reset());
await _run(() => sl<NotificationsCubit>().reset());
await _run(() => sl<NewMessagesCubit>().reset());
await _run(() => sl<FeedCubit>().reset());
await _run(() async => ProfileCubit.clearMyProfileReference());
await _run(() => sl<VideoCallSessionCubit>().endCall());
await _run(() async => AppMetrica.setUserProfileID(null));
await _run(
() => sl<Prefs>().remove(PrefsKeys.isEmailVerificationSkipped),
);
await _run(() => sl<Prefs>().remove(PrefsKeys.registrationDate));
_authenticatedFingerprint = null;
await _run(() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
final path =
AppRouter.appRouter.routeInformationProvider.value.uri.path;
if (path != RouteConstants.authScreen) {
context.go(RouteConstants.authScreen);
}
});
} finally {
_endingSession = false;
}
}
Future<void> onAuthenticated() async {
String? token;
try {
token = await TokenStorage.getTokenFromStorage();
} catch (e, st) {
await _report(e, st);
return;
}
if (token == null || token.isEmpty) return;
final fingerprint = token.hashCode;
if (_authenticatedFingerprint == fingerprint) return;
_authenticatedFingerprint = fingerprint;
await _run(() async {
await _wsManager.clear();
await _wsManager.initialize();
});
await _run(() => sl<ProfileAvatarCubit>().getMyProfile());
await _run(() => sl<ChatsCubit>().getChatRoomsWithLoading());
await _run(() => sl<NotificationsCubit>().getNotifications());
await _run(() => sl<FirebaseApi>().registerPushToken());
}
Future<void> logout() async {
try {
await _run(() => sl<FirebaseApi>().unregisterPushTokens());
await _run(
() => sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout'),
);
} finally {
await endSession(reason: SessionEndReason.logout);
}
}
Future<void> _run(FutureOr<void> Function() action) async {
try {
await action();
} catch (e, st) {
await _report(e, st);
}
}
Future<void> _report(Object error, StackTrace stackTrace) {
return _errorReporter.report(
error,
stackTrace,
origin: 'SessionLifecycleService',
);
}
}

View File

@@ -3,6 +3,7 @@ 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/session/session_lifecycle_service.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';
@@ -17,15 +18,26 @@ class AuthCubit extends Cubit<AuthState> {
final ProfileRepository profileRepository;
final FirebaseApi firebaseApi;
final Prefs prefs;
final SessionLifecycleService sessionLifecycleService;
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
AuthCubit({
required this.authRepository,
required this.prefs,
required this.profileRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const AuthState());
Future<bool> getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
try {
final token = (await authRepository.getTokenByCredentials(email, password)).token;
final token = (await authRepository.getTokenByCredentials(
email,
password,
)).token;
emit(AuthState.loaded(token: token));
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
return true;
} catch (e, st) {
await reportFrom(e, st, this);
@@ -48,7 +60,8 @@ class AuthCubit extends Cubit<AuthState> {
Future<void> registerAnonymousPushToken() async {
try {
final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
final hasAnonymousTokenBeenSent =
prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false;
if (!hasAnonymousTokenBeenSent) {
await firebaseApi.registerAnonymousPushToken();
prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true);

View File

@@ -1,7 +1,9 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.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';
@@ -11,12 +13,24 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
final AuthRepository authRepository;
final FirebaseApi firebaseApi;
final SessionLifecycleService sessionLifecycleService;
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
OAuthCubit({
required this.authRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
required Prefs prefs,
}) : super(const OAuthState()) {
this.prefs = prefs;
}
Future<void> signInViaSocialMedia({required String provider, required String accessToken, required String refreshToken, required String clientId}) async {
Future<void> signInViaSocialMedia({
required String provider,
required String accessToken,
required String refreshToken,
required String clientId,
}) async {
try {
emit(const OAuthState.loading());
@@ -28,7 +42,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
);
log('OAuthCubit: Got token model: ${tokenModel.token}');
TokenStorage.saveTokenInStorage(tokenModel.token);
await TokenStorage.saveTokenInStorage(tokenModel.token);
await sessionLifecycleService.onAuthenticated();
emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) {
await reportFrom(e, st, this);
@@ -45,7 +60,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -53,14 +69,20 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (errorsList.isNotEmpty) {
emit(OAuthState.error(errorMessage: 'validation_error', registrationValidationErrors: errorsList));
emit(
OAuthState.error(
errorMessage: 'validation_error',
registrationValidationErrors: errorsList,
),
);
} else {
emit(OAuthState.error(errorMessage: e.toString()));
}
@@ -103,7 +125,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height,
clientId: clientId,
);
TokenStorage.saveTokenInStorage(token);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) {
@@ -120,7 +143,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (errorStr.contains('Некорректный формат email')) {
errorsList.add(RegistrationValidationErrors.incorrectEmailFormat);
}
if (errorStr.contains('почта уже существует') || errorStr.contains('Эл. почта уже существует')) {
if (errorStr.contains('почта уже существует') ||
errorStr.contains('Эл. почта уже существует')) {
errorsList.add(RegistrationValidationErrors.emailExist);
}
}
@@ -128,14 +152,17 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
if (phoneErrors != null && phoneErrors.isNotEmpty) {
for (final error in phoneErrors) {
final errorStr = error.toString();
if (errorStr.contains('phone уже существует') || errorStr.contains('телефон уже существует')) {
if (errorStr.contains('phone уже существует') ||
errorStr.contains('телефон уже существует')) {
errorsList.add(RegistrationValidationErrors.phoneExist);
}
}
}
if (dateOfBirth != null && dateOfBirth.isNotEmpty) {
if (dateOfBirth.contains('Некорректный формат даты рождения')) {
errorsList.add(RegistrationValidationErrors.incorrectDateOfBirthFormat);
errorsList.add(
RegistrationValidationErrors.incorrectDateOfBirthFormat,
);
}
}
if (errorsList.isNotEmpty) {
@@ -146,13 +173,23 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
return null;
}
Future<OAuthVerificationRequestResponse?> requestOAuthVerification({String? email, String? phone}) async {
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);
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}');
log(
'OAuthCubit: Verification code requested successfully, masked email: ${response.email}',
);
}
// Сбрасываем состояние после успешного запроса кода
emit(const OAuthState());
@@ -184,7 +221,9 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}) async {
try {
emit(const OAuthState.loading());
log('OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code');
log(
'OAuthCubit: Confirming OAuth verification - provider: $provider, verificationType: $verificationType, code: $code',
);
final response = await authRepository.confirmOAuthVerification(
provider: provider,
accessToken: accessToken,
@@ -202,8 +241,11 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight,
height: height,
);
log('OAuthCubit: OAuth verification confirmed successfully, token received');
TokenStorage.saveTokenInStorage(response.token);
log(
'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await sessionLifecycleService.onAuthenticated();
emit(OAuthState.loaded(token: response.token));
} catch (e, st) {
await reportFrom(e, st, this);
@@ -212,4 +254,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}
}
}

View File

@@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_text_form_field.dart';
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
@@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub
import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart';
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
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/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
@@ -68,23 +64,8 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
rethrow;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -202,18 +183,7 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _reconnectAfterAuth() async {
final context = AppRouter.navigatorKey.currentContext;
if (context == null) return;
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
log(e.toString());
}
showSuccessToast('Успешный вход');
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -395,7 +365,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket(context);
await _reconnectWebSocket();
}
}
setState(() {

View File

@@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.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/create_diet/presentation/widgets/error_widget.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';
@@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult();
}
Future<void> _reconnectWebSocket(BuildContext context) async {
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
if (context.mounted) {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешная регистрация');
} catch (e) {
rethrow;
}
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешная регистрация');
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket(context);
_reconnectWebSocket();
});
return const SizedBox.shrink();
},
@@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) {
_reconnectWebSocket(widgetContext);
_reconnectWebSocket();
}
} catch (e, stackTrace) {
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);

View File

@@ -3,13 +3,10 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
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:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
@@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true),
appBar: AppBar(
title: const Text('Сканирование QR-кода'),
centerTitle: true,
),
body: Column(
children: <Widget>[
Expanded(
@@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
try {
final scannedUrl = scanData.code;
final uri = Uri.parse(scannedUrl ?? '');
final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : '';
final scannedBaseUrl = (uri.hasAuthority)
? '${uri.scheme}://${uri.host}'
: '';
sl<AppConfig>().setBaseUrl(scannedBaseUrl);
sl<Dio>().options.baseUrl = scannedBaseUrl;
final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'}));
final response = await Dio().getUri(
uri,
options: Options(
headers: {
'accept': 'application/json',
'content-type': 'application/json',
},
),
);
final token = response.data['token'];
await TokenStorage.saveTokenInStorage(token);
try {
await sl<WsManager>().clear();
await sl<WsManager>().initialize();
} catch (e) {
rethrow;
}
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<FirebaseApi>().registerPushToken();
} catch (e) {
rethrow;
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
await sl<SessionLifecycleService>().onAuthenticated();
AppRouter.navigatorKey.currentContext?.go(
'/feed-screen',
extra: {'needToInitializeWebSocket': false},
);
showSuccessToast('Добро пожаловать!');
} catch (e) {
await controller.resumeCamera();
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
}
});
}

View File

@@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit<NewMessagesState> {
NewMessagesCubit() : super(const NewMessagesState.empty());
void reset() {
_messagesCount = 0;
if (!isClosed) {
emit(const NewMessagesState.empty());
}
}
void incrementCounter() {
_messagesCount++;
emit(NewMessagesState.loaded(messagesCount: _messagesCount));

View File

@@ -1,18 +1,22 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:go_router/go_router.dart';
import 'package:permission_handler/permission_handler.dart';
class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final ProfileRepository profileRepository;
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
ProfileAvatarCubit({required this.profileRepository})
: super(const ProfileAvatarState.loading());
int _requestId = 0;
int? _sessionFingerprint;
bool _ensuring = false;
int _calculateAge(String birthdate) {
try {
@@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
final now = DateTime.now();
int age = now.year - birthYear;
if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) {
if (now.month < birthMonth ||
(now.month == birthMonth && now.day < birthDay)) {
age--;
}
@@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit<ProfileAvatarState> {
return 0;
}
void reset() {
_requestId++;
_sessionFingerprint = null;
unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {}));
if (!isClosed) emit(const ProfileAvatarState.loading());
}
Future<void> ensureFreshForCurrentSession() async {
final token = await TokenStorage.getTokenFromStorage();
if (token == null || token.isEmpty) {
reset();
return;
}
final fingerprint = token.hashCode;
if (_sessionFingerprint == fingerprint &&
state is ProfileAvatarStateLoaded) {
return;
}
if (_ensuring) {
return;
}
_ensuring = true;
try {
await getMyProfile();
} finally {
_ensuring = false;
}
}
Future<void> getMyProfile() async {
final requestId = ++_requestId;
final token = await TokenStorage.getTokenFromStorage();
final fingerprint = token?.hashCode;
if (isClosed || requestId != _requestId) return;
emit(const ProfileAvatarState.loading());
try {
final profile = await profileRepository.getMyProfile();
if (isClosed || requestId != _requestId) return;
final profileDetails = await profileRepository.getMyProfileDetails();
if (isClosed || requestId != _requestId) return;
_sessionFingerprint = fingerprint;
emit(ProfileAvatarState.loaded(profile: profile));
try {
final notificationStatus = await Permission.notification.status;
if (isClosed || requestId != _requestId) return;
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),
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, st) {
await reportFrom(e, st, this);
// Игнорируем ошибки плагинов, профиль уже загружен
}
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
if (isClosed || requestId != _requestId) return;
emit(ProfileAvatarState.error(errorMessage: e.toString()));
}
}

View File

@@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget {
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
int currentBottomNavBarIndex = 1;
List<String>? pages;
int? _pagesProfileId;
bool? _pagesIsConsultant;
int tapsCount = 0;
List<String> _getPages(bool isConsultant) {
@@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final isConsultant = profileModel.roles.contains('consultant');
// Инициализируем pages на основе profileModel
pages ??= _getPages(isConsultant);
if (pages == null ||
_pagesProfileId != profileModel.id ||
_pagesIsConsultant != isConsultant) {
_pagesProfileId = profileModel.id;
_pagesIsConsultant = isConsultant;
pages = _getPages(isConsultant);
if (currentBottomNavBarIndex >= pages!.length) {
currentBottomNavBarIndex = 0;
}
}
// Expose tab switching to descendants (e.g. notification widget)
final controller = BottomNavScope.maybeOf(context);

View File

@@ -57,6 +57,17 @@ class ChatsCubit extends Cubit<ChatsState> {
int page = 1;
late int lastPage;
/// Clears all user-scoped state. WS subscriptions stay intact so that
/// `getChatRoomsWithLoading` can re-load the new user's chats.
void reset() {
_query = null;
page = 1;
lastPage = 1;
if (!isClosed) {
emit(ChatsState());
}
}
Future<void> connectToWebSocket() async {
try {
await newIncomingMessagesSubscription?.cancel();

View File

@@ -135,9 +135,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
textEditingController.clear();
});
}
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -189,9 +189,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -205,9 +205,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (images.isNotEmpty) {
List<File> files = images.map((image) => File(image.path)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},
@@ -243,9 +243,9 @@ class _EmptyChatScreenState extends State<EmptyChatScreen> {
if (result != null) {
List<File> files = result.paths.map((path) => File(path!)).toList();
await sl<ChatRepository>().sendMessage(files: files, slug: widget.user.slug);
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
final chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
}
}
},

View File

@@ -137,11 +137,12 @@ class ChatRepository {
}
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
final response = await chatService.getChatByUserSlug(slug: slug);
if (response.response.statusCode == 404) {
return '404';
final data = response.data;
if (data is Map && data['id'] is String && (data['id'] as String).isNotEmpty) {
return data['id'] as String;
}
return response.data['id'];
return null;
}
}

View File

@@ -14,6 +14,8 @@ class BecomeConsultantScreen extends StatefulWidget {
class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Scaffold(
appBar: AppBar(
title: const Text('Стать консультантом'),
@@ -24,14 +26,18 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
icon: const Icon(Icons.close),
),
),
body: const Padding(
padding: EdgeInsets.all(16),
child: Text(
body: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + 52 + 8 + bottomInset),
child: const Text(
'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.',
style: TextStyle(fontSize: 16),
),
),
bottomSheet: _startRegistrationButton(),
bottomSheet: Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 8 + bottomInset),
child: _startRegistrationButton(),
),
);
}
@@ -44,7 +50,7 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 32,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,

View File

@@ -9,6 +9,7 @@ part 'consultation_model.g.dart';
abstract class ConsultationModel with _$ConsultationModel {
factory ConsultationModel({
required int id,
@JsonKey(name: 'chat_id') String? chatId,
@JsonKey(name: 'last_state') required ChosenPlanStateEnum chosenPlanState,
@JsonKey(name: 'plan_title') required String planTitle,
@JsonKey(name: 'user_name') required String userName,

View File

@@ -1,6 +1,7 @@
import 'dart:developer';
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -8,7 +9,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class ConsultationsCubit extends Cubit<ConsultationsState> {
final ConsultationsRepository consultationsRepository;
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
final ChatRepository chatRepository;
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
Future<void> loadConsultations() async {
try {
@@ -20,4 +22,14 @@ class ConsultationsCubit extends Cubit<ConsultationsState> {
emit(state.copyWith(state: BaseState.error));
}
}
Future<String?> getChatIdByUserSlug(String slug) async {
try {
return await chatRepository.getChatByUserSlug(slug);
} catch (e, st) {
await reportFrom(e, st, this);
log(e.toString(), stackTrace: st);
return null;
}
}
}

View File

@@ -3,14 +3,17 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class ConsultationCardWidget extends StatefulWidget {
@@ -34,6 +37,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
final List<Color> legendColors = const [Colors.white, Color(0xFFF0C497), Color(0xFFFF9500), Color(0xFF2F80ED), Color(0xFF4CD964)];
late final List<(int, DiaryModel)> givenDaysWithDayNumber;
bool _isOpeningChat = false;
final List<List<DiaryStateEnum>> legendStates = [
[DiaryStateEnum.init],
[DiaryStateEnum.started, DiaryStateEnum.lastDayStarted],
@@ -279,7 +283,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () {},
onTap: _isOpeningChat ? null : _openChat,
child: Ink(
height: 52,
width: 52,
@@ -308,6 +312,22 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
}
Future<void> _openChat() async {
if (_isOpeningChat) return;
setState(() => _isOpeningChat = true);
var chatId = widget.consultationsModel.chatId;
if (chatId == null || chatId.isEmpty) {
chatId = await context.read<ConsultationsCubit>().getChatIdByUserSlug(widget.consultationsModel.userSlug);
}
if (!mounted) return;
setState(() => _isOpeningChat = false);
if (chatId == null || chatId.isEmpty) {
showErrorToast(S.of(context).msg_something_went_wrong);
return;
}
GoRouter.of(context).pushNamed('chatScreen', extra: {'uuid': chatId});
}
Row _legendTitle(String text, Color color) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -12,9 +12,19 @@ class FeedCubit extends Cubit<FeedState> {
bool _hasMore = true;
bool isLoading = false;
String? _query;
int _requestId = 0;
FeedCubit({required this.feedRepository}) : super(const FeedState.loading());
void reset() {
_requestId++;
_nextCursor = null;
_hasMore = true;
isLoading = false;
_query = null;
if (!isClosed) emit(const FeedState.loading());
}
void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) {
if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return;
final List<FeedBlogPostModel> feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList];
@@ -39,17 +49,18 @@ class FeedCubit extends Cubit<FeedState> {
}
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
final requestId = ++_requestId;
try {
_query = query;
_nextCursor = null;
_hasMore = true;
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed) {
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
}
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
@@ -62,14 +73,16 @@ class FeedCubit extends Cubit<FeedState> {
return;
}
final requestId = _requestId;
isLoading = true;
emit((state as FeedStateLoaded).copyWith(isLoading: true));
try {
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
if (isClosed || requestId != _requestId) return;
_nextCursor = feed.nextCursor;
_hasMore = _nextCursor != null;
if (!isClosed && state is FeedStateLoaded) {
if (state is FeedStateLoaded) {
emit(
FeedState.loaded(
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
@@ -78,14 +91,15 @@ class FeedCubit extends Cubit<FeedState> {
);
}
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
if (!isClosed && state is FeedStateLoaded) {
if (state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else if (!isClosed) {
} else {
emit(FeedState.error(errorMessage: e.toString()));
}
} finally {
isLoading = false;
if (requestId == _requestId) isLoading = false;
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:developer';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class MenuCubit extends Cubit<MenuState> {
final ProfileRepository profileRepository;
MenuCubit(this.profileRepository) : super(const MenuState.initial());
final SessionLifecycleService sessionLifecycleService;
MenuCubit(this.profileRepository, this.sessionLifecycleService)
: super(const MenuState.initial());
Future<void> logout() => sessionLifecycleService.logout();
Future<void> getMyProfile() async {
emit(const MenuState.loading());
@@ -23,7 +28,10 @@ class MenuCubit extends Cubit<MenuState> {
Future<String?> makePassKey() async {
try {
final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {});
final passKey = await profileRepository.getPassKey(
routeName: 'product_nutritional_value.index',
routeParams: {},
);
return passKey;
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -1,22 +1,15 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/const.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget {
Future<Null> Function() _onTapLogout(BuildContext context) {
return () async {
final menuCubit = context.read<MenuCubit>();
showDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
@@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
try {
// Logout revokes the Sanctum token. Unregister first, or
// FCM / VoIP tokens stay on the server and calls still ring.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
if (context.mounted) {
GoRouter.of(context).go('/auth-screen', extra: true);
}
}
Navigator.of(context).pop();
await menuCubit.logout();
},
isDestructiveAction: true,
child: const Text("Выйти"),

View File

@@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit<NotificationsState> {
StreamSubscription? _newNotificationsSubscription;
NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty());
void reset() {
_notifications.clear();
if (!isClosed) emit(const NotificationsState.empty());
}
Future<void> connectToWebSocket() async {
try {
await _newNotificationsSubscription?.cancel();

View File

@@ -22,6 +22,10 @@ class ProfileCubit extends Cubit<ProfileState> {
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
static ProfileCubit? myProfileCubit;
static void clearMyProfileReference() {
myProfileCubit = null;
}
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
Future<void> getProfileBySlug(String slug) async {
@@ -141,13 +145,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> getChatRoomIdBySlug({required String slug}) async {
try {
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug);
if (chatIdOrError == '404') {
final chatId = await chatRepository.getChatByUserSlug(slug);
if (chatId == null || chatId.isEmpty) {
final userProfile = await profileRepository.getProfileBySlug(slug);
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else {
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
return chatIdOrError;
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
return chatId;
}
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -1,11 +1,9 @@
import 'dart:developer';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/registration/models/user_registration_model.dart';
import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart';
@@ -18,7 +16,8 @@ import 'package:go_router/go_router.dart';
class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCacheMixin {
final RegistrationRepository registrationRepository;
RegistrationCubit({required this.registrationRepository, required Prefs prefs})
final SessionLifecycleService sessionLifecycleService;
RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs})
: super(
const RegistrationState(
sheet: UserRegistrationSheet(
@@ -62,7 +61,7 @@ class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCa
state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'),
);
await TokenStorage.saveTokenInStorage(token);
await sl<FirebaseApi>().registerPushToken();
await sessionLifecycleService.onAuthenticated();
RegistrationAnalyticsService.reportRegistrationComplete();
cacheRegistrationDate(DateTime.now());
AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen');

View File

@@ -1,6 +1,8 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart';
@@ -13,7 +15,13 @@ import 'package:gpassword/gpassword.dart';
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
final RegistrationRepositoryV2 registrationRepository;
RegistrationCubitV2({required this.registrationRepository}) : super(const RegistrationStateV2());
final FirebaseApi firebaseApi;
final SessionLifecycleService sessionLifecycleService;
RegistrationCubitV2({
required this.registrationRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const RegistrationStateV2());
void setFirstName(String firstName) {
emit(state.copyWith(firstName: firstName));
@@ -194,9 +202,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
bool didApiComplainOnValidation = false;
String errorMessage = '';
try {
final token = await registrationRepository.register(registrationModel: registrationModel);
final token = await registrationRepository.register(
registrationModel: registrationModel,
);
if (token != null) {
TokenStorage.saveTokenInStorage(token);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
emit(state.copyWith(registrationError: null));
return (true, false);
}
@@ -214,11 +225,21 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in emailErrors) {
allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат email') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Некорректный формат email'));
emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Некорректный формат email',
),
);
didApiComplainOnValidation = true;
}
if (error.toString() == 'Такой Эл. почта уже существует.') {
emit(state.copyWith(isEmailValid: false, emailDisplayError: 'Такая Эл. почта уже существует.'));
emit(
state.copyWith(
isEmailValid: false,
emailDisplayError: 'Такая Эл. почта уже существует.',
),
);
didApiComplainOnValidation = true;
}
}
@@ -227,7 +248,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in phoneErrors) {
allErrors.add(error.toString());
if (error.toString() == 'Такой phone уже существует.') {
emit(state.copyWith(isPhoneValid: false, phoneDisplayError: 'Такой номер телефона уже существует.'));
emit(
state.copyWith(
isPhoneValid: false,
phoneDisplayError: 'Такой номер телефона уже существует.',
),
);
didApiComplainOnValidation = true;
}
}
@@ -236,7 +262,12 @@ class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
for (var error in dateOfBirth) {
allErrors.add(error.toString());
if (error.toString() == 'Некорректный формат даты рождения') {
emit(state.copyWith(isBirthdayValid: false, dateOfBirthDisplayError: 'Некорректный формат даты рождения'));
emit(
state.copyWith(
isBirthdayValid: false,
dateOfBirthDisplayError: 'Некорректный формат даты рождения',
),
);
didApiComplainOnValidation = true;
}
}

View File

@@ -1,3 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
@@ -17,12 +19,14 @@ class SettingsCubit extends Cubit<SettingsState> {
final SettingsRepository settingsRepository;
final ConsultantRepository consultantRepository;
final ConsultationsRepository consultationsRepository;
final SessionLifecycleService sessionLifecycleService;
SettingsCubit({
required this.profileRepository,
required this.settingsRepository,
required this.profileRepository,
required this.settingsRepository,
required this.consultantRepository,
required this.consultationsRepository,
required this.sessionLifecycleService,
}) : super(const SettingsState.loading());
Future<void> init() async {
@@ -30,10 +34,12 @@ class SettingsCubit extends Cubit<SettingsState> {
late final ConsultationRequestModel? consultantInfo;
late final bool hasActiveConsultation;
late final OAuthAccountsDataModel? oauthAccounts;
try {
if (profile.phoneConfirmationState.contains('confirmed')) {
consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug);
consultantInfo = await consultantRepository.getMyRequestStatus(
profile.slug,
);
} else {
consultantInfo = null;
}
@@ -41,14 +47,14 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this);
consultantInfo = null;
}
try {
hasActiveConsultation = await _checkActiveConsultation();
} catch (e, st) {
await reportFrom(e, st, this);
hasActiveConsultation = false;
}
try {
final oauthResponse = await settingsRepository.getOAuthAccounts();
oauthAccounts = oauthResponse.data;
@@ -56,7 +62,7 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this);
oauthAccounts = null;
}
try {
emit(
SettingsState.loaded(
@@ -74,9 +80,13 @@ class SettingsCubit extends Cubit<SettingsState> {
}
}
Future<void> updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async {
Future<void> updateProfileDetails({
required ProfileDetailsModel profileDetailsModel,
}) async {
try {
await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel);
await settingsRepository.updateProfileDetails(
profileDetailsModel: profileDetailsModel,
);
} catch (e, st) {
await reportFrom(e, st, this);
} finally {
@@ -84,9 +94,13 @@ class SettingsCubit extends Cubit<SettingsState> {
}
}
Future<void> updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async {
Future<void> updateProfileSettings({
required ProfileSettingsModel profileSettingsModel,
}) async {
try {
await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel);
await settingsRepository.updateProfileSettings(
profileSettingsModel: profileSettingsModel,
);
} catch (e, st) {
await reportFrom(e, st, this);
} finally {
@@ -97,9 +111,15 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> deleteAccount() async {
try {
await profileRepository.deleteAccount();
await sessionLifecycleService.endSession(
reason: SessionEndReason.accountDeleted,
);
return true;
} catch (e, st) {
await reportFrom(e, st, this);
if (e is DioException && e.response?.statusCode == 401) {
return true;
}
return false;
}
}
@@ -135,11 +155,14 @@ class SettingsCubit extends Cubit<SettingsState> {
Future<bool> _checkActiveConsultation() async {
try {
final userPurchases = await consultationsRepository.getUserPurchases(null, null);
final userPurchases = await consultationsRepository.getUserPurchases(
null,
null,
);
final activeStates = ChosenPlanStateEnum.getActiveStates();
return userPurchases.any((purchase) =>
activeStates.contains(purchase.chosenPlanState)
return userPurchases.any(
(purchase) => activeStates.contains(purchase.chosenPlanState),
);
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -698,7 +698,6 @@ class _DeleteAccountBottomSheet extends State<DeleteAccountBottomSheet> {
onPressed: () async {
final result = await widget.cubit.deleteAccount();
if (result == true) {
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
return;
}
AppRouter.navigatorKey.currentContext?.pop();

View File

@@ -52,6 +52,7 @@ class VideoCallCallKitCoordinator {
_PendingIncomingAccept? _pendingIncomingAccept;
_AndroidAcceptResumeObserver? _androidResumeObserver;
_AppResumeObserver? _iosResumeObserver;
bool _pendingAcceptDrainScheduled = false;
Future<void> initialize() async {
@@ -86,6 +87,12 @@ class VideoCallCallKitCoordinator {
}
});
await _firebaseApi.registerPushToken();
_iosResumeObserver = _AppResumeObserver(
onResumed: () {
unawaited(_drainPendingNativeAcceptedVoipCall());
},
);
WidgetsBinding.instance.addObserver(_iosResumeObserver!);
await _drainPendingNativeAcceptedVoipCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scheduleNativeVoipDiagnosticsDrain();
@@ -257,6 +264,11 @@ class VideoCallCallKitCoordinator {
WidgetsBinding.instance.removeObserver(observer);
_androidResumeObserver = null;
}
final iosObserver = _iosResumeObserver;
if (iosObserver != null) {
WidgetsBinding.instance.removeObserver(iosObserver);
_iosResumeObserver = null;
}
unawaited(_eventSubscription?.cancel());
_eventSubscription = null;
_callKitParamsById.clear();
@@ -1228,6 +1240,19 @@ class _PendingIncomingAccept {
final bool? counterpartIsConsultant;
}
class _AppResumeObserver with WidgetsBindingObserver {
_AppResumeObserver({required this.onResumed});
final VoidCallback onResumed;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
onResumed();
}
}
}
class _AndroidAcceptResumeObserver with WidgetsBindingObserver {
_AndroidAcceptResumeObserver({required this.onResumed});

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.67+156
version: 1.3.69+158
publish_to: 'none'
environment:

View File

@@ -0,0 +1,118 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../fixtures/doubles/session_doubles.dart';
class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler {
int nextCalls = 0;
DioException? nextError;
@override
void next(DioException error) {
nextCalls++;
nextError = error;
}
@override
void resolve(Response<dynamic> response) {
throw UnimplementedError('resolve should not be called');
}
@override
void reject(
DioException error, [
bool callFollowingErrorInterceptor = false,
]) {
throw UnimplementedError('reject should not be called');
}
}
DioException _unauthorized(RequestOptions options) {
return DioException(
requestOptions: options,
response: Response<dynamic>(requestOptions: options, statusCode: 401),
type: DioExceptionType.badResponse,
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakeSessionLifecycleService sessionLifecycleService;
late TokenInterceptor interceptor;
setUp(() async {
SharedPreferences.setMockInitialValues({});
TokenStorage.clearCache();
await sl.reset();
sessionLifecycleService = FakeSessionLifecycleService();
sl.registerSingleton<SessionLifecycleService>(sessionLifecycleService);
interceptor = TokenInterceptor();
});
tearDown(() async {
await sl.reset();
TokenStorage.clearCache();
});
group('TokenInterceptor.onError 401', () {
test('request without Authorization does not end the session', () async {
await TokenStorage.saveTokenInStorage('current-token');
final options = RequestOptions(path: '/api/profile/index');
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(handler.nextError, same(error));
expect(sessionLifecycleService.endSessionCalls, 0);
});
test('stale request token does not end the new session', () async {
await TokenStorage.saveTokenInStorage('new-token');
final options = RequestOptions(path: '/api/profile/index');
options.headers['Authorization'] = 'Bearer old-token';
options.extra['authToken'] = 'old-token';
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(sessionLifecycleService.endSessionCalls, 0);
});
test(
'current request token ends the session once as unauthorized',
() async {
await TokenStorage.saveTokenInStorage('current-token');
final options = RequestOptions(path: '/api/profile/index');
options.headers['Authorization'] = 'Bearer current-token';
options.extra['authToken'] = 'current-token';
final error = _unauthorized(options);
final handler = _RecordingErrorInterceptorHandler();
await interceptor.onError(error, handler);
expect(handler.nextCalls, 1);
expect(sessionLifecycleService.endSessionCalls, 1);
expect(
sessionLifecycleService.lastEndReason,
SessionEndReason.unauthorized,
);
},
);
});
}

View File

@@ -1,22 +1,24 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../fixtures/doubles/session_doubles.dart';
class MockRequestOptions extends Mock implements RequestOptions {}
class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {}
class MockRequestInterceptorHandler extends Mock
implements RequestInterceptorHandler {}
class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {}
class MockErrorInterceptorHandler extends Mock
implements ErrorInterceptorHandler {}
class MockResponse extends Mock implements Response {}
class MockWsManager extends Mock implements WsManager {}
class MockDioException extends Mock implements DioException {}
class FakeRequestOptions extends Fake implements RequestOptions {}
@@ -32,23 +34,18 @@ void main() {
});
group('TokenInterceptor', () {
late TokenInterceptor interceptor;
late FakeSessionLifecycleService fakeSession;
setUp(() async {
SharedPreferences.setMockInitialValues({});
final mockWsManager = MockWsManager();
when(() => mockWsManager.clear()).thenAnswer((_) async {});
await sl.reset(); // Сбрасываем sl перед каждым тестом
sl.registerSingleton<WsManager>(mockWsManager);
TokenStorage.clearCache();
await sl.reset();
fakeSession = FakeSessionLifecycleService();
sl.registerSingleton<SessionLifecycleService>(fakeSession);
interceptor = TokenInterceptor();
// Сбрасываем токен перед каждым тестом
try {
await TokenStorage.removeTokenInStorage();
} catch (e) {
// Игнорируем ошибки при очистке
}
await TokenStorage.removeTokenInStorage();
});
group('onRequest', () {
@@ -59,6 +56,7 @@ void main() {
final headers = <String, dynamic>{};
final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null);
@@ -73,6 +71,7 @@ void main() {
final headers = <String, dynamic>{};
final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null);
@@ -89,6 +88,7 @@ void main() {
final headers = <String, dynamic>{};
final options = MockRequestOptions();
when(() => options.headers).thenReturn(headers);
when(() => options.extra).thenReturn(<String, dynamic>{});
final handler = MockRequestInterceptorHandler();
when(() => handler.next(any())).thenReturn(null);
@@ -101,11 +101,16 @@ void main() {
});
group('onError', () {
test('удаляет токен и перенаправляет на auth при 401', () async {
test('завершает сессию при 401 для текущего токена', () async {
const token = 'test-token';
await TokenStorage.saveTokenInStorage(token);
final options = MockRequestOptions();
when(
() => options.headers,
).thenReturn({'Authorization': 'Bearer $token'});
when(() => options.extra).thenReturn({'authToken': token});
final response = MockResponse();
when(() => response.statusCode).thenReturn(401);
@@ -116,14 +121,10 @@ void main() {
final handler = MockErrorInterceptorHandler();
when(() => handler.next(any())).thenReturn(null);
// Проверяем, что токен был удален
final tokenBefore = await TokenStorage.getTokenFromStorage();
expect(tokenBefore, token);
await interceptor.onError(error, handler);
final tokenAfter = await TokenStorage.getTokenFromStorage();
expect(tokenAfter, null);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.unauthorized);
verify(() => handler.next(error)).called(1);
});

View File

@@ -0,0 +1,223 @@
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/prefs.dart';
import 'package:eatvio_app/core/prefs_keys.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
class _FakeWsManager implements WsManager {
int clearCalls = 0;
@override
Future<void> clear() async {
clearCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('WsManager.${invocation.memberName}');
}
}
class _FakeErrorReporter implements ErrorReporter {
int reportCalls = 0;
@override
Future<void> report(
Object error,
StackTrace? stackTrace, {
String? origin,
Map<String, Object?>? extras,
}) async {
reportCalls++;
}
}
class _FakeProfileAvatarCubit implements ProfileAvatarCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}');
}
}
class _FakeChatsCubit implements ChatsCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('ChatsCubit.${invocation.memberName}');
}
}
class _FakeNotificationsCubit implements NotificationsCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('NotificationsCubit.${invocation.memberName}');
}
}
class _FakeNewMessagesCubit implements NewMessagesCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('NewMessagesCubit.${invocation.memberName}');
}
}
class _FakeFeedCubit implements FeedCubit {
int resetCalls = 0;
@override
void reset() {
resetCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('FeedCubit.${invocation.memberName}');
}
}
class _FakeVideoCallSessionCubit implements VideoCallSessionCubit {
int endCallCalls = 0;
@override
Future<void> endCall() async {
endCallCalls++;
}
@override
dynamic noSuchMethod(Invocation invocation) {
throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}');
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late _FakeWsManager wsManager;
late _FakeProfileAvatarCubit profileAvatarCubit;
late _FakeChatsCubit chatsCubit;
late _FakeNotificationsCubit notificationsCubit;
late _FakeNewMessagesCubit newMessagesCubit;
late _FakeFeedCubit feedCubit;
late _FakeVideoCallSessionCubit videoCallSessionCubit;
late SessionLifecycleService service;
setUp(() async {
SharedPreferences.setMockInitialValues({
'token': 'token-a',
'socketId': 'socket-a',
PrefsKeys.customBaseUrl.name: 'https://old.example',
PrefsKeys.isEmailVerificationSkipped.name: true,
PrefsKeys.registrationDate.name: '2024-01-01',
PrefsKeys.welcomeScreenWasShowed.name: true,
});
TokenStorage.clearCache();
await sl.reset();
wsManager = _FakeWsManager();
profileAvatarCubit = _FakeProfileAvatarCubit();
chatsCubit = _FakeChatsCubit();
notificationsCubit = _FakeNotificationsCubit();
newMessagesCubit = _FakeNewMessagesCubit();
feedCubit = _FakeFeedCubit();
videoCallSessionCubit = _FakeVideoCallSessionCubit();
sl.registerSingleton<ProfileAvatarCubit>(profileAvatarCubit);
sl.registerSingleton<ChatsCubit>(chatsCubit);
sl.registerSingleton<NotificationsCubit>(notificationsCubit);
sl.registerSingleton<NewMessagesCubit>(newMessagesCubit);
sl.registerSingleton<FeedCubit>(feedCubit);
sl.registerSingleton<VideoCallSessionCubit>(videoCallSessionCubit);
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
service = SessionLifecycleService(
wsManager: wsManager,
errorReporter: _FakeErrorReporter(),
);
});
tearDown(() async {
await sl.reset();
TokenStorage.clearCache();
});
group('SessionLifecycleService.endSession', () {
test(
'resets user-scoped state and clears only session-scoped keys',
() async {
await service.endSession(reason: SessionEndReason.logout);
expect(wsManager.clearCalls, 1);
expect(profileAvatarCubit.resetCalls, 1);
expect(chatsCubit.resetCalls, 1);
expect(notificationsCubit.resetCalls, 1);
expect(newMessagesCubit.resetCalls, 1);
expect(feedCubit.resetCalls, 1);
expect(videoCallSessionCubit.endCallCalls, 1);
expect(await TokenStorage.getTokenFromStorage(), isNull);
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
final prefs = sl<Prefs>();
expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example');
expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull);
expect(prefs.getString(PrefsKeys.registrationDate), isNull);
expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue);
},
);
test('is idempotent for concurrent calls', () async {
final first = service.endSession(reason: SessionEndReason.logout);
final second = service.endSession(reason: SessionEndReason.unauthorized);
await Future.wait([first, second]);
expect(wsManager.clearCalls, 1);
expect(profileAvatarCubit.resetCalls, 1);
expect(chatsCubit.resetCalls, 1);
expect(notificationsCubit.resetCalls, 1);
expect(newMessagesCubit.resetCalls, 1);
expect(feedCubit.resetCalls, 1);
expect(videoCallSessionCubit.endCallCalls, 1);
expect(await TokenStorage.getTokenFromStorage(), isNull);
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
});
});
}

View File

@@ -12,6 +12,7 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
import 'auth_cubit_test.mocks.dart';
@GenerateNiceMocks([
@@ -75,6 +76,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
expect(cubit!.state, isA<AuthState>());
@@ -86,6 +88,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
const email = 'test@example.com';
@@ -116,6 +119,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
const email = 'test@example.com';
@@ -140,6 +144,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockPrefs.getBool(any)).thenReturn(false);
@@ -159,6 +164,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockPrefs.getBool(any)).thenReturn(true);
@@ -174,6 +180,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockFirebaseApi.registerPushToken())
@@ -190,6 +197,7 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
// TokenStorage.haveTokenInStorage() вернет false

View File

@@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../fixtures/doubles/auth_doubles.dart';
import '../../../../fixtures/doubles/profile_doubles.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/fixture_reader.dart';
import 'auth_cubit_test.mocks.dart';
@@ -26,6 +27,7 @@ void main() {
late FixtureProfileRepository profileRepository;
late MockPrefs mockPrefs;
late MockFirebaseApi mockFirebaseApi;
late FakeSessionLifecycleService fakeSession;
late Map<String, dynamic> credentialsFixture;
late TokenModel tokenFixture;
@@ -37,6 +39,7 @@ void main() {
profileRepository = FixtureProfileRepository();
mockPrefs = MockPrefs();
mockFirebaseApi = MockFirebaseApi();
fakeSession = FakeSessionLifecycleService();
SharedPreferences.setMockInitialValues({});
// Mock Fluttertoast plugin
@@ -59,12 +62,12 @@ void main() {
});
test('начальное состояние - AuthState', () {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
expect(authCubit!.state, const AuthState());
});
test('getTokenByCredentials - успешная авторизация', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
@@ -77,7 +80,7 @@ void main() {
});
test('getTokenByCredentials - переход в состояние loading', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.responseDelay = const Duration(milliseconds: 50);
final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String);
@@ -88,7 +91,7 @@ void main() {
});
test('getTokenByCredentials - обработка ошибки авторизации', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.error = Exception('Неверный адрес электронной почты или пароль');
final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword');
@@ -102,7 +105,7 @@ void main() {
});
test('getTokenByCredentials - обработка различных типов ошибок', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
authRepository.error = const FormatException('Invalid credentials format');
final result = await authCubit!.getTokenByCredentials('invalid', 'pass');
@@ -115,7 +118,7 @@ void main() {
});
test('getTokenByCredentials - проверка вызова с правильными параметрами', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123');
@@ -124,7 +127,7 @@ void main() {
});
test('getTokenByCredentials - множественные вызовы', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
await authCubit!.getTokenByCredentials('first@test.com', 'password1');
await Future.delayed(const Duration(milliseconds: 100));
@@ -138,7 +141,7 @@ void main() {
});
test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false);
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
@@ -150,7 +153,7 @@ void main() {
});
test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true);
await authCubit!.registerAnonymousPushToken();
@@ -159,7 +162,7 @@ void main() {
});
test('registerPushToken - регистрирует push токен', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {});
await authCubit!.registerPushToken();
@@ -168,7 +171,7 @@ void main() {
});
test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async {
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession);
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null);
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.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_confirm_response.dart';
@@ -13,9 +14,21 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
import '../../../../../fixtures/fixture_reader.dart';
import 'oauth_cubit_test.mocks.dart';
class _RecordingFirebaseApi extends Fake implements FirebaseApi {
int registerPushTokenCalls = 0;
bool hadAuthToken = false;
@override
Future<void> registerPushToken() async {
registerPushTokenCalls++;
hadAuthToken = await TokenStorage.haveTokenInStorage();
}
}
@GenerateNiceMocks([MockSpec<AuthRepository>(), MockSpec<Prefs>()])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -24,26 +37,38 @@ void main() {
OAuthCubit? oauthCubit;
late MockAuthRepository mockAuthRepository;
late MockPrefs mockPrefs;
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
late TokenModel tokenFixture;
late Map<String, dynamic> signInRequestFixture;
late Map<String, dynamic> signUpRequestFixture;
setUp(() async {
tokenFixture = TokenModel.fromJson(FixtureReader.readAsMap('auth/token_response.json'));
signInRequestFixture = FixtureReader.readAsMap('auth/oauth_signin_request.json');
signUpRequestFixture = FixtureReader.readAsMap('auth/oauth_signup_request.json');
tokenFixture = TokenModel.fromJson(
FixtureReader.readAsMap('auth/token_response.json'),
);
signInRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signin_request.json',
);
signUpRequestFixture = FixtureReader.readAsMap(
'auth/oauth_signup_request.json',
);
mockAuthRepository = MockAuthRepository();
mockPrefs = MockPrefs();
firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
SharedPreferences.setMockInitialValues({});
// Mock Fluttertoast plugin
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), (
MethodCall methodCall,
) async {
return null;
});
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
(MethodCall methodCall) async {
return null;
},
);
});
tearDown(() async {
@@ -52,18 +77,31 @@ void main() {
oauthCubit!.close();
oauthCubit = null;
}
TokenStorage.clearCache();
// Clear mock handlers
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('PonnamKarthik/fluttertoast'), null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
null,
);
});
test('начальное состояние - OAuthState', () {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
expect(oauthCubit!.state, const OAuthState());
});
test('signInViaSocialMedia - успешный вход через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -92,7 +130,11 @@ void main() {
});
test('signInViaSocialMedia - переход в состояние loading', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -119,7 +161,11 @@ void main() {
});
test('signInViaSocialMedia - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -144,7 +190,11 @@ void main() {
});
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.signUpViaSocialMedia(
@@ -164,7 +214,9 @@ void main() {
),
).thenAnswer((_) async => tokenFixture.token);
when(mockPrefs.setString(any, any)).thenAnswer((_) async => Future.value());
when(
mockPrefs.setString(any, any),
).thenAnswer((_) async => Future.value());
final result = await oauthCubit!.signUpViaSocialMedia(
provider: signUpRequestFixture['provider'] as String,
@@ -187,48 +239,61 @@ void main() {
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(fakeSession.onAuthenticatedCalls, 1);
});
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'signInViaSocialMedia - обработка ошибки валидации email/phone',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
response: Response(
statusCode: 422,
data: {
'errors': {
'email': ['Некорректный формат email'],
},
},
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
),
);
response: Response(
statusCode: 422,
data: {
'errors': {
'email': ['Некорректный формат email'],
},
},
requestOptions: RequestOptions(path: '/api/auth/oauth/signin'),
),
);
when(
mockAuthRepository.signInViaSocialMedia(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
),
).thenThrow(dioException);
when(
mockAuthRepository.signInViaSocialMedia(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
),
).thenThrow(dioException);
await oauthCubit!.signInViaSocialMedia(
provider: signInRequestFixture['provider'] as String,
accessToken: signInRequestFixture['access_token'] as String,
refreshToken: signInRequestFixture['refresh_token'] as String,
clientId: signInRequestFixture['client_id'] as String,
);
await oauthCubit!.signInViaSocialMedia(
provider: signInRequestFixture['provider'] as String,
accessToken: signInRequestFixture['access_token'] as String,
refreshToken: signInRequestFixture['refresh_token'] as String,
clientId: signInRequestFixture['client_id'] as String,
);
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateError>());
final errorState = oauthCubit!.state as OAuthStateError;
expect(errorState.errorMessage, 'validation_error');
});
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateError>());
final errorState = oauthCubit!.state as OAuthStateError;
expect(errorState.errorMessage, 'validation_error');
},
);
test('signUpViaSocialMedia - обработка ошибки валидации email', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
@@ -282,82 +347,129 @@ void main() {
expect(result[0], RegistrationValidationErrors.incorrectEmailFormat);
});
test('requestOAuthVerification - успешный запрос кода верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'requestOAuthVerification - успешный запрос кода верификации',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
const verificationResponse = OAuthVerificationRequestResponse(email: 't***@example.com', status: 'sent');
const verificationResponse = OAuthVerificationRequestResponse(
email: 't***@example.com',
status: 'sent',
);
when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenAnswer((_) async => verificationResponse);
when(
mockAuthRepository.requestOAuthVerification(
email: anyNamed('email'),
phone: anyNamed('phone'),
),
).thenAnswer((_) async => verificationResponse);
final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com');
final result = await oauthCubit!.requestOAuthVerification(
email: 'test@example.com',
);
expect(result, isNotNull);
expect(result!.email, 't***@example.com');
expect(oauthCubit!.state, isA<OAuthState>());
});
expect(result, isNotNull);
expect(result!.email, 't***@example.com');
expect(oauthCubit!.state, isA<OAuthState>());
},
);
test('requestOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(mockAuthRepository.requestOAuthVerification(email: anyNamed('email'), phone: anyNamed('phone'))).thenThrow(Exception('Ошибка запроса'));
when(
mockAuthRepository.requestOAuthVerification(
email: anyNamed('email'),
phone: anyNamed('phone'),
),
).thenThrow(Exception('Ошибка запроса'));
final result = await oauthCubit!.requestOAuthVerification(email: 'test@example.com');
final result = await oauthCubit!.requestOAuthVerification(
email: 'test@example.com',
);
expect(result, isNull);
expect(oauthCubit!.state, isA<OAuthStateError>());
});
test('confirmOAuthVerification - успешное подтверждение верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
test(
'confirmOAuthVerification - успешное подтверждение верификации',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
final verificationResponse = OAuthVerificationConfirmResponse(success: true, message: 'Success', token: tokenFixture.token, user: {}, isNewUser: false);
final verificationResponse = OAuthVerificationConfirmResponse(
success: true,
message: 'Success',
token: tokenFixture.token,
user: {},
isNewUser: false,
);
when(
mockAuthRepository.confirmOAuthVerification(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
id: anyNamed('id'),
email: anyNamed('email'),
phone: anyNamed('phone'),
code: anyNamed('code'),
verificationType: anyNamed('verificationType'),
firstName: anyNamed('firstName'),
lastName: anyNamed('lastName'),
dateOfBirth: anyNamed('dateOfBirth'),
gender: anyNamed('gender'),
weight: anyNamed('weight'),
height: anyNamed('height'),
),
).thenAnswer((_) async => verificationResponse);
when(
mockAuthRepository.confirmOAuthVerification(
provider: anyNamed('provider'),
accessToken: anyNamed('accessToken'),
refreshToken: anyNamed('refreshToken'),
clientId: anyNamed('clientId'),
id: anyNamed('id'),
email: anyNamed('email'),
phone: anyNamed('phone'),
code: anyNamed('code'),
verificationType: anyNamed('verificationType'),
firstName: anyNamed('firstName'),
lastName: anyNamed('lastName'),
dateOfBirth: anyNamed('dateOfBirth'),
gender: anyNamed('gender'),
weight: anyNamed('weight'),
height: anyNamed('height'),
),
).thenAnswer((_) async => verificationResponse);
await oauthCubit!.confirmOAuthVerification(
provider: 'google',
accessToken: 'access_token',
refreshToken: 'refresh_token',
clientId: 'client_id',
id: 'id',
email: 'test@example.com',
phone: null,
code: '123456',
verificationType: 'email',
firstName: 'Test',
lastName: 'User',
dateOfBirth: '2000-01-01',
gender: 'male',
weight: 70,
height: 175,
);
await oauthCubit!.confirmOAuthVerification(
provider: 'google',
accessToken: 'access_token',
refreshToken: 'refresh_token',
clientId: 'client_id',
id: 'id',
email: 'test@example.com',
phone: null,
code: '123456',
verificationType: 'email',
firstName: 'Test',
lastName: 'User',
dateOfBirth: '2000-01-01',
gender: 'male',
weight: 70,
height: 175,
);
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
});
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(fakeSession.onAuthenticatedCalls, 1);
},
);
test('confirmOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
when(
mockAuthRepository.confirmOAuthVerification(

View File

@@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../../../fixtures/doubles/auth_doubles.dart';
import '../../../../fixtures/doubles/profile_doubles.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/fixture_reader.dart';
import 'basic_widget_test.mocks.dart';
@@ -36,7 +37,7 @@ void main() {
mockFirebaseApi = MockFirebaseApi();
SharedPreferences.setMockInitialValues({});
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
});
tearDown(() {
@@ -55,7 +56,7 @@ void main() {
testWidgets('should create AuthCubit successfully', (WidgetTester tester) async {
// Arrange & Act
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
// Assert
expect(cubit, isA<AuthCubit>());

View File

@@ -0,0 +1,204 @@
import 'dart:async';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.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/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shared_preferences/shared_preferences.dart';
class MockProfileRepository extends Mock implements ProfileRepository {}
const _profileA = ProfileModel(
id: 1,
slug: 'user-a',
name: 'User A',
email: 'a@example.com',
phoneConfirmationState: 'confirmed',
isPublicProfile: true,
isBlockedByMe: false,
isBlockedByAuthor: false,
isBlockedByUser: false,
isEntitySubscribedByMe: false,
profilePhotoUrl: 'avatars/a.png',
roles: [],
badges: [],
subscriptions: [],
subscribers: [],
);
const _profileB = ProfileModel(
id: 2,
slug: 'user-b',
name: 'User B',
email: 'b@example.com',
phoneConfirmationState: 'confirmed',
isPublicProfile: true,
isBlockedByMe: false,
isBlockedByAuthor: false,
isBlockedByUser: false,
isEntitySubscribedByMe: false,
profilePhotoUrl: 'avatars/b.png',
roles: [],
badges: [],
subscriptions: [],
subscribers: [],
);
const _details = ProfileDetailsModel(
firstName: 'User',
lastName: 'Test',
birthdate: '1990-01-01',
gender: 'male',
country: 'Russia',
city: 'Moscow',
lang: 'ru',
weight: 70.0,
height: 175,
chestCircumference: 100,
waistCircumference: 80,
hipCircumference: 90,
bloodPressureUp: 120,
bloodPressureDown: 80,
hasHypo: false,
hasHyper: false,
aboutAlergy: '-',
aboutTakingPils: '-',
aboutSportHobbies: '-',
favoriteFoods: '-',
unfavoriteFoods: '-',
additionalInfo: '-',
isMoodFillable: true,
isBedtimeFillable: true,
activityCoefficient: 1.5,
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MockProfileRepository repository;
ProfileAvatarCubit? cubit;
setUp(() async {
await sl.reset();
SharedPreferences.setMockInitialValues({'token': 'token-a'});
TokenStorage.clearCache();
repository = MockProfileRepository();
});
tearDown(() async {
if (cubit != null && !cubit!.isClosed) {
await cubit!.close();
}
cubit = null;
await sl.reset();
TokenStorage.clearCache();
});
void stubSuccessfulProfile(ProfileModel profile) {
when(() => repository.getMyProfile()).thenAnswer((_) async => profile);
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
}
test('reset emits loading and clears the cached profile', () async {
stubSuccessfulProfile(_profileA);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
cubit!.reset();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
await cubit!.ensureFreshForCurrentSession();
verify(() => repository.getMyProfile()).called(2);
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
});
test('late getMyProfile completion after reset is ignored', () async {
final profileCompleter = Completer<ProfileModel>();
when(
() => repository.getMyProfile(),
).thenAnswer((_) => profileCompleter.future);
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
cubit = ProfileAvatarCubit(profileRepository: repository);
final pendingRequest = cubit!.getMyProfile();
await pumpEventQueue();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
cubit!.reset();
profileCompleter.complete(_profileA);
await pendingRequest;
await pumpEventQueue();
expect(cubit!.state, isA<ProfileAvatarStateLoading>());
verifyNever(() => repository.getMyProfileDetails());
});
test(
'ensureFreshForCurrentSession does not refetch for the same token',
() async {
stubSuccessfulProfile(_profileA);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
await cubit!.ensureFreshForCurrentSession();
verify(() => repository.getMyProfile()).called(1);
expect(cubit!.state, isA<ProfileAvatarStateLoaded>());
},
);
test(
'ensureFreshForCurrentSession refetches after the token changes',
() async {
final profiles = <ProfileModel>[_profileA, _profileB];
when(
() => repository.getMyProfile(),
).thenAnswer((_) async => profiles.removeAt(0));
when(
() => repository.getMyProfileDetails(),
).thenAnswer((_) async => _details);
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.ensureFreshForCurrentSession();
final firstState = cubit!.state as ProfileAvatarStateLoaded;
expect(firstState.profile.id, _profileA.id);
await TokenStorage.saveTokenInStorage('token-b');
await cubit!.ensureFreshForCurrentSession();
final secondState = cubit!.state as ProfileAvatarStateLoaded;
expect(secondState.profile.id, _profileB.id);
expect(secondState.profile.profilePhotoUrl, _profileB.profilePhotoUrl);
verify(() => repository.getMyProfile()).called(2);
},
);
test('errors emit ProfileAvatarStateError and do not navigate', () async {
when(
() => repository.getMyProfile(),
).thenAnswer((_) async => throw Exception('profile request failed'));
cubit = ProfileAvatarCubit(profileRepository: repository);
await cubit!.getMyProfile();
expect(cubit!.state, isA<ProfileAvatarStateError>());
expect(
(cubit!.state as ProfileAvatarStateError).errorMessage,
contains('profile request failed'),
);
});
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/token_storage.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/profile/models/profile_model.dart';
@@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profile_avatar_cubit_test.mocks.dart';
@@ -16,6 +18,8 @@ void main() {
late MockProfileRepository mockRepository;
setUp(() {
SharedPreferences.setMockInitialValues({'token': 'test-token'});
TokenStorage.clearCache();
mockRepository = MockProfileRepository();
});

View File

@@ -526,11 +526,11 @@ void main() {
},
);
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
test('getChatByUserSlug - возвращает null, если в ответе нет id', () async {
const slug = 'user-slug';
final response = HttpResponse(
{},
Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
);
when(
@@ -539,7 +539,7 @@ void main() {
final result = await chatRepository.getChatByUserSlug(slug);
expect(result, '404');
expect(result, isNull);
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
});

View File

@@ -16,5 +16,29 @@ void main() {
expect(find.byType(BecomeConsultantScreen), findsOneWidget);
expect(find.text('Стать консультантом'), findsOneWidget);
});
testWidgets('keeps the bottom action above the system navigation area', (tester) async {
const bottomInset = 34.0;
await tester.pumpWidget(
MaterialApp(
theme: ThemeHelper().themeData(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(bottom: bottomInset)),
child: child!,
),
home: const BecomeConsultantScreen(),
),
);
await tester.pumpAndSettle();
final buttonRect = tester.getRect(
find.ancestor(of: find.text('Начать регистрацию'), matching: find.byType(Ink)).first,
);
final viewHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio;
expect(buttonRect.bottom, lessThanOrEqualTo(viewHeight - bottomInset));
expect(tester.takeException(), isNull);
});
});
}

View File

@@ -156,6 +156,55 @@ void main() {
});
group('ConsultationModel', () {
test('fromJson parses chat_id', () {
final json = {
'id': 1,
'chat_id': 'direct-chat-uuid',
'last_state': 'started',
'plan_title': 'Test Plan',
'user_name': 'User 1',
'user_slug': 'user-1',
'consultant_payment': 1000.0,
'duration': 30,
'current_diary_day': 5,
'user_profile_photo_url': 'photo.jpg',
'consultation_option_price': 2000,
'start_date': '2024-01-01T00:00:00Z',
'end_date': '2024-01-31T00:00:00Z',
'consultant_id': 1,
'diaries': [],
'personal_diet_id': null,
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, 'direct-chat-uuid');
});
test('fromJson allows missing chat_id', () {
final json = {
'id': 1,
'last_state': 'started',
'plan_title': 'Test Plan',
'user_name': 'User 1',
'user_slug': 'user-1',
'consultant_payment': 1000.0,
'duration': 30,
'current_diary_day': 5,
'user_profile_photo_url': 'photo.jpg',
'consultation_option_price': 2000,
'start_date': '2024-01-01T00:00:00Z',
'end_date': '2024-01-31T00:00:00Z',
'consultant_id': 1,
'diaries': [],
'personal_diet_id': null,
};
final model = ConsultationModel.fromJson(json);
expect(model.chatId, isNull);
});
test('toJson serializes ConsultationModel correctly', () {
final model = ConsultationModel(
id: 1,

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/enums/base_state.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_state.dart';
@@ -8,14 +9,21 @@ import 'package:mocktail/mocktail.dart';
class MockConsultationsRepository extends Mock implements ConsultationsRepository {}
class MockChatRepository extends Mock implements ChatRepository {}
void main() {
group('ConsultationsCubit', () {
late ConsultationsCubit cubit;
late MockConsultationsRepository mockRepository;
late MockChatRepository mockChatRepository;
setUp(() {
mockRepository = MockConsultationsRepository();
cubit = ConsultationsCubit(consultationsRepository: mockRepository);
mockChatRepository = MockChatRepository();
cubit = ConsultationsCubit(
consultationsRepository: mockRepository,
chatRepository: mockChatRepository,
);
});
tearDown(() {
@@ -47,5 +55,33 @@ void main() {
expect(cubit.state.state, BaseState.error);
});
test('getChatIdByUserSlug - возвращает id чата', () async {
when(() => mockChatRepository.getChatByUserSlug('john-doe'))
.thenAnswer((_) async => 'chat-uuid');
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, 'chat-uuid');
verify(() => mockChatRepository.getChatByUserSlug('john-doe')).called(1);
});
test('getChatIdByUserSlug - возвращает null при ошибке', () async {
when(() => mockChatRepository.getChatByUserSlug(any()))
.thenThrow(Exception('Error'));
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, isNull);
});
test('getChatIdByUserSlug - возвращает null когда чата нет', () async {
when(() => mockChatRepository.getChatByUserSlug(any()))
.thenAnswer((_) async => null);
final chatId = await cubit.getChatIdByUserSlug('john-doe');
expect(chatId, isNull);
});
});
}

View File

@@ -1,18 +1,26 @@
import 'dart:async';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
import 'package:eatvio_app/features/consultations/presentation/cubit/consultations_cubit.dart';
import 'package:eatvio_app/features/consultations/presentation/widgets/consultation_card_widget.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:mocktail/mocktail.dart';
import 'package:network_image_mock/network_image_mock.dart';
class MockConsultationsCubit extends Mock implements ConsultationsCubit {}
void main() {
group('ConsultationCardWidget smoke', () {
setUpAll(() async {
@@ -28,6 +36,20 @@ void main() {
AppConfig(buildType: AppBuildType.tests)
..setBaseUrl('http://localhost'),
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
(MethodCall methodCall) async => null,
);
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('PonnamKarthik/fluttertoast'),
null,
);
});
final mockConsultation = ConsultationModel(
@@ -64,7 +86,8 @@ void main() {
personalDietId: null,
);
Widget wrap(Widget child) {
Widget wrap(Widget child, {ConsultationsCubit? cubit}) {
final consultationsCubit = cubit ?? MockConsultationsCubit();
return MaterialApp.router(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
@@ -77,7 +100,20 @@ void main() {
routerConfig: GoRouter(
initialLocation: '/',
routes: [
GoRoute(path: '/', builder: (context, state) => child),
GoRoute(
path: '/',
builder: (context, state) => BlocProvider<ConsultationsCubit>.value(
value: consultationsCubit,
child: child,
),
),
GoRoute(
path: '/chat-screen',
name: 'chatScreen',
builder: (context, state) => Scaffold(
body: Text('chat:${(state.extra as Map)['uuid']}'),
),
),
GoRoute(
path: '/profile-screen',
name: 'profileScreen',
@@ -163,5 +199,118 @@ void main() {
});
},
);
testWidgets('opens chat with consultation user when chat icon tapped', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) async => 'chat-uuid');
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
expect(find.text('chat:chat-uuid'), findsOneWidget);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
testWidgets(
'opens chat directly with chat id without requesting it again',
(tester) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
final consultationWithChatId = mockConsultation.copyWith(
chatId: 'direct-chat-uuid',
);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(
consultationsModel: consultationWithChatId,
),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
expect(find.text('chat:direct-chat-uuid'), findsOneWidget);
verifyNever(() => cubit.getChatIdByUserSlug(any()));
});
},
);
testWidgets('shows error and stays on card when chat id is null', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) async => null);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 3));
expect(find.textContaining('chat:'), findsNothing);
expect(
find.byType(ConsultationCardWidget),
findsOneWidget,
);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
testWidgets('does not request chat id twice while first is pending', (
tester,
) async {
final cubit = MockConsultationsCubit();
when(() => cubit.stream).thenAnswer((_) => const Stream.empty());
final completer = Completer<String?>();
when(() => cubit.getChatIdByUserSlug('john-doe'))
.thenAnswer((_) => completer.future);
await mockNetworkImagesFor(() async {
await tester.pumpWidget(
wrap(
ConsultationCardWidget(consultationsModel: mockConsultation),
cubit: cubit,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.chat_outlined));
await tester.tap(find.byIcon(Icons.chat_outlined));
completer.complete('chat-uuid');
await tester.pumpAndSettle();
expect(find.text('chat:chat-uuid'), findsOneWidget);
verify(() => cubit.getChatIdByUserSlug('john-doe')).called(1);
});
});
});
}

View File

@@ -4,6 +4,8 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'menu_cubit_test.mocks.dart';
@GenerateNiceMocks([MockSpec<ProfileRepository>()])
@@ -25,12 +27,12 @@ void main() {
});
test('начальное состояние - MenuState', () {
menuCubit = MenuCubit(mockProfileRepository);
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
expect(menuCubit!.state, isA<MenuState>());
});
test('makePassKey - успешно создает passKey', () async {
menuCubit = MenuCubit(mockProfileRepository);
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
const expectedPassKey = 'test-pass-key';
when(mockProfileRepository.getPassKey(
@@ -48,7 +50,7 @@ void main() {
});
test('makePassKey - возвращает null при ошибке', () async {
menuCubit = MenuCubit(mockProfileRepository);
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
when(mockProfileRepository.getPassKey(
routeName: 'product_nutritional_value.index',

View File

@@ -15,6 +15,7 @@ import 'package:go_router/go_router.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'menu_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([MockSpec<ProfileRepository>()])
@@ -138,7 +139,7 @@ void main() {
testWidgets('renders menu screen for user', (tester) async {
final repository = MockProfileRepository();
final cubit = MenuCubit(repository);
final cubit = MenuCubit(repository, FakeSessionLifecycleService());
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile);
@@ -155,7 +156,7 @@ void main() {
testWidgets('renders menu screen for consultant', (tester) async {
final repository = MockProfileRepository();
final cubit = MenuCubit(repository);
final cubit = MenuCubit(repository, FakeSessionLifecycleService());
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile);

View File

@@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import '../../fixtures/doubles/session_doubles.dart';
import 'basic_widget_test.mocks.dart';
@GenerateMocks([RegistrationRepository, Prefs])
@@ -19,7 +20,7 @@ void main() {
setUp(() {
mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
});
tearDown(() {
@@ -38,7 +39,7 @@ void main() {
testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async {
// Arrange & Act
final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
// Assert
expect(cubit, isA<RegistrationCubit>());

View File

@@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import '../../fixtures/doubles/session_doubles.dart';
import 'integration_widget_test.mocks.dart';
@GenerateMocks([RegistrationRepository, Prefs])
@@ -17,7 +18,7 @@ void main() {
setUp(() {
mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
});
tearDown(() {

View File

@@ -7,6 +7,8 @@ import 'package:eatvio_app/features/registration/repositories/registration_repos
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import '../../../../../fixtures/doubles/session_doubles.dart';
class MockRegistrationRepository extends Mock implements RegistrationRepository {}
class MockPrefs extends Mock implements Prefs {}
@@ -26,7 +28,7 @@ void main() {
setUp(() {
mockRepository = MockRegistrationRepository();
mockPrefs = MockPrefs();
cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs);
cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs, sessionLifecycleService: FakeSessionLifecycleService());
});
tearDown(() {

View File

@@ -13,6 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'registration_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([MockSpec<RegistrationRepository>(), MockSpec<Prefs>()])
@@ -44,7 +45,7 @@ void main() {
testWidgets('renders registration screen with initial step', (tester) async {
final repository = MockRegistrationRepository();
final prefs = MockPrefs();
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs);
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService());
sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs);
@@ -59,7 +60,7 @@ void main() {
testWidgets('renders phone screen as first step', (tester) async {
final repository = MockRegistrationRepository();
final prefs = MockPrefs();
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs);
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService());
sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs);

View File

@@ -1,4 +1,6 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart';
import 'package:flutter/services.dart';
@@ -7,45 +9,66 @@ import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../fixtures/doubles/session_doubles.dart';
import 'registration_cubit_v2_test.mocks.dart';
class _RecordingFirebaseApi extends Fake implements FirebaseApi {
int registerPushTokenCalls = 0;
bool hadAuthToken = false;
@override
Future<void> registerPushToken() async {
registerPushTokenCalls++;
hadAuthToken = await TokenStorage.haveTokenInStorage();
}
}
@GenerateMocks([RegistrationRepositoryV2])
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('RegistrationCubitV2', () {
late RegistrationCubitV2 cubit;
late MockRegistrationRepositoryV2 mockRepository;
const MethodChannel passwordGeneratorChannel = MethodChannel('flutter_password_generator');
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
const MethodChannel passwordGeneratorChannel = MethodChannel(
'flutter_password_generator',
);
setUp(() {
mockRepository = MockRegistrationRepositoryV2();
cubit = RegistrationCubitV2(registrationRepository: mockRepository);
firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
cubit = RegistrationCubitV2(
registrationRepository: mockRepository,
firebaseApi: firebaseApi,
sessionLifecycleService: fakeSession,
);
// Инициализируем SharedPreferences для тестов
SharedPreferences.setMockInitialValues({});
// Мокируем MethodChannel для flutter_password_generator
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
passwordGeneratorChannel,
(MethodCall methodCall) async {
if (methodCall.method == 'generateSecureRandomData') {
// Возвращаем Uint8List для генерации пароля
// MethodChannel автоматически преобразует Uint8List в List<int>
return Uint8List.fromList(List<int>.generate(32, (i) => i % 256));
}
return null;
},
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(passwordGeneratorChannel, (
MethodCall methodCall,
) async {
if (methodCall.method == 'generateSecureRandomData') {
// Возвращаем Uint8List для генерации пароля
// MethodChannel автоматически преобразует Uint8List в List<int>
return Uint8List.fromList(List<int>.generate(32, (i) => i % 256));
}
return null;
});
});
tearDown(() {
TokenStorage.clearCache();
cubit.close();
// Очищаем моки
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(
passwordGeneratorChannel,
null,
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(passwordGeneratorChannel, null);
});
test('начальное состояние', () {
@@ -182,14 +205,23 @@ void main() {
cubit.setBirthday(DateTime(1990, 1, 1));
const token = 'test-token';
when(mockRepository.register(registrationModel: anyNamed('registrationModel')))
.thenAnswer((_) async => token);
when(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenAnswer((_) async => token);
final result = await cubit.register();
expect(result.$1, isTrue);
expect(result.$2, isFalse);
verify(mockRepository.register(registrationModel: anyNamed('registrationModel'))).called(1);
expect(await TokenStorage.getTokenFromStorage(), token);
expect(fakeSession.onAuthenticatedCalls, 1);
verify(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).called(1);
});
test('register - возвращает false при невалидных полях', () async {
@@ -199,7 +231,11 @@ void main() {
expect(result.$1, isFalse);
expect(result.$2, isTrue);
verifyNever(mockRepository.register(registrationModel: anyNamed('registrationModel')));
verifyNever(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
);
});
test('register - обработка ошибки валидации email', () async {
@@ -226,16 +262,21 @@ void main() {
),
);
when(mockRepository.register(registrationModel: anyNamed('registrationModel')))
.thenThrow(dioException);
when(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).thenThrow(dioException);
final result = await cubit.register();
expect(result.$1, isFalse);
expect(result.$2, isTrue);
expect(cubit.state.isEmailValid, isFalse);
expect(cubit.state.emailDisplayError, equals('Некорректный формат email'));
expect(
cubit.state.emailDisplayError,
equals('Некорректный формат email'),
);
});
});
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/settings/repositories/service_repository.dar
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart';
import 'settings_cubit_test.mocks.dart';
@@ -23,6 +25,7 @@ void main() {
late MockSettingsRepository mockSettingsRepository;
late MockConsultantRepository mockConsultantRepository;
late MockConsultationsRepository mockConsultationsRepository;
late FakeSessionLifecycleService fakeSession;
setUp(() {
@@ -30,6 +33,7 @@ void main() {
mockSettingsRepository = MockSettingsRepository();
mockConsultantRepository = MockConsultantRepository();
mockConsultationsRepository = MockConsultationsRepository();
fakeSession = FakeSessionLifecycleService();
});
tearDown(() async {
@@ -46,6 +50,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
expect(settingsCubit!.state, const SettingsState.loading());
});
@@ -56,6 +61,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -86,6 +92,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
// Ошибка происходит при загрузке настроек профиля (внутри try-catch блока)
@@ -114,6 +121,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -145,6 +153,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -176,6 +185,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -184,6 +194,8 @@ void main() {
final result = await settingsCubit!.deleteAccount();
expect(result, true);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted);
verify(mockProfileRepository.deleteAccount()).called(1);
});
@@ -193,6 +205,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -209,6 +222,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -244,6 +258,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/se
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import '../../../../fixtures/settings_fixtures.dart';
import '../bloc/settings_cubit_test.mocks.dart';
@@ -27,6 +28,7 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: FakeSessionLifecycleService(),
);
});

View File

@@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import '../../../../fixtures/doubles/session_doubles.dart';
import 'settings_screen_smoke_test.mocks.dart';
@GenerateNiceMocks([
@@ -57,6 +58,7 @@ void main() {
settingsRepository: sl(),
consultantRepository: sl(),
consultationsRepository: sl(),
sessionLifecycleService: FakeSessionLifecycleService(),
),
);
});

View File

@@ -69,7 +69,7 @@ class FixtureChatRepository extends ChatRepository {
: super(chatService: _NoopChatService());
@override
Future<String> getChatByUserSlug(String slug) async {
Future<String?> getChatByUserSlug(String slug) async {
return 'chat-uuid-123';
}
}

View File

@@ -0,0 +1,24 @@
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
class FakeSessionLifecycleService implements SessionLifecycleService {
int onAuthenticatedCalls = 0;
int endSessionCalls = 0;
int logoutCalls = 0;
SessionEndReason? lastEndReason;
@override
Future<void> onAuthenticated() async {
onAuthenticatedCalls++;
}
@override
Future<void> endSession({required SessionEndReason reason}) async {
endSessionCalls++;
lastEndReason = reason;
}
@override
Future<void> logout() async {
logoutCalls++;
}
}

View File

@@ -1,242 +0,0 @@
// FILE: tool/export_api_contract.dart
// VERSION: 1.0.0
// PURPOSE: Экспорт контракта мобильных моделей для бэкенд-репозитория (eatvio-web).
//
// Обходит lib/**/*.dart, находит @freezed фабрики (const factory XModel({...}))
// и извлекает JSON-контракт каждой модели: какие ключи обязательны (required),
// а какие допускают null/отсутствие.
//
// Источник истины — исходные декларации, НЕ сгенерированные *.g.dart:
// nullability = "?" в типе Dart; отсутствие ключа безопасно при @Default(...);
// JSON-имя = @JsonKey(name: "...") либо имя поля (дефолт json_serializable).
//
// USAGE:
// dart run tool/export_api_contract.dart # манифест в stdout
// dart run tool/export_api_contract.dart out.json # манифест в файл
//
// CI (flutter-репо): регенерить и сравнивать с закоммиченной копией;
// diff → fail с требованием обновить манифест и синхронизировать его
// в eatvio-web: tests/Fixtures/Contract/flutter_model_contract.json
import 'dart:convert';
import 'dart:io';
void main(List<String> args) {
final root = Directory('lib');
if (!root.existsSync()) {
stderr.writeln('Run from the flutter app root (directory "lib" not found).');
exit(2);
}
final sources = <File, String>{};
final enumNames = <String>{};
final files = root.listSync(recursive: true).whereType<File>().toList();
// ДЕТЕРМИНИЗМ: сортируем по пути — порядок Directory.listSync зависит от файловой системы,
// а при коллизии имён моделей побеждает последняя обработанная. Без сортировки локально
// и на CI получаются разные манифесты (баг, из-за которого CI падал на чистом PR).
files.sort((a, b) => a.path.compareTo(b.path));
for (final file in files) {
if (!file.path.endsWith('.dart') || file.path.endsWith('.g.dart') || file.path.endsWith('.freezed.dart')) {
continue;
}
final source = file.readAsStringSync();
sources[file] = source;
enumNames.addAll(
RegExp(r'^\s*enum\s+(\w+)', multiLine: true)
.allMatches(source)
.map((m) => m.group(1)!),
);
}
final contract = <String, Map<String, dynamic>>{};
// modelOrigin: имя модели -> файл, из которого она взята (для предупреждений о коллизиях).
final modelOrigin = <String, String>{};
for (final entry in sources.entries) {
final extracted = extractModels(entry.value);
for (final modelName in extracted.keys) {
if (contract.containsKey(modelName)) {
stderr.writeln(
'WARNING: model "$modelName" defined in both "${modelOrigin[modelName]}" '
'and "${entry.key.path}" — keeping the one from "${entry.key.path}" (sorted-order last wins). '
'Consider renaming the Dart classes to make the contract unambiguous.',
);
}
modelOrigin[modelName] = entry.key.path;
contract[modelName] = extracted[modelName]!;
}
}
// kinds считаются второй проходкой: нужны все имена моделей (contract.keys) и enum'ов.
final modelNames = contract.keys.toSet();
for (final entry in contract.entries) {
final types = (entry.value['types'] ?? <String, String>{}) as Map<String, String>;
entry.value['kinds'] = {
for (final e in types.entries) e.key: jsonKindFor(e.value, modelNames, enumNames),
};
}
final sorted = <String, Map<String, dynamic>>{
for (final key in contract.keys.toList()..sort()) key: contract[key]!,
};
const encoder = JsonEncoder.withIndent(' ');
final output = encoder.convert(sorted);
if (args.isNotEmpty) {
File(args.first).writeAsStringSync('$output\n');
stdout.writeln('Contract written to ${args.first} (${sorted.length} models)');
} else {
stdout.writeln(output);
}
}
/// JSON-вид значения для Dart-типа — по нему бэкенд-чекер валидирует payload:
/// number (int/double), string, boolean, list, map, model (JSON-объект),
/// enum (строка-имя или int), any (не проверяем).
String jsonKindFor(String dartType, Set<String> modelNames, Set<String> enumNames) {
final type = dartType.trim();
if (type == 'dynamic' || type == 'Object') {
return 'any';
}
if (type.startsWith('List<')) {
return 'list';
}
if (type.startsWith('Map<')) {
return 'map';
}
if (const {'int', 'double', 'num'}.contains(type)) {
return 'number';
}
if (type == 'String') {
return 'string';
}
if (type == 'bool') {
return 'boolean';
}
if (enumNames.contains(type)) {
return 'enum';
}
if (modelNames.contains(type)) {
return 'model';
}
return 'unknown';
}
/// Извлекает все @freezed-модели из исходника.
/// Возвращает {ModelName: {required: [...], optional: [...]}}.
Map<String, Map<String, dynamic>> extractModels(String source) {
final result = <String, Map<String, dynamic>>{};
// Блоки "const factory ModelName({ ... }) = _ModelName[...];" (одно- и многострочные).
final factoryRe = RegExp(r'const\s+factory\s+(\w+)\(\s*\{(.*?)\}\s*\)\s*=', dotAll: true);
for (final match in factoryRe.allMatches(source)) {
final modelName = match.group(1)!;
final body = match.group(2)!;
final required = <String>[];
final optional = <String>[];
final types = <String, String>{};
for (final rawLine in _splitTopLevel(body)) {
final line = rawLine.trim();
if (line.isEmpty || line.startsWith('//')) {
continue;
}
final field = _parseField(line);
if (field == null) {
continue;
}
(field.required ? required : optional).add(field.jsonName);
types[field.jsonName] = field.dartType;
}
if (required.isNotEmpty || optional.isNotEmpty) {
result[modelName] = {
'required': required,
'optional': optional,
'types': types,
};
}
}
return result;
}
/// Разбивает тело фабрики по запятым верхнего уровня,
/// не разрывая дженерики (`List<int, String>`) и вложенные скобки.
List<String> _splitTopLevel(String body) {
final parts = <String>[];
final current = StringBuffer();
var angleDepth = 0;
var parenDepth = 0;
var braceDepth = 0;
for (final char in body.runes) {
final ch = String.fromCharCode(char);
if (ch == '<') {
angleDepth++;
} else if (ch == '>') {
angleDepth = (angleDepth - 1).clamp(0, 1 << 30);
} else if (ch == '(') {
parenDepth++;
} else if (ch == ')') {
parenDepth = (parenDepth - 1).clamp(0, 1 << 30);
} else if (ch == '{') {
braceDepth++;
} else if (ch == '}') {
braceDepth = (braceDepth - 1).clamp(0, 1 << 30);
} else if (ch == ',' && angleDepth == 0 && parenDepth == 0 && braceDepth == 0) {
parts.add(current.toString());
current.clear();
continue;
}
current.write(ch);
}
parts.add(current.toString());
return parts;
}
class _Field {
_Field(this.jsonName, this.required, this.dartType);
final String jsonName;
final bool required;
final String dartType;
}
_Field? _parseField(String line) {
// Убираем комментарии в конце строки.
final code = line.split('//').first.trim();
if (code.isEmpty) {
return null;
}
// JSON-имя: @JsonKey(name: "type_no") или имя поля (одинарные и двойные кавычки).
final jsonKeyMatch =
RegExp(r'''@JsonKey\([^)]*?\bname:\s*["']([^"']+)["']''').firstMatch(code);
// Убираем аннотации, оставляем "модификаторы тип поле".
final declaration = code.replaceAll(RegExp(r'@\w+\([^)]*\)'), '').trim();
final fieldMatch = RegExp(r'^(?:(?:required|final|const)\s+)*([\w<>?,\s]+?)\s+(\w+)$').firstMatch(declaration);
if (fieldMatch == null) {
return null;
}
final type = fieldMatch.group(1)!.trim();
final dartName = fieldMatch.group(2)!;
// Метаданные исходной строки.
final hasDefault = RegExp(r'@Default\(').hasMatch(line);
final isNullable = type.endsWith('?') || type == 'dynamic';
return _Field(
jsonKeyMatch?.group(1) ?? dartName,
!isNullable && !hasDefault,
type.endsWith('?') ? type.substring(0, type.length - 1) : type,
);
}