Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами

в 15:16, , рубрики: flutter, flutter mobile development, разработка приложений

В конце ноября 2025-го я сел писать строительный калькулятор для RuStore. Хотел собрать всё, что нужно при ремонте, в одном приложении - от расчёта обоев до ИИ-ассистента, который подскажет, где ты накосячил с расходом штукатурки. Через 2,5 месяца «Мастерок» вышел в продакшн: 45+ калькуляторов, 269 коммитов, 259 тысяч строк кода, рейтинг 4.9 в RuStore.

В этой статье расскажу про архитектуру, покажу реальный код и объясню, почему переписал систему калькуляторов с нуля на полпути разработки, как впихнул ИИ с характером ворчливого прораба через OpenRouter и зачем написал 8180 тестов на проект, который делает один человек.

Зачем ещё один калькулятор

Строительных калькуляторов в сторах хватает. Но у большинства одна и та же болячка: один калькулятор - одно приложение. Хочешь посчитать обои - скачай приложение для обоев. Хочешь ламинат - другое приложение. Плитку - третье. А если всё вместе - получи приложение на 100 Мб с рекламой через каждый тап.

Я хотел сделать иначе: лёгкое приложение (APK 19 Мб), которое покрывает весь ремонт от фундамента до водосточной системы, умеет сохранять расчёты в проекты, делиться ими по QR-коду, а ещё есть втроенный PDF, при этом всё работает офлайн.

А ещё мне хотелось попробовать интеграцию ИИ не как чат-бота в вакууме, а как персонажа, который встроен в контекст: знает, какой калькулятор открыт, видит введённые цифры и может сказать «Стоп, 3 мешка Ротбанда на 20 квадратов - ты точно штукатурил раньше?»

Стек и масштаб

Коротко о проекте в цифрах, чтобы было понятно, о чём речь:

  • Flutter 3.38.2 / Dart 3.10.0

  • State management: Riverpod 3 (24 провайдера)

  • БД: Isar NoSQL (оффлайн-first, 4 модели)

  • Аналитика: Firebase Analytics + Crashlytics + MyTracker (RuStore)

  • ИИ: OpenRouter API → Gemini 3 Flash Preview

  • Код: 466 файлов Dart, 128 475 строк в lib/

  • Тесты: 8 180 тестов (5 398 unit + 2 785 widget), 130 992 строки тестового кода

  • Калькуляторы: 45+ штук в 10 категориях

  • Локализация: 5 164 ключа (русский)

  • APK: 19 Мб, от Android 7.0

Классическая структура проекта Clean Architecture с разделением на четыре слоя:

lib/ (466 files, 128 475 lines)
├── core/        (67 files)   — темы, локализация, сервисы, утилиты
├── domain/      (170 files)  — бизнес-логика, калькуляторы, сущности
├── data/        (20 files)   — репозитории, источники данных, Isar-модели
└── presentation/ (206 files) — экраны, провайдеры, виджеты
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 1

domain/ — самый толстый слой. Там живут 93 UseCase-а расчётов, 43 декларативных определения калькуляторов V2, 12 сущностей и все модели предметной области. Он ничего не знает ни про Flutter, ни про базу данных, ни про сеть.

Clean Architecture на практике: не по книжке, а как удобно

Когда говорят «Clean Architecture на Flutter», обычно подразумевают слепое следование шаблону Дяди Боба с абстрактными репозиториями, use case на каждый чих и интерфейсами ради интерфейсов. Я пошёл другим путём: взял принципы, но адаптировал под реальность, один разработчик, ограниченное время, 45+ калькуляторов, которые нужно было выпустить за 2,5 месяца.

Главный принцип, который я соблюдал строго: domain не импортирует ничего из presentation и data. Всё остальное по ситуации.

UseCase как единица бизнес-логики

Каждый калькулятор — это UseCase, который наследуется от BaseCalculator. Вот как выглядит типичный расчёт (подвал/цокольный этаж):

