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

В конце ноября 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) — экраны, провайдеры, виджеты

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

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

Обратите внимание на 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 [];
}
});

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 полей для каждого калькулятора
],
);
}

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

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

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

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

enum FieldInputType {
number, // TextFormField
select, // DropdownButton
checkbox, // Checkbox
switch_, // Switch
radio, // Radio
slider, // Slider
}

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

enum UnitType {
squareMeters, // м²
cubicMeters, // м³
linearMeters, // пог. м
pieces, // шт.
kilograms, // кг
bags, // меш.
rolls, // рул.
meters, // м
millimeters, // мм
rubles, // ₽
// ...
}

Каждое определение калькулятора содержит метаданные: иконку, цвет, сложность (от 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];
}
}

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

Если для калькулятора есть кастомный экран то используется он. Если нет - 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()); // Модель готова к первому запросу
// ...
}

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

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

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

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

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

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

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

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

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

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

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

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

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

Максимум 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 == 'dаta: [DONE]') continue;
if (!trimmed.startsWith('dаta: ')) 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);
}
}
});

Таймаут 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;
}

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

Тестирование: 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(); // ок, новый день
});

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('реальные цифры'));
// проверка нуля идёт первой
});

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

Синглтон тоже тестируется через 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);
});

В продакшне 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(),
),
);
}

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

Итоги

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

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

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

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


Внимание!

Официальный сайт бота по ссылке ниже.

Официальный сайт