Files
OnBudget/docs/spending_habits_plan.md
2026-06-01 00:55:38 +03:00

21 KiB
Raw Permalink Blame History

План: анализ привычек трат (две шкалы оценки транзакций)

Контекст

Добавляем механику «трекера привычек»: каждую транзакцию можно оценить по двум независимым шкалам, а затем анализировать структуру трат по этим шкалам на отдельном подэкране аналитики.

Шкалы (обе опциональные, по умолчанию «не отмечено»):

  1. Обязательность (obligation): required (Обязательно), optional (Не обязательно), unnecessary (Не нужно).
  2. Импульсивность (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 Тумблер в настройках

settings_table.dart:

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-мапинг).
  • 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.dartHabitChipColors 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):
    • HabitImpulseFilterSpendingImpulse? + спец-значение «Все» (null = все).
    • HabitObligationFilterSet<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.arbflutter 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/habitFilteredTransactionsProviderContainer с override стрима транзакций, проверить суммы и фильтрацию (вкл. ключ null).
  • Widget: HabitChips — рендер пилюль по значениям, пустое состояние «не отмечено»; TxRow с habitTrackingEnabled true/false (ярлыки vs extraInfo).
  • import 'package:drift/drift.dart' hide isNull, isNotNull; в БД-тестах.

Порядок реализации

  1. Часть 0 целиком (data/domain/migration) + build_runner + flutter analyze — фундамент.
  2. Тумблер в профиле (profile_screen.dart, _Row со Switch, как у тёмной темы) → settingsController.setHabitTrackingEnabled.
  3. HabitChipColors + HabitChips + интеграция в TxRow и transactions_section.
  4. Селекторы в форме транзакции.
  5. Подэкран «Анализ привычек» (провайдеры → шапка → фильтры → сводка → список).
  6. Локализация (по ходу 2–5).
  7. Тесты.

Открытые вопросы (решить до реализации)

  • Подвал строки: ярлыки заменяют extraInfo (как написано в ТЗ), но что с категорией + временем? Базовый план: ярлыки идут вместо строки extraInfo, строка категория · время остаётся. Альтернатива: время уходит в строку ярлыков (время · ярлыки), категория — в основную. Уточнить визуал.
  • Месяц в аналитике: общий selectedMonthProvider с главным экраном или отдельный.
  • Палитра habit-цветов: точные hex'ы для светлой темы (в ТЗ описаны «тёмные» фоны — они под тёмную тему; для светлой нужны читаемые аналоги).
  • required как имя enum-значения — зарезервированное слово Dart можно использовать как имя поля enum, но если возникнут конфликты, переименовать в mandatory.