В конце ноября 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 == '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); } } });
Таймаут 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 подтверждал: без тестов я бы находил регрессии неделями.
