9 Commits

7 changed files with 4479 additions and 0 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

@@ -160,6 +160,15 @@ jobs:
flutter pub get
dart run build_runner build --delete-conflicting-outputs
# Контракт манифеста API: манифест обязан быть регенерирован из исходников freezed-моделей.
# Diff после регенерации = PR менял модели, но не обновил api_contract/flutter_model_contract.json.
# Обновлённый манифест нужно также синхронизировать в eatvio-web: tests/Fixtures/Contract/.
- name: API contract manifest check
run: |
dart run tool/export_api_contract.dart api_contract/flutter_model_contract.json
git diff --exit-code api_contract/flutter_model_contract.json \
|| (echo "::error::api_contract/flutter_model_contract.json устарел. Регенерируй манифест (make export-api-contract) и синхронизируй его в eatvio-web: tests/Fixtures/Contract/flutter_model_contract.json" && exit 1)
- name: Native Analyze
run: flutter analyze

76
AGENTS.md 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

@@ -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,
);
}