forked from eatvio/eatvio_app
Compare commits
6 Commits
fix/1087-e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 194b8d5b16 | |||
|
|
508c321913 | ||
|
|
4a5c67632e | ||
| cf5c42ff4b | |||
|
|
5e6e114d5d | ||
|
|
e9cfcb9637 |
@@ -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}"
|
||||
@@ -160,15 +160,6 @@ jobs:
|
||||
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)
|
||||
|
||||
- name: Native Analyze
|
||||
run: flutter analyze
|
||||
|
||||
|
||||
76
AGENTS.md
76
AGENTS.md
@@ -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.
|
||||
11
Makefile
11
Makefile
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,16 +71,26 @@ 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(),
|
||||
@@ -91,48 +102,130 @@ class CubitRegistrations {
|
||||
/// Регистрация 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(),
|
||||
),
|
||||
);
|
||||
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(), firebaseApi: sl(), prefs: sl()),
|
||||
);
|
||||
sl.registerFactory<RegistrationCubitV2>(
|
||||
() =>
|
||||
RegistrationCubitV2(registrationRepository: sl(), firebaseApi: 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 +237,73 @@ class CubitRegistrations {
|
||||
),
|
||||
);
|
||||
sl.registerFactory<SettingsCubit>(
|
||||
() => SettingsCubit(profileRepository: sl(), settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl()),
|
||||
() => SettingsCubit(
|
||||
profileRepository: sl(),
|
||||
settingsRepository: sl(),
|
||||
consultantRepository: sl(),
|
||||
consultationsRepository: 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()),
|
||||
);
|
||||
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<UserDetailsCubit>(
|
||||
() => UserDetailsCubit(profileRepository: sl()),
|
||||
);
|
||||
sl.registerFactory<MenuCubit>(() => MenuCubit(sl()));
|
||||
sl.registerFactory<MarketingCubit>(() => MarketingCubit(sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(() => PersonalDietSuggestionCubit(dietRepository: sl(), personalDietSuggestionService: sl()));
|
||||
sl.registerFactory<PersonalDietSuggestionCubit>(
|
||||
() => PersonalDietSuggestionCubit(
|
||||
dietRepository: sl(),
|
||||
personalDietSuggestionService: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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/token_storage.dart';
|
||||
@@ -11,12 +12,22 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
final AuthRepository authRepository;
|
||||
final FirebaseApi firebaseApi;
|
||||
|
||||
OAuthCubit({required this.authRepository, required Prefs prefs}) : super(const OAuthState()) {
|
||||
OAuthCubit({
|
||||
required this.authRepository,
|
||||
required this.firebaseApi,
|
||||
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 +39,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
);
|
||||
|
||||
log('OAuthCubit: Got token model: ${tokenModel.token}');
|
||||
TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
await TokenStorage.saveTokenInStorage(tokenModel.token);
|
||||
emit(OAuthState.loaded(token: tokenModel.token));
|
||||
} on DioException catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -45,7 +56,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 +65,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 +121,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
height: height,
|
||||
clientId: clientId,
|
||||
);
|
||||
TokenStorage.saveTokenInStorage(token);
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
await firebaseApi.registerPushToken();
|
||||
cacheRegistrationDate(DateTime.now());
|
||||
emit(OAuthState.loaded(token: token));
|
||||
} on DioException catch (e, st) {
|
||||
@@ -120,7 +139,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 +148,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 +169,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 +217,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 +237,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 firebaseApi.registerPushToken();
|
||||
emit(OAuthState.loaded(token: response.token));
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -212,4 +250,3 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
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/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/validation_utils.dart';
|
||||
@@ -13,7 +14,11 @@ import 'package:gpassword/gpassword.dart';
|
||||
|
||||
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
|
||||
final RegistrationRepositoryV2 registrationRepository;
|
||||
RegistrationCubitV2({required this.registrationRepository}) : super(const RegistrationStateV2());
|
||||
final FirebaseApi firebaseApi;
|
||||
RegistrationCubitV2({
|
||||
required this.registrationRepository,
|
||||
required this.firebaseApi,
|
||||
}) : super(const RegistrationStateV2());
|
||||
|
||||
void setFirstName(String firstName) {
|
||||
emit(state.copyWith(firstName: firstName));
|
||||
@@ -194,9 +199,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 firebaseApi.registerPushToken();
|
||||
emit(state.copyWith(registrationError: null));
|
||||
return (true, false);
|
||||
}
|
||||
@@ -214,11 +222,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 +245,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 +259,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
@@ -16,6 +17,17 @@ import 'package:shared_preferences/shared_preferences.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 +36,36 @@ void main() {
|
||||
OAuthCubit? oauthCubit;
|
||||
late MockAuthRepository mockAuthRepository;
|
||||
late MockPrefs mockPrefs;
|
||||
late _RecordingFirebaseApi firebaseApi;
|
||||
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();
|
||||
|
||||
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 +74,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,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
expect(oauthCubit!.state, const OAuthState());
|
||||
});
|
||||
|
||||
test('signInViaSocialMedia - успешный вход через соцсеть', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
when(
|
||||
mockAuthRepository.signInViaSocialMedia(
|
||||
@@ -92,7 +127,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('signInViaSocialMedia - переход в состояние loading', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
when(
|
||||
mockAuthRepository.signInViaSocialMedia(
|
||||
@@ -119,7 +158,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('signInViaSocialMedia - обработка ошибки', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
when(
|
||||
mockAuthRepository.signInViaSocialMedia(
|
||||
@@ -144,7 +187,11 @@ void main() {
|
||||
});
|
||||
|
||||
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
when(
|
||||
mockAuthRepository.signUpViaSocialMedia(
|
||||
@@ -164,7 +211,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 +236,62 @@ 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(firebaseApi.registerPushTokenCalls, 1);
|
||||
expect(firebaseApi.hadAuthToken, isTrue);
|
||||
});
|
||||
|
||||
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
test(
|
||||
'signInViaSocialMedia - обработка ошибки валидации email/phone',
|
||||
() async {
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
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,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
final dioException = DioException(
|
||||
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
|
||||
@@ -282,82 +345,130 @@ 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,
|
||||
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,
|
||||
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,
|
||||
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(firebaseApi.registerPushTokenCalls, 1);
|
||||
expect(firebaseApi.hadAuthToken, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('confirmOAuthVerification - обработка ошибки', () async {
|
||||
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
|
||||
oauthCubit = OAuthCubit(
|
||||
authRepository: mockAuthRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
prefs: mockPrefs,
|
||||
);
|
||||
|
||||
when(
|
||||
mockAuthRepository.confirmOAuthVerification(
|
||||
|
||||
@@ -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';
|
||||
@@ -9,43 +11,60 @@ import 'package:shared_preferences/shared_preferences.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;
|
||||
const MethodChannel passwordGeneratorChannel = MethodChannel(
|
||||
'flutter_password_generator',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockRegistrationRepositoryV2();
|
||||
cubit = RegistrationCubitV2(registrationRepository: mockRepository);
|
||||
|
||||
firebaseApi = _RecordingFirebaseApi();
|
||||
cubit = RegistrationCubitV2(
|
||||
registrationRepository: mockRepository,
|
||||
firebaseApi: firebaseApi,
|
||||
);
|
||||
|
||||
// Инициализируем 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 +201,24 @@ 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(firebaseApi.registerPushTokenCalls, 1);
|
||||
expect(firebaseApi.hadAuthToken, isTrue);
|
||||
verify(
|
||||
mockRepository.register(
|
||||
registrationModel: anyNamed('registrationModel'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('register - возвращает false при невалидных полях', () async {
|
||||
@@ -199,7 +228,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 +259,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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user