9 Commits

69 changed files with 5042 additions and 2079 deletions

View File

@@ -0,0 +1,64 @@
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: [debian-12]
runs-on: [ubuntu-latest]
steps:
- uses: https://github.com/actions/checkout@v3
@@ -155,10 +155,19 @@ jobs:
- name: Get dependencies
run: |
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter clean
flutter pub cache repair
flutter pub get
dart run build_runner build --delete-conflicting-outputs
# Контракт манифеста 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 Normal file
View File

@@ -0,0 +1,76 @@
# 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,3 +11,14 @@ 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)

89
api_contract/README.md Normal file
View File

@@ -0,0 +1,89 @@
# Регламент синхронизации 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,21 +437,73 @@ import flutter_foreground_task
return payload
}
/// 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 {
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]
)
return
}
let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue
if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl {
UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey)
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,
]
)
return
}
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload)
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,
]
)
}
}
private func sanitizedDiagnosticValue(_ value: Any?) -> Any {
@@ -1125,8 +1177,6 @@ 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: [
@@ -1135,7 +1185,11 @@ import flutter_foreground_task
"data": call.data.toJSON(),
]
)
deliverStoredAcceptedCall()
voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON())
DispatchQueue.main.async { [weak self] in
self?.bringAppToForeground()
self?.openAppForAcceptedCall(call)
}
action.fulfill()
}

View File

@@ -19,10 +19,6 @@ 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,37 +8,25 @@ 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,
action: action
fields: fields
) else {
NSLog(
"[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
"[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)"
)
return
}
let task = EatvioBackgroundTask()
task.begin(name: "video-call-\(action)")
task.begin(name: "video-call-decline")
let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
NSLog(
"[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
"[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")"
)
task.end()
}
@@ -49,8 +37,7 @@ enum VideoCallDeclineClient {
baseUrl: String?,
token: String?,
hwId: String?,
fields: VideoCallDeclineFields,
action: String = "decline"
fields: VideoCallDeclineFields
) -> URLRequest? {
if fields.endedPush { return nil }
let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -63,9 +50,8 @@ 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/\(action)")
let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline")
else { return nil }
var body: [String: String] = ["hw_id": deviceId]

View File

@@ -43,60 +43,37 @@ 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 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").
# Read build number from Flutter Generated.xcconfig
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|
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
matches = line.match(/FLUTTER_BUILD_NUMBER=(.*)/)
if matches
flutter_build_number = matches[1].strip
break
end
end
end
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}")
if flutter_build_number.nil?
UI.user_error!("Could not find FLUTTER_BUILD_NUMBER in #{generated_xcconfig_path}")
end
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}"
UI.message "Flutter build number: #{flutter_build_number}"
match(type: "appstore", readonly: true)
# Set build number explicitly instead of incrementing
increment_build_number(
xcodeproj: "Runner.xcodeproj",
build_number: new_build_number
build_number: flutter_build_number
)
update_code_signing_settings(
@@ -116,13 +93,22 @@ 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 #{new_build_number}",
changelog: "TestFlight build #{flutter_build_number}",
verbose: true
)
rescue => ex

View File

@@ -42,8 +42,7 @@ 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';
@@ -71,174 +70,69 @@ 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(),
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<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(),
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(),
),
);
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()));
// Feed related cubits
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<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()));
// 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(),
@@ -250,74 +144,28 @@ class CubitRegistrations {
),
);
sl.registerFactory<SettingsCubit>(
() => 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()),
() => 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<ConsultationPaymentCubit>(
() => 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(),
),
() => 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<ViewsCubit>(() => ViewsCubit(viewsRepository: sl()));
sl.registerFactory<UserDetailsCubit>(
() => UserDetailsCubit(profileRepository: sl()),
);
sl.registerFactory<MenuCubit>(() => MenuCubit(sl(), 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()));
}
}

View File

@@ -1,15 +0,0 @@
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,7 +1,9 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:go_router/go_router.dart';
class TokenInterceptor extends Interceptor {
@override
@@ -12,7 +14,6 @@ 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);
@@ -24,18 +25,9 @@ class TokenInterceptor extends Interceptor {
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
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,
);
}
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
}
super.onError(err, handler);
}

View File

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

View File

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

View File

