В конце ноября 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 на Flutter», обычно подразумевают слепое следование шаблону Дяди Боба с абстрактными репозиториями, use case на каждый чих и интерфейсами ради интерфейсов. Я пошёл другим путём: взял принципы, но адаптировал под реальность, один разработчик, ограниченное время, 45+ калькуляторов, которые нужно было выпустить за 2,5 месяца.
Главный принцип, который я соблюдал строго: domain не импортирует ничего из presentation и data. Всё остальное по ситуации.
Каждый калькулятор — это 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 элементов.
Это та часть, ради которой я переписал треть проекта на полпути. Первая версия калькуляторов (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-кода.
Все определения регистрируются в едином реестре. Калькуляторы группируются по категориям в отдельных файлах (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, старые сохранённые проекты и избранное не потерялись.
Открытие калькулятора в 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-летним стажем, который разговаривает строительным сленгом, ловит ошибки в расчётах и подкалывает, когда видит подозрительные цифры.
Разрабатывая из России, я быстро упёрся в санкции: Google API напрямую недоступен. Попробовал поднять прокси через Cloudflare Workers, проработало один день, потом Cloudflare прикрыл эндпоинт. OpenRouter решил проблему: единый API-гейтвей ко множеству моделей, работает стабильно.
Модель - google/gemini-3-flash-preview. Быстрая, дешёвая, достаточно умная для контекстных советов. Temperature 0.5, top_p 0.95.
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-ответа 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 секунд на весь стрим. При обрыве, всё из буфера идёт в историю. Если буфер пустой, то последнее сообщение пользователя откатывается, чтобы не ломать контекст.
Защита: 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 тестов», обычная реакция - «зачем, если ты один?» Ответ простой: 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 подтверждал: без тестов я бы находил регрессии неделями.