class CalculateBasementV2 extends BaseCalculator {
  static const double _wastePercent = 0.15;
  static const double _concretePerM3 = 2400; // кг/м³
  static const double _rebarPerM3 = 80;      // кг на м³ бетона

  @override
  CalculatorResult calculate(
    Map<String, dynamic> inputs,
    List<PriceItem> prices,
  ) {
    final length = getDouble(inputs, 'length');
    final width = getDouble(inputs, 'width');
    final height = getDouble(inputs, 'height');
    final wallThickness = getDouble(inputs, 'wallThickness', defaultValue: 0.3);

    if (length <= 0 || width <= 0 || height <= 0) {
      throw const CalculationException('Все размеры должны быть больше нуля');
    }

    // Площадь пола и стен
    final floorArea = length * width;
    final perimeter = 2 * (length + width);
    final wallArea = perimeter * height;

    // Объём бетона: пол + стены
    final floorConcreteVolume = floorArea * wallThickness;
    final wallConcreteVolume = wallArea * wallThickness;
    final totalConcreteVolume = floorConcreteVolume + wallConcreteVolume;
    final concreteWithWaste = totalConcreteVolume * (1 + _wastePercent);

    // Арматура
    final rebarWeight = totalConcreteVolume * _rebarPerM3;

    // Стоимость
    final totalPrice = calculatePrice(prices, {
      'concrete': concreteWithWaste,
      'rebar': rebarWeight,
    });

    return CalculatorResult(
      values: {
        'floorArea': roundTo(floorArea, 2),
        'wallArea': roundTo(wallArea, 2),
        'concreteVolume': roundTo(concreteWithWaste, 2),
        'rebarWeight': roundTo(rebarWeight, 1),
      },
      totalPrice: totalPrice,
    );
  }
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 2

BaseCalculator даёт общие утилиты: getDouble() с дефолтами, roundTo(), calculatePrice() по прайс-листу, стандартную валидацию. Каждый UseCase - чистая функция: получил входные данные и прайс → вернул результат. Никаких зависимостей на UI, базу, сеть.

Провайдеры как клей между слоями

Riverpod связывает domain с presentation. Вот как устроен провайдер для расчёта ленточного фундамента — он берёт UseCase из domain, прайс-лист из data и отдаёт результат в UI:

final foundationResultProvider =
    FutureProvider.family<FoundationResult, FoundationInput>((ref, input) async {
  try {
    final priceList = await ref.watch(priceListProvider.future);
    final usecase = CalculateStripFoundation();

    final calculatorResult = usecase.call(
      {
        'perimeter': input.perimeter,
        'width': input.width,
        'height': input.height,
      },
      priceList,
    );

    return FoundationResult(
      concreteVolume: calculatorResult.values['concreteVolume'] ?? 0,
      rebarWeight: calculatorResult.values['rebarWeight'] ?? 0,
      cost: calculatorResult.totalPrice ?? 0,
    );
  } catch (e, stackTrace) {
    ErrorHandler.logError(e, stackTrace, 'foundationResultProvider');
    return FoundationResult(concreteVolume: 0, rebarWeight: 0, cost: 0);
  }
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 3

Обратите внимание на catch - вместо того чтобы пробрасывать ошибку в UI и показывать красный экран, провайдер возвращает пустой результат. Graceful degradation: UI покажет нули, а ошибка уйдёт в логи и Crashlytics. Пользователь видит, что что-то не так, но приложение не падает.

Этот же паттерн повторяется во всех провайдерах. Вот calculationsProvider - загрузка сохранённых расчётов из Isar:

final calculationsProvider =
    FutureProvider.autoDispose<List<Calculation>>((ref) async {
  try {
    final repo = ref.watch(calculationRepositoryProvider);
    final calculations = await repo.getAllCalculations();
    calculations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
    return calculations;
  } catch (e, stackTrace) {
    ErrorHandler.logError(e, stackTrace, 'calculationsProvider');
    return [];
  }
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 4

autoDispose — важная деталь. Когда пользователь уходит с экрана списка расчётов, провайдер умирает и освобождает память. При возвращении — данные загрузятся заново из базы. Для тяжёлых списков есть отдельный PaginatedCalculationsNotifier с постраничной загрузкой по 20 элементов.

Система калькуляторов V2: когда декларативность побеждает копипасту

Это та часть, ради которой я переписал треть проекта на полпути. Первая версия калькуляторов (V1) работала, но масштабировалась отвратительно.

Проблема V1

В V1 каждый калькулятор был отдельным экраном с захардкоженными полями ввода, ручной валидацией и копипастой UI-кода. Вот фрагмент V1-калькулятора мансарды:

// V1: жёстко прошитые поля, ручная генерация UI
Widget _buildInputFields() {
  return Column(
    children: [
      TextFormField(
        decoration: InputDecoration(labelText: 'Длина, м'),
        keyboardType: TextInputType.number,
        onChanged: (v) => setState(() => _length = double.tryParse(v) ?? 0),
      ),
      TextFormField(
        decoration: InputDecoration(labelText: 'Ширина, м'),
        keyboardType: TextInputType.number,
        onChanged: (v) => setState(() => _width = double.tryParse(v) ?? 0),
      ),
      // ... и так 6-8 полей для каждого калькулятора
    ],
  );
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 5

Когда калькуляторов стало 20+, поддерживать это стало невозможно. Каждое изменение UI - правь 20 файлов. Добавить новое поле - копируй код. Хочешь зависимость между полями (показать поле «толщина утеплителя» только если включён чекбокс «утепление») - пиши кастомную логику для каждого экрана.

Решение: декларативные определения

V2 перевернул подход. Калькулятор описывается декларативно, например через CalculatorDefinitionV2, а UI генерируется автоматически из описания полей. Один универсальный экран ProCalculatorScreen умеет отрисовать любой калькулятор по его определению.

Поля ввода задаются через enum FieldInputType:

enum FieldInputType {
  number,     // TextFormField
  select,     // DropdownButton
  checkbox,   // Checkbox
  switch_,    // Switch
  radio,      // Radio
  slider,     // Slider
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 6

А единицы измерения — через UnitType, который хранит и символ, и ключ локализации:

enum UnitType {
  squareMeters,  // м²
  cubicMeters,   // м³
  linearMeters,  // пог. м
  pieces,        // шт.
  kilograms,     // кг
  bags,          // меш.
  rolls,         // рул.
  meters,        // м
  millimeters,   // мм
  rubles,        // ₽
  // ...
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 7

Каждое определение калькулятора содержит метаданные: иконку, цвет, сложность (от 1 до 5), популярность, теги для поиска, хинты до и после расчёта, и самое главное — декларативные зависимости между полями через dependsOn и showWhen. Поле «толщина утеплителя» появляется только когда включён переключатель «утепление» — и для этого не нужно писать ни строчки UI-кода.

CalculatorRegistry: центральный реестр

Все определения регистрируются в едином реестре. Калькуляторы группируются по категориям в отдельных файлах (foundation_calculators.dart, ceiling_calculators.dart и т.д.), каждый из которых возвращает список определений. При старте приложения реестр собирает их все и строит индексы:

class CalculatorRegistry {
  static final List<CalculatorDefinitionV2> _calculators = [];
  static final Map<String, CalculatorDefinitionV2> _idCache = {};
  static final Map<String, List<CalculatorDefinitionV2>> _categoryCache = {};
  static CalculatorSearchIndex? _searchIndex;

  static void _ensureInitialized() {
    if (_calculators.isNotEmpty) return;
    _calculators.addAll([
      ...FoundationCalculators.all,
      ...CeilingCalculators.all,
      ...EngineeringCalculators.all,
      ...FlooringCalculators.all,
      // ...
    ]);
    for (final calc in _calculators) {
      _idCache[calc.id] = calc;
    }
  }

  static CalculatorDefinitionV2? getById(String id) {
    _ensureInitialized();
    final direct = _idCache[id];
    if (direct != null) return direct;
    final canonical = CalculatorIdMigration.canonicalize(id);
    return _idCache[canonical];
  }
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 8

idCache - поиск по ID за O(1). searchIndex - поисковый индекс по тегам, названиям и ключевым словам. Отдельная миграция CalculatorIdMigration обеспечивает обратную совместимость: когда floors_screed стал floors_screed_unified, старые сохранённые проекты и избранное не потерялись.

Маршрутизация: Map вместо 49 if-блоков

Открытие калькулятора в V1 по сути гигантская портянка if-else. В V2 это CalculatorScreenRegistry:

class CalculatorScreenRegistry {
  static final Map<String, CalculatorScreenBuilder> _builders = {
    'mixes_plaster': (def, inputs) => PlasterCalculatorScreen(
          definition: def, initialInputs: inputs),
    'floors_laminate': (_, _) => const LaminateCalculatorScreen(),
    // ... 49 записей
  };

  static Widget buildWithFallback(
    CalculatorDefinitionV2 definition,
    Map<String, double>? initialInputs,
  ) {
    return build(definition.id, definition, initialInputs) ??
        ProCalculatorScreen(
          definition: definition, initialInputs: initialInputs,
        );
  }
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 9

Если для калькулятора есть кастомный экран то используется он. Если нет - fallback на универсальный ProCalculatorScreen. Новые калькуляторы сразу создаются декларативно, старые можно мигрировать по одному.

ИИ-прораб Михалыч: характер в коде

Михалыч — ИИ-ассистент, встроенный в приложение. Не просто «чат с GPT», а персонаж: ворчливый прораб с 30-летним стажем, который разговаривает строительным сленгом, ловит ошибки в расчётах и подкалывает, когда видит подозрительные цифры.

Почему OpenRouter, а не напрямую

Разрабатывая из России, я быстро упёрся в санкции: Google API напрямую недоступен. Попробовал поднять прокси через Cloudflare Workers, проработало один день, потом Cloudflare прикрыл эндпоинт. OpenRouter решил проблему: единый API-гейтвей ко множеству моделей, работает стабильно.

Модель - google/gemini-3-flash-preview. Быстрая, дешёвая, достаточно умная для контекстных советов. Temperature 0.5, top_p 0.95.

Архитектура AiService

AiService — 827 строк. Singleton с предзагрузкой в main():

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load();
  unawaited(AiService.preload()); // Модель готова к первому запросу
  // ...
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 10

unawaited - принципиальный момент. Предзагрузка идёт параллельно с Firebase и UI, не блокируя запуск. Когда пользователь первый раз откроет чат, сервис будет уже инициализирован.

Системный промпт: как задать характер

Ядро промпта, который превращает Gemini в Михалыча:

Ты — Михалыч, ворчливый прораб-наставник с 30-летним стажем.

ТВОЙ ХАРАКТЕР:
- Ворчливый наставник, но ПОЛЕЗНЫЙ. Юмор — приправа, а не основное блюдо.
- Подкалываешь метко и коротко: "Запас 5%? Ну-ну. На третий день 
  побежишь в магазин."
- Ловишь ошибки: "Стоп. 3 мешка Ротбанда на 20 квадратов? Ты точно 
  штукатурил раньше?"

ГЛАВНОЕ ПРАВИЛО — КОНКРЕТИКА И ПОЛЬЗА:
- Называй конкретные марки, цифры, размеры. "Бери Ceresit CM-14" 
  вместо "бери хороший клей".
- Советуй сопутствующие материалы, которые часто забывают: 
  грунтовка, демпферная лента, маяки, крестики.
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 11

Промпт прошёл через десятки итераций. В первых версиях Михалыч звучал как ChatGPT в каске. Потом перегнул палку с грубостью, в итоге модель начинала оскорблять. Текущий баланс нашёлся через формулировку «юмор - приправа, а не основное блюдо». Но и это ещё не идеал.

Контекстная осведомлённость

Михалыч знает, какой калькулятор открыт и что ввёл пользователь. Три режима контекста:

if (isHomeScreen) {
  if (hasHistory) {
    contextBlock = 'Пользователь на главном экране.nn'
        '$calculationHistoryn'
        'Используй эту историю для контекстных советов.';
  } else {
    contextBlock = 'Расчётов пока не делал. Поздоровайся по-свойски.';
  }
} else if (hasData) {
  contextBlock = 'Калькулятор: $calculatorName.n'
      'Данные расчёта: $calculationData.';
} else {
  contextBlock = 'Открыт калькулятор «$calculatorName». '
      'Конкретных цифр нет — дай общий практический совет. '
      'НЕ говори что поля пустые, НЕ проси ввести данные.';
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 12

Последняя строчка стоила часа отладки. Без неё модель при пустых полях упорно отвечала «Сначала заполни поля ввода», что в последствии бесполезно и раздражает.

Компактификация истории

OpenRouter тарифицирует по токенам. Историю нужно хранить для контекста, но не раздувать:

void _trimHistory() {
  const maxItems = _maxHistoryPairs * 2; // 8 пар
  if (_history.length > maxItems) {
    _history.removeRange(0, _history.length - maxItems);
  }
  // Старые ответы обрезаем до 400 символов
  for (var i = 0; i < _history.length - 2; i++) {
    final msg = _history[i];
    if (msg['role'] != 'assistant') continue;
    final content = msg['content'] ?? '';
    if (content.length > _maxOldResponseLength) {
      _history[i] = {
        'role': 'assistant',
        'content': '${content.substring(0, _maxOldResponseLength)}...',
      };
    }
  }
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 13

Максимум 8 пар (вопрос-ответ), старые ответы Михалыча обрезаны до 400 символов. Последний ответ будет всегда полный. Экономия токенов в 2-3 раза при длинных диалогах.

SSE-стриминг

Текст появляется по словам, не блоком. Ручной парсинг SSE-ответа OpenRouter:

response.stream.transform(utf8.decoder).listen((chunk) {
  lineBuf += chunk;
  final lines = lineBuf.split('n');
  lineBuf = lines.removeLast(); // неполная строка

  for (final line in lines) {
    final trimmed = line.trim();
    if (trimmed == 'data: [DONE]') continue;
    if (!trimmed.startsWith('data: ')) continue;

    final json = jsonDecode(trimmed.substring(6));
    final content = json['choices']?[0]?['delta']?['content'];
    if (content != null && content.isNotEmpty) {
      buffer.write(content);
      controller.add(content);
    }
  }
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 14

Таймаут 120 секунд на весь стрим. При обрыве, всё из буфера идёт в историю. Если буфер пустой, то последнее сообщение пользователя откатывается, чтобы не ломать контекст.

Двойной лимит и Quick Tips

Защита: 20 запросов в день + 10 в час. Счётчик увеличивается ДО запроса к API, своего рода защита от бесконечных запросов. Даже сообщения об ошибках есть в характере: «Всё, начальник, на сегодня хватит, у меня уже голова пухнет!»

А для очевидных ошибок ввода - getQuickTip(), локальная проверка без API:

String? getQuickTip(String calculatorId, Map<String, double> inputs) {
  for (final entry in inputs.entries) {
    if (entry.value <= 0) {
      return 'Из воздуха строить собрался? '
          'Вводи реальные цифры в поле «${entry.key}».';
    }
  }
  final area = inputs['area'] ?? inputs['length'] ?? 0;
  if (area > 500) {
    return 'Ты космодром строишь? Проверь размеры.';
  }
  return null;
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 15

Ноль затрат, мгновенный ответ, тот же Михалыч.

Тестирование: 8180 тестов на одного разработчика

Когда я говорю «8180 тестов», обычная реакция - «зачем, если ты один?» Ответ простой: 45+ калькуляторов - это 45+ наборов формул с граничными случаями. Изменил calculatePrice() в BaseCalculator и любой из 45 может сломаться. Без тестов, будет ручная проверка каждого. С тестамиflutter test за 40 секунд и готово.

Структура

5 398 unit-тестов + 2 785 widget-тестов, 407 файлов. По файлу на калькулятор в usecases, плюс тесты моделей, провайдеров, сервисов.

Как тестировать ИИ-сервис без ИИ

AiService нельзя тестировать с реальным API. Зато можно тестировать лимиты, контекст, quick tips, синглтон:

test('throws AiDailyLimitException at count 20', () async {
  SharedPreferences.setMockInitialValues({
    'ai_request_count': 20,
    'ai_last_request_date':
        DateFormat('yyyy-MM-dd').format(DateTime.now()),
  });
  final service = await AiService.instance;
  expect(
    () => service.checkDailyLimit(),
    throwsA(isA<AiDailyLimitException>()),
  );
});

test('allows requests on new day (counter resets)', () async {
  SharedPreferences.setMockInitialValues({
    'ai_request_count': 20,
    'ai_last_request_date': '2020-01-01',
  });
  final service = await AiService.instance;
  await service.checkDailyLimit(); // ок, новый день
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 16

Quick tips - на граничных значениях:

test('returns null for area exactly 500', () {
  final tip = service.getQuickTip('tile', {'area': 500});
  expect(tip, isNull); // 500 — ок, 501 — уже «космодром»
});

test('zero check takes priority over area check', () {
  final tip = service.getQuickTip('tile', {'width': 0, 'area': 600});
  expect(tip, contains('реальные цифры'));
  // проверка нуля идёт первой
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 17

Тесты фиксируют контракт: в каком порядке идут проверки, какие граничные значения допустимы, что показать при одновременно нескольких ошибках.

Синглтон тоже тестируется через resetInstance(), помеченный @visibleForTesting:

test('resetInstance creates new instance', () async {
  final instance1 = await AiService.instance;
  AiService.resetInstance();
  final instance2 = await AiService.instance;
  expect(identical(instance1, instance2), isFalse);
});
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 18

В продакшне resetInstance() никогда не вызывается. В тестах даёт чистый экземпляр для каждого кейса.

Точка входа

Как всё стартует:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await dotenv.load();
  unawaited(AiService.preload());
  if (!kIsWeb) FrameTimingLogger.maybeInit();

  try {
    if (Firebase.apps.isEmpty) {
      await Firebase.initializeApp(
        options: DefaultFirebaseOptions.currentPlatform);
    }
  } catch (e) {
    debugPrint('Firebase already initialized: $e');
  }

  if (!kIsWeb) {
    unawaited(TrackerService.initialize(
      dotenv.env['MYTRACKER_SDK_KEY'] ?? ''));
  }

  final prefs = await SharedPreferences.getInstance();

  FlutterError.onError = (details) {
    GlobalErrorHandler.logFatalError(
      details.exception, details.stack ?? StackTrace.current, 'FlutterError');
    crashlytics.recordFlutterFatalError(details);
  };

  runApp(
    ProviderScope(
      overrides: [
        calculatorMemoryProvider.overrideWithValue(
          CalculatorMemoryService(prefs)),
      ],
      child: const ProbuilderApp(),
    ),
  );
}
Как я за 2,5 месяца написал строительный калькулятор на Flutter с ИИ-прорабом, 45+ калькуляторами и 8000 тестами - 19

Три момента: unawaited для параллельной инициализации сервисов. ProviderScope с overrides для SharedPreferences - они нужны синхронно, поэтому создаются в main(). Условные импорты if (dart.library.io) — Crashlytics и MyTracker работают только на нативе, а приложение компилируется и под веб.

Итоги

За 2,5 месяца соло-разработки, приложение с рейтингом 4.9 в RuStore, которым я сам пользуюсь при ремонте.

Переписывание V1→V2 на полпути стоило недели, но сэкономило месяц на остальных 40+ калькуляторах. Декларативный подход окупается с третьего калькулятора.

ИИ-персонаж - это продуктовый дизайн, а не промпт-инжиниринг. 80% времени ушло не на интеграцию с API, а на подбор тона и поведения в граничных случаях.

8000 тестов для соло-проекта, думаю, что не перебор. Каждый рефакторинг BaseCalculator подтверждал: без тестов я бы находил регрессии неделями.

Автор: Soft_gAming

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js