@@ -1,122 +0,0 @@
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,7 +3,6 @@ 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';
@@ -18,26 +17,15 @@ 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,
required this.sessionLifecycleService,
}) : super(const AuthState());
AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState());
Future<bool> getTokenByCredentials(String email, String password) async {
emit(const AuthState.loading());
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);
@@ -60,8 +48,7 @@ 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,9 +1,7 @@
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';
@@ -13,24 +11,12 @@ 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 this.firebaseApi,
required this.sessionLifecycleService,
required Prefs prefs,
}) : super(const OAuthState()) {
OAuthCubit({required this.authRepository, 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());
@@ -42,8 +28,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
);
log('OAuthCubit: Got token model: ${tokenModel.token}');
await TokenStorage.saveTokenInStorage(tokenModel.token);
await sessionLifecycleService.onAuthenticated();
TokenStorage.saveTokenInStorage(tokenModel.token);
emit(OAuthState.loaded(token: tokenModel.token));
} on DioException catch (e, st) {
await reportFrom(e, st, this);
@@ -60,8 +45,7 @@ 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);
}
}
@@ -69,20 +53,14 @@ 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()));
}
@@ -125,8 +103,7 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
height: height,
clientId: clientId,
);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
TokenStorage.saveTokenInStorage(token);
cacheRegistrationDate(DateTime.now());
emit(OAuthState.loaded(token: token));
} on DioException catch (e, st) {
@@ -143,8 +120,7 @@ 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);
}
}
@@ -152,17 +128,14 @@ 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) {
@@ -173,23 +146,13 @@ 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());
@@ -221,9 +184,7 @@ 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,
@@ -241,11 +202,8 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
weight: weight,
height: height,
);
log(
'OAuthCubit: OAuth verification confirmed successfully, token received',
);
await TokenStorage.saveTokenInStorage(response.token);
await sessionLifecycleService.onAuthenticated();
log('OAuthCubit: OAuth verification confirmed successfully, token received');
TokenStorage.saveTokenInStorage(response.token);
emit(OAuthState.loaded(token: response.token));
} catch (e, st) {
await reportFrom(e, st, this);
@@ -254,3 +212,4 @@ class OAuthCubit extends Cubit<OAuthState> with RegistrationDateCacheMixin {
}
}
}

View File

@@ -4,10 +4,12 @@ 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';
@@ -16,6 +18,8 @@ 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';
@@ -64,8 +68,23 @@ class _AuthScreenState extends State<AuthScreen> {
super.dispose();
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешный вход');
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;
}
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -183,7 +202,18 @@ class _AuthScreenState extends State<AuthScreen> {
}
Future<void> _reconnectAfterAuth() async {
showSuccessToast('Успешный вход');
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());
}
AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -365,7 +395,7 @@ class _AuthScreenState extends State<AuthScreen> {
final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text);
if (isAuthenticated) {
if (context.mounted) {
await _reconnectWebSocket();
await _reconnectWebSocket(context);
}
}
setState(() {

View File

@@ -9,7 +9,11 @@ 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';
@@ -58,8 +62,23 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
_initializeFieldsFromAuthResult();
}
Future<void> _reconnectWebSocket() async {
showSuccessToast('Успешная регистрация');
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;
}
}
AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false});
}
@@ -432,7 +451,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
Builder(
builder: (context) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_reconnectWebSocket();
_reconnectWebSocket(context);
});
return const SizedBox.shrink();
},
@@ -776,7 +795,7 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
// Перезагружаем веб-сокет и переходим на главный экран
if (widgetContext.mounted) {
_reconnectWebSocket();
_reconnectWebSocket(widgetContext);
}
} catch (e, stackTrace) {
log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace);

View File

@@ -3,10 +3,13 @@ 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';
@@ -36,10 +39,7 @@ 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,33 +60,30 @@ 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);
await sl<SessionLifecycleService>().onAuthenticated();
AppRouter.navigatorKey.currentContext?.go(
'/feed-screen',
extra: {'needToInitializeWebSocket': false},
);
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});
showSuccessToast('Добро пожаловать!');
} catch (e) {
await controller.resumeCamera();
showErrorToast(
'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.',
);
showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.');
}
});
}

View File

