21 KiB
План: анализ привычек трат (две шкалы оценки транзакций)
Контекст
Добавляем механику «трекера привычек»: каждую транзакцию можно оценить по двум независимым шкалам, а затем анализировать структуру трат по этим шкалам на отдельном подэкране аналитики.
Шкалы (обе опциональные, по умолчанию «не отмечено»):
- Обязательность (
obligation):required(Обязательно),optional(Не обязательно),unnecessary(Не нужно). - Импульсивность (
impulse):impulsive(Импульсивно),considered(Обдуманно).
Вся фича прячется за тумблером в настройках профиля
(habitTrackingEnabled). Когда выключено — БД-поля остаются, но UI (ярлыки в строках,
селекторы в форме, подэкран аналитики) не показывается. Это значит: миграция и доменные
поля делаются всегда, а презентация — условная.
Соответствие архитектуре (см. CLAUDE.md): data (Drift) → domain (entity/enum) →
application (провайдеры) → presentation (виджеты). Цвета — только через тему, без хардкода.
Часть 0. Данные и доменка (фундамент, нужен всегда)
0.1 Enum'ы шкал
enum_converters.dart — добавить рядом с существующими enum'ами + nullable TypeConverter'ы (поля опциональны):
enum SpendingObligation { required, optional, unnecessary }
class SpendingObligationConverter extends TypeConverter<SpendingObligation, String> {
const SpendingObligationConverter();
@override
SpendingObligation fromSql(String fromDb) =>
SpendingObligation.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(SpendingObligation value) => value.name;
}
enum SpendingImpulse { impulsive, considered }
// + SpendingImpulseConverter по тому же шаблону
Drift применяет
.map(converter)к не-nullable колонке; для nullable-колонки конвертер вызывается только для не-null значений (.nullable()ставится после.map(...)).
0.2 Колонки транзакции
transactions_table.dart — после
extraInfo, до полей парсинга:
/// Шкала «обязательность» (habit-tracking). null = не отмечено.
TextColumn get obligation =>
text().map(const SpendingObligationConverter()).nullable()();
/// Шкала «импульсивность» (habit-tracking). null = не отмечено.
TextColumn get impulse =>
text().map(const SpendingImpulseConverter()).nullable()();
0.3 Тумблер в настройках
BoolColumn get habitTrackingEnabled =>
boolean().withDefault(const Constant(false))();
0.4 Миграция
app_database.dart: schemaVersion 5 → 6,
новый блок в onUpgrade:
if (from < 6) {
await m.addColumn(transactionsTable, transactionsTable.obligation);
await m.addColumn(transactionsTable, transactionsTable.impulse);
await m.addColumn(settingsTable, settingsTable.habitTrackingEnabled);
}
0.5 Доменные сущности + мапперы + черновик
- transaction.dart (freezed):
добавить
SpendingObligation? obligationиSpendingImpulse? impulse. - transaction_mapper.dart:
пробросить
obligation: obligation, impulse: impulse. - transaction_repository.dart
- transaction_repository_impl.dart:
добавить параметры
obligation/impulseвcreate(...)и прокинуть в companion (update(Transaction)уже принимает сущность целиком — нужно лишь добавить поля в companion-мапинг).
- transaction_repository_impl.dart:
добавить параметры
- transactions_controller.dart:
добавить
obligation/impulseв сигнатуруcreateTransaction(...)и проброс в репозиторий. - transaction_draft.dart:
два новых nullable-поля + сеттеры
setObligation/setImpulse(использовать тот же_sentinel-паттерн вcopyWith, чтобы можно было сбрасывать в null). - settings.dart +
settings_mapper.dart:
добавить
bool habitTrackingEnabled. - Settings repo/controller: метод
setHabitTrackingEnabled(userId, bool)по образцуupdateThemeMode(settings_repository_impl.dart, settings_controller.dart); не забытьhabitTrackingEnabledв_toCompanion,_defaultSettings(=false) иensureDefaults.
0.6 Code-gen
После правок: dart run build_runner build --delete-conflicting-outputs
(затрагивает app_database.g.dart, transaction.freezed.dart, settings.freezed.dart,
transaction_draft.g.dart). Затем flutter analyze.
Часть 1. Ярлыки в строке транзакции (presentation)
1.1 Цвета ярлыков
Семантические цвета шкал (тёмно-зелёный/светло-зелёный и т.д.) не относятся к брендовой
палитре AppPalette, но хардкод в виджетах запрещён. Решение: отдельный
ThemeExtension рядом с палитрой.
Новый файл lib/src/app/theme/habit_chip_colors.dart — HabitChipColors extends ThemeExtension<HabitChipColors> с парами фон/текст под каждое значение + светлый/тёмный
варианты, по образцу app_colors.dart (copyWith/lerp/
extension ... on BuildContext). Зарегистрировать в ThemeData.extensions в
app_theme.dart рядом с AppPalette.
Токены (тёмная тема как база; для светлой подобрать читаемые аналоги):
| Значение | Фон | Текст/иконка |
|---|---|---|
required (Обязательно) |
тёмно-зелёный | светло-зелёный |
optional (Не обязательно) |
тёмно-горчичный / коричнево-оранжевый | песочный |
unnecessary (Не нужно) |
тёмно-бордовый | бледно-розовый |
impulsive (⚡ Импульс) |
тёмно-коричневый / полупрозрачный оранжевый | светло-оранжевый |
considered (Обдуманно) ярлыком не показывается (чтобы не перегружать список).
1.2 Виджет ярлыков
Новый lib/src/features/home/presentation/widgets/habit_chips.dart:
HabitChips({required Transaction tx})→Rowс выравниванием по левому краю.- Каждый ярлык — «пилюля»:
ContainerсBorderRadius.circular(99),padding: EdgeInsets.symmetric(horizontal: 6, vertical: 1),fontSize: 11(как у времени в tx_row.dart),fontWeight: FontWeight.w600. - Порядок: сначала ярлык обязательности (если задан), затем «⚡ Импульс» (только при
impulse == impulsive); между нимиSizedBox(width: 6). - «⚡ Импульс» =
Icon(Icons.bolt, size: 11)слева + текст. - Пустое состояние (обе шкалы null) → текст «не отмечено»,
fontStyle: italic, без фона, цветp.ink2. - Локализация всех подписей через
context.l10n(см. §3).
1.3 Интеграция в строку
В tx_row.dart фича-флаг приходит
параметром (виджет — StatelessWidget, без ref): добавить
final bool habitTrackingEnabled; в конструктор TxRow.
Логика подвала строки (сейчас subtitle = '${cat?.name} · ${time}' на 3-й строке, плюс
опциональная строка extraInfo):
- Флаг выключен → поведение как сейчас.
- Флаг включён → блок
extraInfoзаменяется строкой привычек: время +·+ ярлыки. То есть нижняя строка =Row[ Text(time), Text(' · '), HabitChips(tx) ]с выравниванием по левому краю. (Категория остаётся вsubtitle-строке как сейчас, либо переносится — см. «Открытый вопрос» ниже; базовый вариант: ярлыки идут вместоextraInfo, как просил юзер «Вместо доп. информации».)
Источник флага: тот, кто строит TxRow
(transactions_section.dart
и аналитический список §2) читает
ref.watch(settingsControllerProvider(userId)).value?.habitTrackingEnabled ?? false
и прокидывает в TxRow.
1.4 Селекторы в форме транзакции
transaction_form_screen.dart: при включённом флаге показать два селектора (под существующими полями):
- Обязательность — три chip'а в ряд (по образцу
type_segmented.dart/ фильтр-chip'ов), повторное нажатие на активный сбрасывает в null. - Импульсивность — segmented на два значения (Импульсивно/Обдуманно), также сбрасываемый.
Связать с TransactionDraftController.setObligation/setImpulse; прокинуть значения в
createTransaction(...) / updateTransaction(...) при сохранении. При hydrate из существующей
транзакции (режим редактирования) — заполнить из сущности.
Часть 2. Подэкран «Анализ привычек» в аналитике
2.1 Аналитика как хаб
Сейчас analytics_screen.dart —
заглушка. Превращаем в список подэкранов (на будущее их несколько); первый —
«Анализ привычек» (показывать пункт только при habitTrackingEnabled).
Навигация:
- app_routes.dart:
static const habitAnalysis = '/analytics/habits'; - app_router.dart:
GoRouteвнутри ветки analytics (или push поверх) → новый экран.
Новый файл lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart
(ConsumerWidget).
2.2 Шапка + выбор месяца
- Крупный заголовок «Транзакции» слева (белый/
p.ink). - Селектор месяца справа/над заголовком: «Май 2026» + стрелки
‹ ›, переиспользуя существующийselectedMonthProvider(.previous()/.next()) из selected_category_filter.dart и форматDateFormat('LLLL yyyy', locale). Цвет текста —p.ink/p.ink2.
Месяц общий с главным экраном (тот же провайдер) — это ок и даже желательно (консистентный период). Если нужна независимость — завести отдельный
habitSelectedMonthProvider; решить до реализации (см. открытые вопросы).
2.3 Провайдеры агрегатов
Новый файл lib/src/features/analytics/application/habit_analysis_providers.dart
(@riverpod), по образцу month_summary.dart:
- Состояние фильтров (autoDispose
@riverpod class):HabitImpulseFilter→SpendingImpulse?+ спец-значение «Все» (null = все).HabitObligationFilter→Set<SpendingObligation>(мульти-выбор chip'ов; пусто = все).
habitMonthTransactions(userId)— транзакции заselectedMonth, только расходы (type == expense), без учёта фильтров (нужны для подсчёта сумм в каждой секции).habitSumByImpulse(userId)→Map<SpendingImpulse?, int>(включая ключ null = «без оценки»; для секции «Все» — общая сумма).habitSumByObligation(userId)→Map<SpendingObligation?, int>.habitFilteredTransactions(userId)— применяет оба фильтра кhabitMonthTransactions(для сводки и списка).
Суммы — в минорных единицах; форматирование через MoneyText
(money_text.dart).
2.4 Блок фильтров (с суммами)
Над контролами — подзаголовки мелким шрифтом, ALL CAPS, цвет p.ink2:
«ИМПУЛЬСИВНОСТЬ», «ОБЯЗАТЕЛЬНОСТЬ».
Импульсивность — segmented control (3 равные секции, контент в 2 строки, текст по центру):
| Секция | Строка 1 | Строка 2 |
|---|---|---|
| Все | Все | сумма |
| Обдуманные | Обдуманные | сумма |
| Импульс | ⚡ Импульс | сумма |
Активный сегмент — фон p.ink2/тёмно-серый + белый текст; неактивные сливаются с фоном
(паттерн type_segmented.dart).
Обязательность — набор chip'ов (3 кнопки в ряд, тонкая обводка p.line, цветная
точка-индикатор слева, контент в 2 строки):
| Chip | Точка | Строка 1 | Строка 2 |
|---|---|---|---|
| Обязательно | 🟢 | Обязательно | сумма |
| Можно без | 🟡 | Можно без | сумма |
| Не нужно | 🔴 | Не нужно | сумма |
Цвета точек — из HabitChipColors (§1.1). Нажатие тоглит значение в HabitObligationFilter.
Подписи фильтра отличаются от ярлыков строки: здесь «Можно без» вместо «Не обязательно» — это отдельные l10n-ключи (§3).
2.5 Сводка + список
- Сводная строка под фильтрами: слева количество («10 операций» — переиспользовать
l10n.transactionCount(n)), справа крупная сумма («−40 592 ₽»,MoneyTextкрупным шрифтом) поhabitFilteredTransactions. - Ниже — скроллируемый список за выбранный месяц, отфильтрованный, дизайн идентичен
главному экрану: переиспользовать
TxRow(сhabitTrackingEnabled: true, чтобы ярлыки были видны) и группировку по дням (DayHeader).
Часть 3. Локализация
app_en.arb / app_ru.arb → flutter gen-l10n.
Новые ключи (RU значения):
| Ключ | RU |
|---|---|
habitTrackingTile |
«Анализ привычек трат» (тумблер в профиле) |
habitObligationRequired |
«Обязательно» |
habitObligationOptional |
«Не обязательно» |
habitObligationUnnecessary |
«Не нужно» |
habitObligationOptionalShort |
«Можно без» (для фильтра аналитики) |
habitImpulseImpulsive |
«Импульс» |
habitImpulseConsidered |
«Обдуманные» |
habitNotMarked |
«не отмечено» |
habitFilterAll |
«Все» |
habitScaleImpulseCaps |
«ИМПУЛЬСИВНОСТЬ» |
habitScaleObligationCaps |
«ОБЯЗАТЕЛЬНОСТЬ» |
habitAnalysisTitle |
«Транзакции» (заголовок подэкрана) |
habitAnalysisTile |
«Анализ привычек» (пункт в хабе аналитики) |
Часть 4. Тесты (см. соглашения в CLAUDE.md → Testing)
- Миграция v5→v6: тест на
AppDatabase.forTesting— apply миграции, проверить наличие колонок и дефолтов. - Repo/controller:
create/updateсobligation/impulse(round-trip через FakeRepo и через реальную in-memory БД). - Агрегаты:
habitSumByObligation/habitSumByImpulse/habitFilteredTransactions—ProviderContainerс override стрима транзакций, проверить суммы и фильтрацию (вкл. ключ null). - Widget:
HabitChips— рендер пилюль по значениям, пустое состояние «не отмечено»;TxRowсhabitTrackingEnabledtrue/false (ярлыки vsextraInfo). import 'package:drift/drift.dart' hide isNull, isNotNull;в БД-тестах.
Порядок реализации
- Часть 0 целиком (data/domain/migration) +
build_runner+flutter analyze— фундамент. - Тумблер в профиле (profile_screen.dart,
_RowсоSwitch, как у тёмной темы) →settingsController.setHabitTrackingEnabled. HabitChipColors+HabitChips+ интеграция вTxRowиtransactions_section.- Селекторы в форме транзакции.
- Подэкран «Анализ привычек» (провайдеры → шапка → фильтры → сводка → список).
- Локализация (по ходу 2–5).
- Тесты.
Открытые вопросы (решить до реализации)
- Подвал строки: ярлыки заменяют
extraInfo(как написано в ТЗ), но что с категорией + временем? Базовый план: ярлыки идут вместо строкиextraInfo, строкакатегория · времяостаётся. Альтернатива: время уходит в строку ярлыков (время · ярлыки), категория — в основную. Уточнить визуал. - Месяц в аналитике: общий
selectedMonthProviderс главным экраном или отдельный. - Палитра habit-цветов: точные hex'ы для светлой темы (в ТЗ описаны «тёмные» фоны — они под тёмную тему; для светлой нужны читаемые аналоги).
requiredкак имя enum-значения — зарезервированное слово Dart можно использовать как имя поля enum, но если возникнут конфликты, переименовать вmandatory.