@@ -6,13 +6,6 @@ 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,22 +1,18 @@
import 'dart:async';
import 'package:appmetrica_plugin/appmetrica_plugin.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:eatvio_app/core/app_router.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());
int _requestId = 0;
int? _sessionFingerprint;
bool _ensuring = false;
ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading());
int _calculateAge(String birthdate) {
try {
@@ -29,8 +25,7 @@ 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--;
}
@@ -43,82 +38,30 @@ 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);
if (isClosed || requestId != _requestId) return;
AppRouter.navigatorKey.currentContext?.go('/auth-screen');
emit(ProfileAvatarState.error(errorMessage: e.toString()));
}
}

View File

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

View File

@@ -57,17 +57,6 @@ 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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (context.mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},
@@ -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 chatId = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (chatId != null && chatId.isNotEmpty && mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatId});
final chatIdOrError = await sl<ChatRepository>().getChatByUserSlug(widget.user.slug);
if (mounted) {
AppRouter.navigatorKey.currentContext?.pushReplacementNamed('chatScreen', extra: {'uuid': chatIdOrError});
}
}
},

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ 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,7 +1,6 @@
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';
@@ -9,8 +8,7 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class ConsultationsCubit extends Cubit<ConsultationsState> {
final ConsultationsRepository consultationsRepository;
final ChatRepository chatRepository;
ConsultationsCubit({required this.consultationsRepository, required this.chatRepository}) : super(ConsultationsState());
ConsultationsCubit({required this.consultationsRepository}) : super(ConsultationsState());
Future<void> loadConsultations() async {
try {
@@ -22,14 +20,4 @@ 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,17 +3,14 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/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 {
@@ -37,7 +34,6 @@ 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],
@@ -283,7 +279,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
const SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: _isOpeningChat ? null : _openChat,
onTap: () {},
child: Ink(
height: 52,
width: 52,
@@ -312,22 +308,6 @@ 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,19 +12,9 @@ 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];
@@ -49,18 +39,17 @@ 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;
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
if (!isClosed) {
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()));
@@ -73,16 +62,14 @@ 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 (state is FeedStateLoaded) {
if (!isClosed && state is FeedStateLoaded) {
emit(
FeedState.loaded(
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
@@ -91,15 +78,14 @@ class FeedCubit extends Cubit<FeedState> {
);
}
} catch (e, st) {
if (isClosed || requestId != _requestId) return;
await reportFrom(e, st, this);
if (state is FeedStateLoaded) {
if (!isClosed && state is FeedStateLoaded) {
emit((state as FeedStateLoaded).copyWith(isLoading: false));
} else {
} else if (!isClosed) {
emit(FeedState.error(errorMessage: e.toString()));
}
} finally {
if (requestId == _requestId) isLoading = false;
isLoading = false;
}
}

View File

@@ -1,6 +1,5 @@
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';
@@ -8,11 +7,7 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
class MenuCubit extends Cubit<MenuState> {
final ProfileRepository profileRepository;
final SessionLifecycleService sessionLifecycleService;
MenuCubit(this.profileRepository, this.sessionLifecycleService)
: super(const MenuState.initial());
Future<void> logout() => sessionLifecycleService.logout();
MenuCubit(this.profileRepository) : super(const MenuState.initial());
Future<void> getMyProfile() async {
emit(const MenuState.loading());
@@ -28,10 +23,7 @@ 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,15 +1,22 @@
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';
@@ -148,7 +155,6 @@ class MenuScreen extends StatelessWidget {
Future<Null> Function() _onTapLogout(BuildContext context) {
return () async {
final menuCubit = context.read<MenuCubit>();
showDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
@@ -156,8 +162,25 @@ class MenuScreen extends StatelessWidget {
actions: [
CupertinoDialogAction(
onPressed: () async {
Navigator.of(context).pop();
await menuCubit.logout();
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);
}
}
},
isDestructiveAction: true,
child: const Text("Выйти"),

View File

@@ -16,11 +16,6 @@ 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,10 +22,6 @@ 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 {
@@ -145,13 +141,13 @@ class ProfileCubit extends Cubit<ProfileState> {
Future<String?> getChatRoomIdBySlug({required String slug}) async {
try {
final chatId = await chatRepository.getChatByUserSlug(slug);
if (chatId == null || chatId.isEmpty) {
final userProfile = await profileRepository.getProfileBySlug(slug);
final chatIdOrError = await chatRepository.getChatByUserSlug(slug);
final userProfile = await profileRepository.getProfileBySlug(slug);
if (chatIdOrError == '404') {
AppRouter.navigatorKey.currentContext?.pushNamed('emptyChatScreen', extra: userProfile);
} else {
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatId});
return chatId;
AppRouter.navigatorKey.currentContext?.pushNamed('chatScreen', extra: {'uuid': chatIdOrError});
return chatIdOrError;
}
} catch (e, st) {
await reportFrom(e, st, this);

View File

@@ -1,9 +1,11 @@
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';
@@ -16,8 +18,7 @@ import 'package:go_router/go_router.dart';
class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCacheMixin {
final RegistrationRepository registrationRepository;
final SessionLifecycleService sessionLifecycleService;
RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs})
RegistrationCubit({required this.registrationRepository, required Prefs prefs})
: super(
const RegistrationState(
sheet: UserRegistrationSheet(
@@ -61,7 +62,7 @@ class RegistrationCubit extends Cubit<RegistrationState> with RegistrationDateCa
state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'),
);
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
await sl<FirebaseApi>().registerPushToken();
RegistrationAnalyticsService.reportRegistrationComplete();
cacheRegistrationDate(DateTime.now());
AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen');

View File

@@ -1,8 +1,6 @@
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';
@@ -15,13 +13,7 @@ import 'package:gpassword/gpassword.dart';
class RegistrationCubitV2 extends Cubit<RegistrationStateV2> {
final RegistrationRepositoryV2 registrationRepository;
final FirebaseApi firebaseApi;
final SessionLifecycleService sessionLifecycleService;
RegistrationCubitV2({
required this.registrationRepository,
required this.firebaseApi,
required this.sessionLifecycleService,
}) : super(const RegistrationStateV2());
RegistrationCubitV2({required this.registrationRepository}) : super(const RegistrationStateV2());
void setFirstName(String firstName) {
emit(state.copyWith(firstName: firstName));
@@ -202,12 +194,9 @@ 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) {
await TokenStorage.saveTokenInStorage(token);
await sessionLifecycleService.onAuthenticated();
TokenStorage.saveTokenInStorage(token);
emit(state.copyWith(registrationError: null));
return (true, false);
}
@@ -225,21 +214,11 @@ 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;
}
}
@@ -248,12 +227,7 @@ 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;
}
}
@@ -262,12 +236,7 @@ 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,5 +1,3 @@
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';
@@ -19,14 +17,12 @@ 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 {
@@ -34,12 +30,10 @@ 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;
}
@@ -47,14 +41,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;
@@ -62,7 +56,7 @@ class SettingsCubit extends Cubit<SettingsState> {
await reportFrom(e, st, this);
oauthAccounts = null;
}
try {
emit(
SettingsState.loaded(
@@ -80,13 +74,9 @@ 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 {
@@ -94,13 +84,9 @@ 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 {
@@ -111,15 +97,9 @@ 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;
}
}
@@ -155,14 +135,11 @@ 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,6 +698,7 @@ 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,7 +52,6 @@ class VideoCallCallKitCoordinator {
_PendingIncomingAccept? _pendingIncomingAccept;
_AndroidAcceptResumeObserver? _androidResumeObserver;
_AppResumeObserver? _iosResumeObserver;
bool _pendingAcceptDrainScheduled = false;
Future<void> initialize() async {
@@ -87,12 +86,6 @@ class VideoCallCallKitCoordinator {
}
});
await _firebaseApi.registerPushToken();
_iosResumeObserver = _AppResumeObserver(
onResumed: () {
unawaited(_drainPendingNativeAcceptedVoipCall());
},
);
WidgetsBinding.instance.addObserver(_iosResumeObserver!);
await _drainPendingNativeAcceptedVoipCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scheduleNativeVoipDiagnosticsDrain();
@@ -264,11 +257,6 @@ 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();
@@ -1240,19 +1228,6 @@ 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.69+158
version: 1.3.67+156
publish_to: 'none'
environment:

View File

@@ -1,118 +0,0 @@
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,24 +1,22 @@
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:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/core/locator_service.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 {}
@@ -34,18 +32,23 @@ void main() {
});
group('TokenInterceptor', () {
late TokenInterceptor interceptor;
late FakeSessionLifecycleService fakeSession;
setUp(() async {
SharedPreferences.setMockInitialValues({});
TokenStorage.clearCache();
await sl.reset();
fakeSession = FakeSessionLifecycleService();
sl.registerSingleton<SessionLifecycleService>(fakeSession);
final mockWsManager = MockWsManager();
when(() => mockWsManager.clear()).thenAnswer((_) async {});
await sl.reset(); // Сбрасываем sl перед каждым тестом
sl.registerSingleton<WsManager>(mockWsManager);
interceptor = TokenInterceptor();
await TokenStorage.removeTokenInStorage();
// Сбрасываем токен перед каждым тестом
try {
await TokenStorage.removeTokenInStorage();
} catch (e) {
// Игнорируем ошибки при очистке
}
});
group('onRequest', () {
@@ -56,7 +59,6 @@ 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);
@@ -71,7 +73,6 @@ 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);
@@ -88,7 +89,6 @@ 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,16 +101,11 @@ void main() {
});
group('onError', () {
test('завершает сессию при 401 для текущего токена', () async {
test('удаляет токен и перенаправляет на auth при 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);
@@ -121,10 +116,14 @@ void main() {
final handler = MockErrorInterceptorHandler();
when(() => handler.next(any())).thenReturn(null);
// Проверяем, что токен был удален
final tokenBefore = await TokenStorage.getTokenFromStorage();
expect(tokenBefore, token);
await interceptor.onError(error, handler);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.unauthorized);
final tokenAfter = await TokenStorage.getTokenFromStorage();
expect(tokenAfter, null);
verify(() => handler.next(error)).called(1);
});

View File

@@ -1,223 +0,0 @@
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,7 +12,6 @@ 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([
@@ -76,7 +75,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
expect(cubit!.state, isA<AuthState>());
@@ -88,7 +86,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
const email = 'test@example.com';
@@ -119,7 +116,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
const email = 'test@example.com';
@@ -144,7 +140,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockPrefs.getBool(any)).thenReturn(false);
@@ -164,7 +159,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockPrefs.getBool(any)).thenReturn(true);
@@ -180,7 +174,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
when(mockFirebaseApi.registerPushToken())
@@ -197,7 +190,6 @@ void main() {
profileRepository: mockProfileRepository,
firebaseApi: mockFirebaseApi,
prefs: mockPrefs,
sessionLifecycleService: FakeSessionLifecycleService(),
);
// TokenStorage.haveTokenInStorage() вернет false

View File

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

View File

@@ -1,5 +1,4 @@
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';
@@ -14,21 +13,9 @@ 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();
@@ -37,38 +24,26 @@ 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 {
@@ -77,31 +52,18 @@ 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,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
expect(oauthCubit!.state, const OAuthState());
});
test('signInViaSocialMedia - успешный вход через соцсеть', () async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -130,11 +92,7 @@ void main() {
});
test('signInViaSocialMedia - переход в состояние loading', () async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -161,11 +119,7 @@ void main() {
});
test('signInViaSocialMedia - обработка ошибки', () async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
when(
mockAuthRepository.signInViaSocialMedia(
@@ -190,11 +144,7 @@ void main() {
});
test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
when(
mockAuthRepository.signUpViaSocialMedia(
@@ -214,9 +164,7 @@ 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,
@@ -239,61 +187,48 @@ 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,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
test('signInViaSocialMedia - обработка ошибки валидации email/phone', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, 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'),
),
);
},
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,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
final dioException = DioException(
requestOptions: RequestOptions(path: '/api/auth/oauth/signup'),
@@ -347,129 +282,82 @@ void main() {
expect(result[0], RegistrationValidationErrors.incorrectEmailFormat);
});
test(
'requestOAuthVerification - успешный запрос кода верификации',
() async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
test('requestOAuthVerification - успешный запрос кода верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, 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,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, 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,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
test('confirmOAuthVerification - успешное подтверждение верификации', () async {
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, 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);
expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token);
expect(fakeSession.onAuthenticatedCalls, 1);
},
);
await Future.delayed(const Duration(milliseconds: 100));
expect(oauthCubit!.state, isA<OAuthStateLoaded>());
final loadedState = oauthCubit!.state as OAuthStateLoaded;
expect(loadedState.token, tokenFixture.token);
});
test('confirmOAuthVerification - обработка ошибки', () async {
oauthCubit = OAuthCubit(
authRepository: mockAuthRepository,
firebaseApi: firebaseApi, sessionLifecycleService: fakeSession,
prefs: mockPrefs,
);
oauthCubit = OAuthCubit(authRepository: mockAuthRepository, prefs: mockPrefs);
when(
mockAuthRepository.confirmOAuthVerification(

View File

@@ -15,7 +15,6 @@ 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';
@@ -37,7 +36,7 @@ void main() {
mockFirebaseApi = MockFirebaseApi();
SharedPreferences.setMockInitialValues({});
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
});
tearDown(() {
@@ -56,7 +55,7 @@ void main() {
testWidgets('should create AuthCubit successfully', (WidgetTester tester) async {
// Arrange & Act
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService());
final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
// Assert
expect(cubit, isA<AuthCubit>());

View File

@@ -1,204 +0,0 @@
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,4 +1,3 @@
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';
@@ -6,7 +5,6 @@ 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';
@@ -18,8 +16,6 @@ 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 - возвращает null, если в ответе нет id', () async {
test('getChatByUserSlug - возвращает 404 при отсутствии чата', () async {
const slug = 'user-slug';
final response = HttpResponse(
{},
Response(requestOptions: RequestOptions(path: ''), statusCode: 200),
Response(requestOptions: RequestOptions(path: ''), statusCode: 404),
);
when(
@@ -539,7 +539,7 @@ void main() {
final result = await chatRepository.getChatByUserSlug(slug);
expect(result, isNull);
expect(result, '404');
verify(mockChatService.getChatByUserSlug(slug: slug)).called(1);
});

View File

@@ -16,29 +16,5 @@ 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,55 +156,6 @@ 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,5 +1,4 @@
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';
@@ -9,21 +8,14 @@ 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();
mockChatRepository = MockChatRepository();
cubit = ConsultationsCubit(
consultationsRepository: mockRepository,
chatRepository: mockChatRepository,
);
cubit = ConsultationsCubit(consultationsRepository: mockRepository);
});
tearDown(() {
@@ -55,33 +47,5 @@ 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,26 +1,18 @@
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 {
@@ -36,20 +28,6 @@ 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(
@@ -86,8 +64,7 @@ void main() {
personalDietId: null,
);
Widget wrap(Widget child, {ConsultationsCubit? cubit}) {
final consultationsCubit = cubit ?? MockConsultationsCubit();
Widget wrap(Widget child) {
return MaterialApp.router(
theme: ThemeHelper().themeData(),
localizationsDelegates: const [
@@ -100,20 +77,7 @@ void main() {
routerConfig: GoRouter(
initialLocation: '/',
routes: [
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: '/', builder: (context, state) => child),
GoRoute(
path: '/profile-screen',
name: 'profileScreen',
@@ -199,118 +163,5 @@ 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,8 +4,6 @@ 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>()])
@@ -27,12 +25,12 @@ void main() {
});
test('начальное состояние - MenuState', () {
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
menuCubit = MenuCubit(mockProfileRepository);
expect(menuCubit!.state, isA<MenuState>());
});
test('makePassKey - успешно создает passKey', () async {
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
menuCubit = MenuCubit(mockProfileRepository);
const expectedPassKey = 'test-pass-key';
when(mockProfileRepository.getPassKey(
@@ -50,7 +48,7 @@ void main() {
});
test('makePassKey - возвращает null при ошибке', () async {
menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService());
menuCubit = MenuCubit(mockProfileRepository);
when(mockProfileRepository.getPassKey(
routeName: 'product_nutritional_value.index',

View File

@@ -15,7 +15,6 @@ 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>()])
@@ -139,7 +138,7 @@ void main() {
testWidgets('renders menu screen for user', (tester) async {
final repository = MockProfileRepository();
final cubit = MenuCubit(repository, FakeSessionLifecycleService());
final cubit = MenuCubit(repository);
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile);
@@ -156,7 +155,7 @@ void main() {
testWidgets('renders menu screen for consultant', (tester) async {
final repository = MockProfileRepository();
final cubit = MenuCubit(repository, FakeSessionLifecycleService());
final cubit = MenuCubit(repository);
when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey');
when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile);

View File

@@ -9,7 +9,6 @@ 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])
@@ -20,7 +19,7 @@ void main() {
setUp(() {
mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
});
tearDown(() {
@@ -39,7 +38,7 @@ void main() {
testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async {
// Arrange & Act
final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
// Assert
expect(cubit, isA<RegistrationCubit>());

View File

@@ -7,7 +7,6 @@ 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])
@@ -18,7 +17,7 @@ void main() {
setUp(() {
mockRegistrationRepository = MockRegistrationRepository();
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService());
registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs());
});
tearDown(() {

View File

@@ -7,8 +7,6 @@ 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 {}
@@ -28,7 +26,7 @@ void main() {
setUp(() {
mockRepository = MockRegistrationRepository();
mockPrefs = MockPrefs();
cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs, sessionLifecycleService: FakeSessionLifecycleService());
cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs);
});
tearDown(() {

View File

@@ -13,7 +13,6 @@ 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>()])
@@ -45,7 +44,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, sessionLifecycleService: FakeSessionLifecycleService());
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs);
sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs);
@@ -60,7 +59,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, sessionLifecycleService: FakeSessionLifecycleService());
final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs);
sl.registerLazySingleton<RegistrationRepository>(() => repository);
sl.registerLazySingleton<Prefs>(() => prefs);

View File

@@ -1,6 +1,4 @@
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,66 +7,45 @@ 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;
late _RecordingFirebaseApi firebaseApi;
late FakeSessionLifecycleService fakeSession;
const MethodChannel passwordGeneratorChannel = MethodChannel(
'flutter_password_generator',
);
const MethodChannel passwordGeneratorChannel = MethodChannel('flutter_password_generator');
setUp(() {
mockRepository = MockRegistrationRepositoryV2();
firebaseApi = _RecordingFirebaseApi();
fakeSession = FakeSessionLifecycleService();
cubit = RegistrationCubitV2(
registrationRepository: mockRepository,
firebaseApi: firebaseApi,
sessionLifecycleService: fakeSession,
);
cubit = RegistrationCubitV2(registrationRepository: mockRepository);
// Инициализируем 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('начальное состояние', () {
@@ -205,23 +182,14 @@ 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);
expect(await TokenStorage.getTokenFromStorage(), token);
expect(fakeSession.onAuthenticatedCalls, 1);
verify(
mockRepository.register(
registrationModel: anyNamed('registrationModel'),
),
).called(1);
verify(mockRepository.register(registrationModel: anyNamed('registrationModel'))).called(1);
});
test('register - возвращает false при невалидных полях', () async {
@@ -231,11 +199,7 @@ 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 {
@@ -262,21 +226,16 @@ 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,4 +1,3 @@
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';
@@ -8,7 +7,6 @@ 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';
@@ -25,7 +23,6 @@ void main() {
late MockSettingsRepository mockSettingsRepository;
late MockConsultantRepository mockConsultantRepository;
late MockConsultationsRepository mockConsultationsRepository;
late FakeSessionLifecycleService fakeSession;
setUp(() {
@@ -33,7 +30,6 @@ void main() {
mockSettingsRepository = MockSettingsRepository();
mockConsultantRepository = MockConsultantRepository();
mockConsultationsRepository = MockConsultationsRepository();
fakeSession = FakeSessionLifecycleService();
});
tearDown(() async {
@@ -50,7 +46,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
expect(settingsCubit!.state, const SettingsState.loading());
});
@@ -61,7 +56,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -92,7 +86,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
// Ошибка происходит при загрузке настроек профиля (внутри try-catch блока)
@@ -121,7 +114,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -153,7 +145,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -185,7 +176,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -194,8 +184,6 @@ void main() {
final result = await settingsCubit!.deleteAccount();
expect(result, true);
expect(fakeSession.endSessionCalls, 1);
expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted);
verify(mockProfileRepository.deleteAccount()).called(1);
});
@@ -205,7 +193,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.deleteAccount())
@@ -222,7 +209,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())
@@ -258,7 +244,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: fakeSession,
);
when(mockProfileRepository.getMyProfile())

View File

@@ -6,7 +6,6 @@ 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';
@@ -28,7 +27,6 @@ void main() {
settingsRepository: mockSettingsRepository,
consultantRepository: mockConsultantRepository,
consultationsRepository: mockConsultationsRepository,
sessionLifecycleService: FakeSessionLifecycleService(),
);
});

View File

@@ -18,7 +18,6 @@ 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([
@@ -58,7 +57,6 @@ 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

@@ -1,24 +0,0 @@
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

@@ -0,0 +1,242 @@
// 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,
);
}