Add habits

This commit is contained in:
2026-06-01 10:58:38 +03:00
parent 7d7036c67e
commit 512ea44192
43 changed files with 2282 additions and 35 deletions
+18 -1
View File
@@ -362,5 +362,22 @@
"type": "int"
}
}
}
},
"habitTrackingTile": "Spending habit analysis",
"habitObligationRequired": "Necessary",
"habitObligationOptional": "Optional",
"habitObligationUnnecessary": "Unnecessary",
"habitObligationOptionalShort": "Optional",
"habitImpulseImpulsive": "Impulse",
"habitImpulseConsidered": "Considered",
"habitNotMarked": "not marked",
"habitFilterAll": "All",
"habitScaleImpulseCaps": "IMPULSIVENESS",
"habitScaleObligationCaps": "OBLIGATION",
"habitAnalysisTitle": "Transactions",
"habitAnalysisTile": "Habit analysis",
"profileSeedDemoTile": "Fill with demo transactions",
"profileSeedDemoSuccess": "Demo transactions added"
}
+90
View File
@@ -1663,6 +1663,96 @@ abstract class AppLocalizations {
/// In ru, this message translates to:
/// **'Токенов за сегодня: {tokens} / {limit}'**
String parsingAiTokensTodayLimited(int tokens, int limit);
/// No description provided for @habitTrackingTile.
///
/// In ru, this message translates to:
/// **'Анализ привычек трат'**
String get habitTrackingTile;
/// No description provided for @habitObligationRequired.
///
/// In ru, this message translates to:
/// **'Обязательно'**
String get habitObligationRequired;
/// No description provided for @habitObligationOptional.
///
/// In ru, this message translates to:
/// **'Не обязательно'**
String get habitObligationOptional;
/// No description provided for @habitObligationUnnecessary.
///
/// In ru, this message translates to:
/// **'Не нужно'**
String get habitObligationUnnecessary;
/// No description provided for @habitObligationOptionalShort.
///
/// In ru, this message translates to:
/// **'Можно без'**
String get habitObligationOptionalShort;
/// No description provided for @habitImpulseImpulsive.
///
/// In ru, this message translates to:
/// **'Импульсивно'**
String get habitImpulseImpulsive;
/// No description provided for @habitImpulseConsidered.
///
/// In ru, this message translates to:
/// **'Обдуманно'**
String get habitImpulseConsidered;
/// No description provided for @habitNotMarked.
///
/// In ru, this message translates to:
/// **'не отмечено'**
String get habitNotMarked;
/// No description provided for @habitFilterAll.
///
/// In ru, this message translates to:
/// **'Все'**
String get habitFilterAll;
/// No description provided for @habitScaleImpulseCaps.
///
/// In ru, this message translates to:
/// **'ИМПУЛЬСИВНОСТЬ'**
String get habitScaleImpulseCaps;
/// No description provided for @habitScaleObligationCaps.
///
/// In ru, this message translates to:
/// **'ОБЯЗАТЕЛЬНОСТЬ'**
String get habitScaleObligationCaps;
/// No description provided for @habitAnalysisTitle.
///
/// In ru, this message translates to:
/// **'Транзакции'**
String get habitAnalysisTitle;
/// No description provided for @habitAnalysisTile.
///
/// In ru, this message translates to:
/// **'Анализ привычек'**
String get habitAnalysisTile;
/// No description provided for @profileSeedDemoTile.
///
/// In ru, this message translates to:
/// **'Заполнить демо транзакциями'**
String get profileSeedDemoTile;
/// No description provided for @profileSeedDemoSuccess.
///
/// In ru, this message translates to:
/// **'Демо транзакции добавлены'**
String get profileSeedDemoSuccess;
}
class _AppLocalizationsDelegate
+45
View File
@@ -872,4 +872,49 @@ class AppLocalizationsEn extends AppLocalizations {
String parsingAiTokensTodayLimited(int tokens, int limit) {
return 'Tokens today: $tokens / $limit';
}
@override
String get habitTrackingTile => 'Spending habit analysis';
@override
String get habitObligationRequired => 'Necessary';
@override
String get habitObligationOptional => 'Optional';
@override
String get habitObligationUnnecessary => 'Unnecessary';
@override
String get habitObligationOptionalShort => 'Optional';
@override
String get habitImpulseImpulsive => 'Impulse';
@override
String get habitImpulseConsidered => 'Considered';
@override
String get habitNotMarked => 'not marked';
@override
String get habitFilterAll => 'All';
@override
String get habitScaleImpulseCaps => 'IMPULSIVENESS';
@override
String get habitScaleObligationCaps => 'OBLIGATION';
@override
String get habitAnalysisTitle => 'Transactions';
@override
String get habitAnalysisTile => 'Habit analysis';
@override
String get profileSeedDemoTile => 'Fill with demo transactions';
@override
String get profileSeedDemoSuccess => 'Demo transactions added';
}
+45
View File
@@ -885,4 +885,49 @@ class AppLocalizationsRu extends AppLocalizations {
String parsingAiTokensTodayLimited(int tokens, int limit) {
return 'Токенов за сегодня: $tokens / $limit';
}
@override
String get habitTrackingTile => 'Анализ привычек трат';
@override
String get habitObligationRequired => 'Обязательно';
@override
String get habitObligationOptional => 'Не обязательно';
@override
String get habitObligationUnnecessary => 'Не нужно';
@override
String get habitObligationOptionalShort => 'Можно без';
@override
String get habitImpulseImpulsive => 'Импульсивно';
@override
String get habitImpulseConsidered => 'Обдуманно';
@override
String get habitNotMarked => 'не отмечено';
@override
String get habitFilterAll => 'Все';
@override
String get habitScaleImpulseCaps => 'ИМПУЛЬСИВНОСТЬ';
@override
String get habitScaleObligationCaps => 'ОБЯЗАТЕЛЬНОСТЬ';
@override
String get habitAnalysisTitle => 'Транзакции';
@override
String get habitAnalysisTile => 'Анализ привычек';
@override
String get profileSeedDemoTile => 'Заполнить демо транзакциями';
@override
String get profileSeedDemoSuccess => 'Демо транзакции добавлены';
}
+18 -1
View File
@@ -362,5 +362,22 @@
"type": "int"
}
}
}
},
"habitTrackingTile": "Анализ привычек трат",
"habitObligationRequired": "Обязательно",
"habitObligationOptional": "Не обязательно",
"habitObligationUnnecessary": "Не нужно",
"habitObligationOptionalShort": "Можно без",
"habitImpulseImpulsive": "Импульсивно",
"habitImpulseConsidered": "Обдуманно",
"habitNotMarked": "не отмечено",
"habitFilterAll": "Все",
"habitScaleImpulseCaps": "ИМПУЛЬСИВНОСТЬ",
"habitScaleObligationCaps": "ОБЯЗАТЕЛЬНОСТЬ",
"habitAnalysisTitle": "Транзакции",
"habitAnalysisTile": "Анализ привычек",
"profileSeedDemoTile": "Заполнить демо транзакциями",
"profileSeedDemoSuccess": "Демо транзакции добавлены"
}
+6
View File
@@ -6,6 +6,7 @@ import '../../core/database/converters/enum_converters.dart';
import '../../features/accounts/presentation/screens/account_form_screen.dart';
import '../../features/accounts/presentation/screens/accounts_screen.dart';
import '../../features/analytics/presentation/screens/analytics_screen.dart';
import '../../features/analytics/presentation/screens/habit_analysis_screen.dart';
import '../../features/categories/presentation/screens/categories_list_screen.dart';
import '../../features/categories/presentation/screens/category_form_screen.dart';
import '../../features/home/presentation/screens/home_screen.dart';
@@ -139,6 +140,11 @@ GoRouter appRouter(Ref ref) {
path: AppRoutes.parsingLog,
builder: (context, state) => const ParsingLogScreen(),
),
GoRoute(
path: AppRoutes.habitAnalysis,
pageBuilder: (context, state) =>
_slideUpPage<void>(state, const HabitAnalysisScreen()),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) => AppScaffold(
navigationShell: navigationShell,
+1
View File
@@ -3,6 +3,7 @@ class AppRoutes {
static const home = '/home';
static const analytics = '/analytics';
static const habitAnalysis = '/analytics/habits';
static const accounts = '/accounts';
static const profile = '/profile';
static const onboarding = '/onboarding';
+8 -4
View File
@@ -2,14 +2,18 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_colors.dart';
import 'habit_chip_colors.dart';
class AppTheme {
const AppTheme._();
static ThemeData light() => _build(AppPalette.light, Brightness.light);
static ThemeData dark() => _build(AppPalette.dark, Brightness.dark);
static ThemeData light() =>
_build(AppPalette.light, HabitChipColors.light, Brightness.light);
static ThemeData dark() =>
_build(AppPalette.dark, HabitChipColors.dark, Brightness.dark);
static ThemeData _build(AppPalette p, Brightness brightness) {
static ThemeData _build(
AppPalette p, HabitChipColors habit, Brightness brightness) {
final base = ThemeData(
useMaterial3: true,
brightness: brightness,
@@ -25,7 +29,7 @@ class AppTheme {
),
scaffoldBackgroundColor: p.paper,
dividerColor: p.line,
extensions: [p],
extensions: [p, habit],
);
return base.copyWith(
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
/// Семантические цвета ярлыков «привычек трат» (habit-tracking).
///
/// Не относятся к брендовой палитре [AppPalette], но хардкод в виджетах
/// запрещён — поэтому отдельный [ThemeExtension] рядом с палитрой.
///
/// Для каждого значения шкалы — пара «фон / текст-иконка». Дополнительно
/// цветные точки-индикаторы для фильтров аналитики (§2.4 плана).
@immutable
class HabitChipColors extends ThemeExtension<HabitChipColors> {
const HabitChipColors({
required this.requiredBg,
required this.requiredFg,
required this.optionalBg,
required this.optionalFg,
required this.unnecessaryBg,
required this.unnecessaryFg,
required this.impulsiveBg,
required this.impulsiveFg,
required this.requiredDot,
required this.optionalDot,
required this.unnecessaryDot,
});
/// «Обязательно» — тёмно-зелёный фон / светло-зелёный текст.
final Color requiredBg;
final Color requiredFg;
/// «Не обязательно» — тёмно-горчичный фон / песочный текст.
final Color optionalBg;
final Color optionalFg;
/// «Не нужно» — тёмно-бордовый фон / бледно-розовый текст.
final Color unnecessaryBg;
final Color unnecessaryFg;
/// «⚡ Импульс» — тёмно-коричневый фон / светло-оранжевый текст.
final Color impulsiveBg;
final Color impulsiveFg;
/// Цветные точки-индикаторы для фильтров обязательности (аналитика).
final Color requiredDot;
final Color optionalDot;
final Color unnecessaryDot;
static const dark = HabitChipColors(
requiredBg: Color(0xFF1E3A24),
requiredFg: Color(0xFF8FD89A),
optionalBg: Color(0xFF3D3318),
optionalFg: Color(0xFFD8C48A),
unnecessaryBg: Color(0xFF3D1E22),
unnecessaryFg: Color(0xFFE8A9A9),
impulsiveBg: Color(0xFF3A2A18),
impulsiveFg: Color(0xFFE8B57A),
requiredDot: Color(0xFF5FA86A),
optionalDot: Color(0xFFC9A24B),
unnecessaryDot: Color(0xFFC46B6B),
);
static const light = HabitChipColors(
requiredBg: Color(0xFFD8E8D5),
requiredFg: Color(0xFF3A6B40),
optionalBg: Color(0xFFECE0C0),
optionalFg: Color(0xFF7A5E20),
unnecessaryBg: Color(0xFFF0D8D8),
unnecessaryFg: Color(0xFF8B3A3A),
impulsiveBg: Color(0xFFF0E0C8),
impulsiveFg: Color(0xFF8A5A20),
requiredDot: Color(0xFF5FA86A),
optionalDot: Color(0xFFC9A24B),
unnecessaryDot: Color(0xFFC46B6B),
);
@override
HabitChipColors copyWith({
Color? requiredBg,
Color? requiredFg,
Color? optionalBg,
Color? optionalFg,
Color? unnecessaryBg,
Color? unnecessaryFg,
Color? impulsiveBg,
Color? impulsiveFg,
Color? requiredDot,
Color? optionalDot,
Color? unnecessaryDot,
}) {
return HabitChipColors(
requiredBg: requiredBg ?? this.requiredBg,
requiredFg: requiredFg ?? this.requiredFg,
optionalBg: optionalBg ?? this.optionalBg,
optionalFg: optionalFg ?? this.optionalFg,
unnecessaryBg: unnecessaryBg ?? this.unnecessaryBg,
unnecessaryFg: unnecessaryFg ?? this.unnecessaryFg,
impulsiveBg: impulsiveBg ?? this.impulsiveBg,
impulsiveFg: impulsiveFg ?? this.impulsiveFg,
requiredDot: requiredDot ?? this.requiredDot,
optionalDot: optionalDot ?? this.optionalDot,
unnecessaryDot: unnecessaryDot ?? this.unnecessaryDot,
);
}
@override
HabitChipColors lerp(ThemeExtension<HabitChipColors>? other, double t) {
if (other is! HabitChipColors) return this;
return HabitChipColors(
requiredBg: Color.lerp(requiredBg, other.requiredBg, t)!,
requiredFg: Color.lerp(requiredFg, other.requiredFg, t)!,
optionalBg: Color.lerp(optionalBg, other.optionalBg, t)!,
optionalFg: Color.lerp(optionalFg, other.optionalFg, t)!,
unnecessaryBg: Color.lerp(unnecessaryBg, other.unnecessaryBg, t)!,
unnecessaryFg: Color.lerp(unnecessaryFg, other.unnecessaryFg, t)!,
impulsiveBg: Color.lerp(impulsiveBg, other.impulsiveBg, t)!,
impulsiveFg: Color.lerp(impulsiveFg, other.impulsiveFg, t)!,
requiredDot: Color.lerp(requiredDot, other.requiredDot, t)!,
optionalDot: Color.lerp(optionalDot, other.optionalDot, t)!,
unnecessaryDot: Color.lerp(unnecessaryDot, other.unnecessaryDot, t)!,
);
}
}
extension HabitChipColorsX on BuildContext {
HabitChipColors get habitColors =>
Theme.of(this).extension<HabitChipColors>()!;
}
+9 -1
View File
@@ -64,7 +64,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 5;
int get schemaVersion => 6;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -108,6 +108,14 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(accountBindingsTable);
await m.createTable(transferPairingBlocklistTable);
}
if (from < 6) {
// v5 → v6: habit-tracking поля.
await m.addColumn(
transactionsTable, transactionsTable.obligation);
await m.addColumn(transactionsTable, transactionsTable.impulse);
await m.addColumn(
settingsTable, settingsTable.habitTrackingEnabled);
}
},
);
@@ -48,6 +48,39 @@ class TransactionTypeConverter extends TypeConverter<TransactionType, String> {
String toSql(TransactionType value) => value.name;
}
// ---------------------------------------------------------------------------
// SpendingObligation (habit-tracking — шкала «обязательность»)
// ---------------------------------------------------------------------------
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;
}
// ---------------------------------------------------------------------------
// SpendingImpulse (habit-tracking — шкала «импульсивность»)
// ---------------------------------------------------------------------------
enum SpendingImpulse { impulsive, considered }
class SpendingImpulseConverter extends TypeConverter<SpendingImpulse, String> {
const SpendingImpulseConverter();
@override
SpendingImpulse fromSql(String fromDb) =>
SpendingImpulse.values.firstWhere((e) => e.name == fromDb);
@override
String toSql(SpendingImpulse value) => value.name;
}
// ---------------------------------------------------------------------------
// ThemeMode
// ---------------------------------------------------------------------------
@@ -21,6 +21,10 @@ class SettingsTable extends Table {
IntColumn get firstDayOfMonth => integer().withDefault(const Constant(1))();
/// Тумблер фичи «анализ привычек трат» (habit-tracking).
BoolColumn get habitTrackingEnabled =>
boolean().withDefault(const Constant(false))();
@override
Set<Column> get primaryKey => {userId};
}
@@ -38,6 +38,16 @@ class TransactionsTable extends Table {
/// Для типа transfer: целевой счёт.
TextColumn get transferToAccountId => text().nullable()();
// --- Поля habit-tracking (добавлены в schema v6) ---
/// Шкала «обязательность» (habit-tracking). null = не отмечено.
TextColumn get obligation =>
text().map(const SpendingObligationConverter()).nullable()();
/// Шкала «импульсивность» (habit-tracking). null = не отмечено.
TextColumn get impulse =>
text().map(const SpendingImpulseConverter()).nullable()();
// --- Поля notification-parsing (добавлены в schema v5) ---
/// FK на raw_messages.id — источник транзакции (null для ручных).
@@ -0,0 +1,100 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../core/database/converters/enum_converters.dart';
import '../../home/presentation/state/selected_category_filter.dart';
import '../../transactions/application/transactions_controller.dart';
import '../../transactions/domain/entities/transaction.dart';
part 'habit_analysis_providers.g.dart';
// ---------------------------------------------------------------------------
// Состояние фильтров (autoDispose)
// ---------------------------------------------------------------------------
/// Фильтр по импульсивности: `null` = «Все».
@riverpod
class HabitImpulseFilter extends _$HabitImpulseFilter {
@override
SpendingImpulse? build() => null;
void select(SpendingImpulse? value) => state = value;
}
/// Фильтр по обязательности (мульти-выбор). Пустое множество = «Все».
@riverpod
class HabitObligationFilter extends _$HabitObligationFilter {
@override
Set<SpendingObligation> build() => const {};
void toggle(SpendingObligation value) {
final next = Set<SpendingObligation>.from(state);
if (!next.add(value)) next.remove(value);
state = next;
}
void clear() => state = const {};
}
// ---------------------------------------------------------------------------
// Агрегаты
// ---------------------------------------------------------------------------
/// Расходные транзакции за выбранный месяц (без учёта фильтров привычек).
@riverpod
List<Transaction> habitMonthTransactions(Ref ref, String userId) {
final month = ref.watch(selectedMonthProvider);
final monthStart = DateTime(month.year, month.month);
final monthEnd = DateTime(month.year, month.month + 1)
.subtract(const Duration(milliseconds: 1));
final all = ref
.watch(transactionsStreamProvider(
userId,
from: monthStart,
to: monthEnd,
type: TransactionType.expense,
))
.value ??
const <Transaction>[];
return all;
}
/// Суммы расходов по импульсивности (включая ключ `null` = «без оценки»).
@riverpod
Map<SpendingImpulse?, int> habitSumByImpulse(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId));
final map = <SpendingImpulse?, int>{};
for (final t in txs) {
map[t.impulse] = (map[t.impulse] ?? 0) + t.amount;
}
return map;
}
/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»).
@riverpod
Map<SpendingObligation?, int> habitSumByObligation(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId));
final map = <SpendingObligation?, int>{};
for (final t in txs) {
map[t.obligation] = (map[t.obligation] ?? 0) + t.amount;
}
return map;
}
/// Транзакции с применёнными обоими фильтрами (для сводки и списка).
@riverpod
List<Transaction> habitFilteredTransactions(Ref ref, String userId) {
final txs = ref.watch(habitMonthTransactionsProvider(userId));
final impulse = ref.watch(habitImpulseFilterProvider);
final obligations = ref.watch(habitObligationFilterProvider);
return txs.where((t) {
if (impulse != null && t.impulse != impulse) return false;
if (obligations.isNotEmpty &&
(t.obligation == null || !obligations.contains(t.obligation))) {
return false;
}
return true;
}).toList();
}
@@ -1,17 +1,88 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../shared/widgets/placeholder_screen.dart';
import '../../../settings/application/settings_controller.dart';
import '../../../user/application/active_user_controller.dart';
class AnalyticsScreen extends StatelessWidget {
/// Хаб аналитики — список подэкранов. Пока единственный пункт —
/// «Анализ привычек» (показывается только при включённом тумблере).
class AnalyticsScreen extends ConsumerWidget {
const AnalyticsScreen({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final userId = ref.watch(activeUserControllerProvider).value?.id;
final habitEnabled = userId != null
? (ref.watch(settingsControllerProvider(userId)).value
?.habitTrackingEnabled ??
false)
: false;
return PlaceholderScreen(
subtitle: l10n.analyticsSubtitle,
title: l10n.analyticsTitle,
body: !habitEnabled
? null
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: p.line),
borderRadius: BorderRadius.circular(14),
),
child: _HubTile(
icon: Icons.insights_outlined,
title: l10n.habitAnalysisTile,
onTap: () => context.push(AppRoutes.habitAnalysis),
),
),
],
),
);
}
}
class _HubTile extends StatelessWidget {
const _HubTile({
required this.icon,
required this.title,
required this.onTap,
});
final IconData icon;
final String title;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16),
child: Row(
children: [
Icon(icon, size: 20, color: p.ink2),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(fontSize: 14, color: p.ink),
),
),
Icon(Icons.chevron_right, size: 18, color: p.ink2),
],
),
),
);
}
}
@@ -0,0 +1,533 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/habit_chip_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../accounts/application/accounts_controller.dart';
import '../../../accounts/domain/entities/account.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../home/presentation/state/selected_category_filter.dart';
import '../../../home/presentation/widgets/day_header.dart';
import '../../../home/presentation/widgets/money_text.dart';
import '../../../home/presentation/widgets/tx_row.dart';
import '../../../transactions/domain/entities/transaction.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/habit_analysis_providers.dart';
/// Подэкран «Анализ привычек»: фильтры по двум шкалам + список расходов за месяц.
class HabitAnalysisScreen extends ConsumerWidget {
const HabitAnalysisScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final userId = ref.watch(activeUserControllerProvider).value?.id;
return Scaffold(
backgroundColor: p.paper,
body: userId == null
? const SizedBox.shrink()
: SafeArea(child: _Body(userId: userId)),
);
}
}
class _Body extends ConsumerWidget {
const _Body({required this.userId});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = Localizations.localeOf(context).toString();
final l10n = context.l10n;
final categories = ref.watch(categoriesStreamProvider(userId)).value ??
const <Category>[];
final categoryById = {for (final c in categories) c.id: c};
final accounts =
ref.watch(accountsStreamProvider(userId)).value ?? const <Account>[];
final accountById = {for (final a in accounts) a.id: a};
final filtered = ref.watch(habitFilteredTransactionsProvider(userId));
final groups = _groupByDay(filtered, locale, l10n);
return CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _HeaderBar(userId: userId)),
SliverToBoxAdapter(child: _FilterBlock(userId: userId)),
SliverToBoxAdapter(child: _SummaryRow(userId: userId)),
const SliverToBoxAdapter(child: SizedBox(height: 4)),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) {
final g = groups[i];
return Column(
children: [
DayHeader(label: g.label, totalMinor: g.totalSpentMinor),
for (final t in g.items)
TxRow(
tx: t,
category: categoryById[t.categoryId],
accountById: accountById,
habitTrackingEnabled: true,
),
],
);
},
childCount: groups.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Шапка: крупный заголовок + селектор месяца
// ─────────────────────────────────────────────────────────────────────────────
class _HeaderBar extends ConsumerWidget {
const _HeaderBar({required this.userId});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final locale = Localizations.localeOf(context).toString();
final month = ref.watch(selectedMonthProvider);
final now = DateTime.now();
final isCurrentMonth = month.year == now.year && month.month == now.month;
final monthName = DateFormat.MMMM(locale).format(month);
final monthCap = '${monthName[0].toUpperCase()}${monthName.substring(1)}';
final monthTitle = '$monthCap ${month.year}';
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
l10n.habitAnalysisTitle,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: p.ink,
),
),
),
_NavButton(
icon: Icons.chevron_left,
color: p.ink2,
onTap: () => ref.read(selectedMonthProvider.notifier).previous(),
),
Text(
monthTitle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: p.ink2,
),
),
if (!isCurrentMonth)
_NavButton(
icon: Icons.chevron_right,
color: p.ink2,
onTap: () => ref.read(selectedMonthProvider.notifier).next(),
)
else
const SizedBox(width: 24),
],
),
);
}
}
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.color,
required this.onTap,
});
final IconData icon;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(icon, size: 20, color: color),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Блок фильтров с суммами
// ─────────────────────────────────────────────────────────────────────────────
class _FilterBlock extends ConsumerWidget {
const _FilterBlock({required this.userId});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final hc = context.habitColors;
final impulseSums = ref.watch(habitSumByImpulseProvider(userId));
final obligationSums = ref.watch(habitSumByObligationProvider(userId));
final impulseFilter = ref.watch(habitImpulseFilterProvider);
final obligationFilter = ref.watch(habitObligationFilterProvider);
final totalImpulse =
impulseSums.values.fold<int>(0, (s, v) => s + v);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ScaleCaption(l10n.habitScaleImpulseCaps),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: _ImpulseSegment(
title: l10n.habitFilterAll,
sumMinor: totalImpulse,
selected: impulseFilter == null,
onTap: () =>
ref.read(habitImpulseFilterProvider.notifier).select(null),
),
),
Expanded(
child: _ImpulseSegment(
title: l10n.habitImpulseConsidered,
sumMinor: impulseSums[SpendingImpulse.considered] ?? 0,
selected: impulseFilter == SpendingImpulse.considered,
onTap: () => ref
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.considered),
),
),
Expanded(
child: _ImpulseSegment(
title: l10n.habitImpulseImpulsive,
icon: Icons.bolt,
sumMinor: impulseSums[SpendingImpulse.impulsive] ?? 0,
selected: impulseFilter == SpendingImpulse.impulsive,
onTap: () => ref
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive),
),
),
],
),
const SizedBox(height: 16),
_ScaleCaption(l10n.habitScaleObligationCaps),
const SizedBox(height: 6),
Row(
children: [
for (final o in SpendingObligation.values) ...[
Expanded(
child: _ObligationChip(
title: _obligationFilterLabel(l10n, o),
sumMinor: obligationSums[o] ?? 0,
dotColor: _dotColor(hc, o),
selected: obligationFilter.contains(o),
onTap: () => ref
.read(habitObligationFilterProvider.notifier)
.toggle(o),
),
),
if (o != SpendingObligation.values.last)
const SizedBox(width: 8),
],
],
),
const SizedBox(height: 4),
Divider(height: 1, color: p.line),
],
),
);
}
}
class _ScaleCaption extends StatelessWidget {
const _ScaleCaption(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: context.palette.ink2,
),
);
}
}
class _ImpulseSegment extends StatelessWidget {
const _ImpulseSegment({
required this.title,
required this.sumMinor,
required this.selected,
required this.onTap,
this.icon,
});
final String title;
final int sumMinor;
final bool selected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final p = context.palette;
final fg = selected ? p.paper : p.ink;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: selected ? p.ink2 : null,
border: Border.all(color: selected ? p.ink2 : p.line),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 2),
],
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: fg,
),
),
),
],
),
const SizedBox(height: 2),
MoneyText(
-sumMinor,
color: selected ? p.paper : p.ink2,
fontSize: 11,
withSign: true,
),
],
),
),
),
);
}
}
class _ObligationChip extends StatelessWidget {
const _ObligationChip({
required this.title,
required this.sumMinor,
required this.dotColor,
required this.selected,
required this.onTap,
});
final String title;
final int sumMinor;
final Color dotColor;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
decoration: BoxDecoration(
color: selected ? p.accentSoft : null,
border: Border.all(
color: selected ? p.accent : p.line,
width: selected ? 1.5 : 1,
),
borderRadius: BorderRadius.circular(10),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration:
BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Flexible(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? p.accent : p.ink,
),
),
),
],
),
const SizedBox(height: 2),
MoneyText(
-sumMinor,
color: p.ink2,
fontSize: 11,
withSign: true,
),
],
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Сводная строка
// ─────────────────────────────────────────────────────────────────────────────
class _SummaryRow extends ConsumerWidget {
const _SummaryRow({required this.userId});
final String userId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final p = context.palette;
final l10n = context.l10n;
final filtered = ref.watch(habitFilteredTransactionsProvider(userId));
final total = filtered.fold<int>(0, (s, t) => s + t.amount);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 6),
child: Row(
children: [
Text(
l10n.transactionCount(filtered.length),
style: TextStyle(fontSize: 13, color: p.ink2),
),
const Spacer(),
MoneyText(
-total,
color: p.ink,
fontSize: 22,
fontWeight: FontWeight.w700,
withSign: true,
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Группировка по дням (как на главном экране)
// ─────────────────────────────────────────────────────────────────────────────
String _obligationFilterLabel(AppLocalizations l10n, SpendingObligation o) =>
switch (o) {
SpendingObligation.required => l10n.habitObligationRequired,
SpendingObligation.optional => l10n.habitObligationOptionalShort,
SpendingObligation.unnecessary => l10n.habitObligationUnnecessary,
};
Color _dotColor(HabitChipColors hc, SpendingObligation o) => switch (o) {
SpendingObligation.required => hc.requiredDot,
SpendingObligation.optional => hc.optionalDot,
SpendingObligation.unnecessary => hc.unnecessaryDot,
};
class _DayGroup {
_DayGroup({
required this.label,
required this.items,
required this.totalSpentMinor,
});
final String label;
final List<Transaction> items;
final int totalSpentMinor;
}
List<_DayGroup> _groupByDay(
List<Transaction> txs,
String locale,
AppLocalizations l10n,
) {
final sorted = [...txs]..sort((a, b) => b.date.compareTo(a.date));
final map = <DateTime, List<Transaction>>{};
for (final t in sorted) {
final key = DateTime(t.date.year, t.date.month, t.date.day);
map.putIfAbsent(key, () => []).add(t);
}
final keys = map.keys.toList()..sort((a, b) => b.compareTo(a));
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final fmt = DateFormat('d MMMM', locale);
return [
for (final k in keys)
_DayGroup(
label: _dayLabel(k, today, fmt, l10n),
items: map[k]!,
totalSpentMinor: map[k]!.fold<int>(0, (s, t) => s + t.amount),
),
];
}
String _dayLabel(
DateTime day,
DateTime today,
DateFormat fmt,
AppLocalizations l10n,
) {
final diff = today.difference(day).inDays;
final base = fmt.format(day);
if (diff == 0) return l10n.todayWithDate(base);
if (diff == 1) return l10n.yesterdayWithDate(base);
return base;
}
@@ -12,6 +12,7 @@ import '../../../accounts/domain/entities/account.dart';
import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../notification_parsing/application/parsing_worker.dart';
import '../../../settings/application/settings_controller.dart';
import '../../../transactions/domain/entities/transaction.dart';
import '../../../user/application/active_user_controller.dart';
import '../month_summary.dart';
@@ -70,6 +71,11 @@ class _HomeContent extends ConsumerWidget {
final accountById = {for (final a in accounts) a.id: a};
final txs = ref.watch(filteredTransactionsProvider(userId));
final groups = _groupByDay(txs, locale, l10n);
final habitEnabled = ref
.watch(settingsControllerProvider(userId))
.value
?.habitTrackingEnabled ??
false;
return Stack(
children: [
@@ -91,6 +97,7 @@ class _HomeContent extends ConsumerWidget {
group: groups[i],
categoryById: categoryById,
accountById: accountById,
habitTrackingEnabled: habitEnabled,
),
childCount: groups.length,
),
@@ -116,11 +123,13 @@ class _DayGroupBlock extends StatelessWidget {
required this.group,
required this.categoryById,
required this.accountById,
required this.habitTrackingEnabled,
});
final _DayGroup group;
final Map<String, Category> categoryById;
final Map<String, Account> accountById;
final bool habitTrackingEnabled;
@override
Widget build(BuildContext context) {
@@ -132,6 +141,7 @@ class _DayGroupBlock extends StatelessWidget {
tx: t,
category: categoryById[t.categoryId],
accountById: accountById,
habitTrackingEnabled: habitTrackingEnabled,
),
],
);
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/habit_chip_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
import '../../../transactions/domain/entities/transaction.dart';
/// Ярлыки «привычек трат» в строке транзакции.
///
/// Порядок: ярлык обязательности (если задан) → «⚡ Импульс» (только при
/// [SpendingImpulse.impulsive]). Если обе шкалы не отмечены — курсивное
/// «не отмечено» без фона.
///
/// «Обдуманно» ([SpendingImpulse.considered]) ярлыком не показывается.
class HabitChips extends StatelessWidget {
const HabitChips({super.key, required this.tx});
final Transaction tx;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final hc = context.habitColors;
final obligation = tx.obligation;
final showImpulse = tx.impulse == SpendingImpulse.impulsive;
if (obligation == null && !showImpulse) {
return Text(
l10n.habitNotMarked,
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: context.palette.ink2,
),
);
}
final chips = <Widget>[];
if (obligation != null) {
final (bg, fg) = _obligationColors(hc, obligation);
chips.add(_Pill(
background: bg,
foreground: fg,
label: _obligationLabel(l10n, obligation),
));
}
if (showImpulse) {
if (chips.isNotEmpty) chips.add(const SizedBox(width: 6));
chips.add(_Pill(
background: hc.impulsiveBg,
foreground: hc.impulsiveFg,
label: l10n.habitImpulseImpulsive,
icon: Icons.bolt,
));
}
return Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: chips,
);
}
}
(Color, Color) _obligationColors(HabitChipColors hc, SpendingObligation o) =>
switch (o) {
SpendingObligation.required => (hc.requiredBg, hc.requiredFg),
SpendingObligation.optional => (hc.optionalBg, hc.optionalFg),
SpendingObligation.unnecessary => (hc.unnecessaryBg, hc.unnecessaryFg),
};
String _obligationLabel(AppLocalizations l10n, SpendingObligation o) =>
switch (o) {
SpendingObligation.required => l10n.habitObligationRequired,
SpendingObligation.optional => l10n.habitObligationOptional,
SpendingObligation.unnecessary => l10n.habitObligationUnnecessary,
};
class _Pill extends StatelessWidget {
const _Pill({
required this.background,
required this.foreground,
required this.label,
this.icon,
});
final Color background;
final Color foreground;
final String label;
final IconData? icon;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(99),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 11, color: foreground),
const SizedBox(width: 2),
],
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: foreground,
),
),
],
),
);
}
}
@@ -10,6 +10,7 @@ import '../../../accounts/domain/entities/account.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
import '../../../transactions/domain/entities/transaction.dart';
import 'habit_chips.dart';
import 'money_text.dart';
class TxRow extends StatelessWidget {
@@ -18,12 +19,16 @@ class TxRow extends StatelessWidget {
required this.tx,
required this.category,
required this.accountById,
this.habitTrackingEnabled = false,
});
final Transaction tx;
final Category? category;
final Map<String, Account> accountById;
/// Когда true — вместо строки `extraInfo` показываются ярлыки привычек.
final bool habitTrackingEnabled;
@override
Widget build(BuildContext context) {
final p = context.palette;
@@ -95,7 +100,11 @@ class TxRow extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (tx.extraInfo != null && tx.extraInfo!.isNotEmpty) ...[
if (habitTrackingEnabled) ...[
const SizedBox(height: 3),
HabitChips(tx: tx),
] else if (tx.extraInfo != null &&
tx.extraInfo!.isNotEmpty) ...[
const SizedBox(height: 1),
Text(
tx.extraInfo!,
@@ -7,17 +7,48 @@ import '../../../../app/router/app_routes.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/theme_mode_controller.dart';
import '../../../../shared/widgets/placeholder_screen.dart';
import '../../../settings/application/settings_controller.dart';
import '../../../user/application/active_user_controller.dart';
import '../../../user/application/user_seeder.dart';
class ProfileScreen extends ConsumerWidget {
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
bool _seedingDemo = false;
Future<void> _seedDemo(String userId) async {
if (_seedingDemo) return;
setState(() => _seedingDemo = true);
try {
await ref.read(userSeederProvider).seedDemoTransactionsForUser(userId);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.profileSeedDemoSuccess)),
);
}
} finally {
if (mounted) setState(() => _seedingDemo = false);
}
}
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final mode = ref.watch(themeModeControllerProvider);
final controller = ref.read(themeModeControllerProvider.notifier);
final locale = ref.watch(appLocaleControllerProvider);
final userId = ref.watch(activeUserControllerProvider).value?.id;
final habitEnabled = userId != null
? (ref.watch(settingsControllerProvider(userId)).value
?.habitTrackingEnabled ??
false)
: false;
return PlaceholderScreen(
subtitle: l10n.profileSubtitle,
@@ -43,6 +74,20 @@ class ProfileScreen extends ConsumerWidget {
),
),
Container(height: 1, color: p.line),
_Row(
icon: Icons.insights_outlined,
title: l10n.habitTrackingTile,
trailing: Switch(
value: habitEnabled,
activeThumbColor: p.accent,
onChanged: userId == null
? null
: (v) => ref
.read(settingsControllerProvider(userId).notifier)
.setHabitTrackingEnabled(v),
),
),
Container(height: 1, color: p.line),
_NavRow(
icon: Icons.language_outlined,
title: l10n.profileLanguageTile,
@@ -71,6 +116,20 @@ class ProfileScreen extends ConsumerWidget {
),
),
const SizedBox(height: 12),
Container(
decoration: BoxDecoration(
border: Border.all(color: p.line),
borderRadius: BorderRadius.circular(14),
),
child: _NavRow(
icon: Icons.auto_fix_high_outlined,
title: l10n.profileSeedDemoTile,
loading: _seedingDemo,
onTap: userId == null ? () {} : () => _seedDemo(userId),
enabled: userId != null && !_seedingDemo,
),
),
const SizedBox(height: 12),
Text(
l10n.profileHint,
style: TextStyle(fontSize: 12, color: p.ink2),
@@ -116,35 +175,51 @@ class _NavRow extends StatelessWidget {
required this.title,
required this.onTap,
this.subtitle,
this.loading = false,
this.enabled = true,
});
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback onTap;
final bool loading;
final bool enabled;
@override
Widget build(BuildContext context) {
final p = context.palette;
final textColor = enabled ? p.ink : p.ink2;
final iconColor = enabled ? p.ink2 : p.line;
return InkWell(
onTap: onTap,
onTap: enabled ? onTap : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
child: Row(
children: [
Icon(icon, size: 20, color: p.ink2),
Icon(icon, size: 20, color: iconColor),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(fontSize: 14, color: p.ink),
style: TextStyle(fontSize: 14, color: textColor),
),
),
if (subtitle != null) ...[
Text(subtitle!, style: TextStyle(fontSize: 13, color: p.ink2)),
const SizedBox(width: 4),
],
Icon(Icons.chevron_right, size: 18, color: p.ink2),
if (loading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: p.accent,
),
)
else
Icon(Icons.chevron_right, size: 18, color: iconColor),
],
),
),
@@ -53,6 +53,10 @@ class SettingsController extends _$SettingsController {
(repo) => repo.updateFirstDayOfMonth(userId, day),
);
Future<void> setHabitTrackingEnabled(bool enabled) => _update(
(repo) => repo.updateHabitTrackingEnabled(userId, enabled),
);
/// Полная перезапись настроек.
Future<void> saveSettings(Settings settings) => _update(
(repo) => repo.upsertSettings(settings),
@@ -9,5 +9,6 @@ extension SettingsMapper on SettingsTableData {
themeMode: themeMode, // уже AppThemeMode — конвертер в Drift-таблице
locale: locale,
firstDayOfMonth: firstDayOfMonth,
habitTrackingEnabled: habitTrackingEnabled,
);
}
@@ -42,6 +42,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
themeMode: defaults.themeMode,
locale: defaults.locale,
firstDayOfMonth: defaults.firstDayOfMonth,
habitTrackingEnabled: defaults.habitTrackingEnabled,
);
await upsertSettings(initial);
return initial;
@@ -74,6 +75,12 @@ class SettingsRepositoryImpl implements SettingsRepository {
await upsertSettings(current.copyWith(firstDayOfMonth: day));
}
@override
Future<void> updateHabitTrackingEnabled(String userId, bool enabled) async {
final current = await _requireSettings(userId);
await upsertSettings(current.copyWith(habitTrackingEnabled: enabled));
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Возвращает текущие настройки, создавая дефолтные при отсутствии.
@@ -87,6 +94,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
themeMode: Value(s.themeMode),
locale: Value(s.locale),
firstDayOfMonth: Value(s.firstDayOfMonth),
habitTrackingEnabled: Value(s.habitTrackingEnabled),
);
}
@@ -96,4 +104,5 @@ const _defaultSettings = (
themeMode: AppThemeMode.system,
locale: 'ru',
firstDayOfMonth: 1,
habitTrackingEnabled: false,
);
@@ -22,5 +22,8 @@ abstract class Settings with _$Settings {
/// Первый день рабочей недели/месячного периода (1 = понедельник/1-е число).
required int firstDayOfMonth,
/// Включён ли «анализ привычек трат» (habit-tracking).
@Default(false) bool habitTrackingEnabled,
}) = _Settings;
}
@@ -30,4 +30,7 @@ abstract interface class SettingsRepository {
/// Изменить первый день месяца/недели (1–28).
Future<void> updateFirstDayOfMonth(String userId, int day);
/// Включить/выключить «анализ привычек трат».
Future<void> updateHabitTrackingEnabled(String userId, bool enabled);
}
@@ -66,6 +66,8 @@ class TransactionsController extends _$TransactionsController {
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
@@ -82,6 +84,8 @@ class TransactionsController extends _$TransactionsController {
merchant: merchant,
extraInfo: extraInfo,
transferToAccountId: transferToAccountId,
obligation: obligation,
impulse: impulse,
rawMessageId: rawMessageId,
autoApplied: autoApplied,
appliedByRuleId: appliedByRuleId,
@@ -14,6 +14,8 @@ extension TransactionMapper on TransactionsTableData {
merchant: merchant,
extraInfo: extraInfo,
transferToAccountId: transferToAccountId,
obligation: obligation,
impulse: impulse,
rawMessageId: rawMessageId,
autoApplied: autoApplied,
appliedByRuleId: appliedByRuleId,
@@ -50,6 +50,8 @@ class TransactionRepositoryImpl implements TransactionRepository {
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
@@ -67,6 +69,8 @@ class TransactionRepositoryImpl implements TransactionRepository {
merchant: Value(merchant),
extraInfo: Value(extraInfo),
transferToAccountId: Value(transferToAccountId),
obligation: Value(obligation),
impulse: Value(impulse),
rawMessageId: Value(rawMessageId),
autoApplied: Value(autoApplied),
appliedByRuleId: Value(appliedByRuleId),
@@ -90,6 +94,8 @@ class TransactionRepositoryImpl implements TransactionRepository {
merchant: Value(transaction.merchant),
extraInfo: Value(transaction.extraInfo),
transferToAccountId: Value(transaction.transferToAccountId),
obligation: Value(transaction.obligation),
impulse: Value(transaction.impulse),
rawMessageId: Value(transaction.rawMessageId),
autoApplied: Value(transaction.autoApplied),
appliedByRuleId: Value(transaction.appliedByRuleId),
@@ -36,6 +36,12 @@ abstract class Transaction with _$Transaction {
/// Целевой счёт для переводов ([TransactionType.transfer]).
String? transferToAccountId,
/// Шкала «обязательность» (habit-tracking). null = не отмечено.
SpendingObligation? obligation,
/// Шкала «импульсивность» (habit-tracking). null = не отмечено.
SpendingImpulse? impulse,
/// FK на raw_messages.id — источник, из которого появилась транзакция.
String? rawMessageId,
@@ -34,6 +34,12 @@ abstract interface class TransactionRepository {
/// Целевой счёт для [TransactionType.transfer].
String? transferToAccountId,
/// Шкала «обязательность» (habit-tracking).
SpendingObligation? obligation,
/// Шкала «импульсивность» (habit-tracking).
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied,
String? appliedByRuleId,
@@ -12,6 +12,7 @@ import '../../../categories/application/categories_controller.dart';
import '../../../categories/domain/entities/category.dart';
import '../../../categories/presentation/widgets/category_icon.dart';
import '../../../home/presentation/widgets/money_text.dart';
import '../../../settings/application/settings_controller.dart';
import '../../../user/application/active_user_controller.dart';
import '../../application/transaction_providers.dart';
import '../../application/transactions_controller.dart';
@@ -22,6 +23,7 @@ import '../widgets/amount_input.dart';
import '../widgets/balance_preview.dart';
import '../widgets/category_picker_sheet.dart';
import '../widgets/date_time_field.dart';
import '../widgets/habit_selectors.dart';
import '../widgets/transfer_account_row.dart';
import '../widgets/type_segmented.dart';
@@ -94,6 +96,8 @@ class _EditHydratorState extends ConsumerState<_EditHydrator> {
transferToAccountId: widget.tx.transferToAccountId,
merchant: widget.tx.merchant,
extraInfo: widget.tx.extraInfo,
obligation: widget.tx.obligation,
impulse: widget.tx.impulse,
));
});
}
@@ -203,6 +207,10 @@ class _FormBodyState extends ConsumerState<_FormBody> {
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
: null,
obligation:
draft.type == TransactionType.expense ? draft.obligation : null,
impulse:
draft.type == TransactionType.expense ? draft.impulse : null,
);
} else {
final existing = await ref
@@ -221,6 +229,10 @@ class _FormBodyState extends ConsumerState<_FormBody> {
transferToAccountId: draft.type == TransactionType.transfer
? draft.transferToAccountId
: null,
obligation:
draft.type == TransactionType.expense ? draft.obligation : null,
impulse:
draft.type == TransactionType.expense ? draft.impulse : null,
));
}
if (!mounted) return;
@@ -317,6 +329,13 @@ class _FormBodyState extends ConsumerState<_FormBody> {
final accountById = {for (final a in accounts) a.id: a};
final isEdit = widget.txId != null;
final habitEnabled = ref
.watch(settingsControllerProvider(widget.userId))
.value
?.habitTrackingEnabled ??
false;
final showHabits =
habitEnabled && draft.type == TransactionType.expense;
return SafeArea(
child: Column(
@@ -450,6 +469,20 @@ class _FormBodyState extends ConsumerState<_FormBody> {
),
),
),
if (showHabits) ...[
const SizedBox(height: 16),
_Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: HabitSelectors(
obligation: draft.obligation,
impulse: draft.impulse,
onObligationChanged: draftCtrl.setObligation,
onImpulseChanged: draftCtrl.setImpulse,
),
),
),
],
const SizedBox(height: 24),
_SubmitButton(
label: _submitLabel(l10n, draft, isEdit),
@@ -20,6 +20,8 @@ class TransactionDraft {
this.transferToAccountId,
this.merchant,
this.extraInfo,
this.obligation,
this.impulse,
});
final TransactionType type;
@@ -30,6 +32,8 @@ class TransactionDraft {
final String? transferToAccountId;
final String? merchant;
final String? extraInfo;
final SpendingObligation? obligation;
final SpendingImpulse? impulse;
TransactionDraft copyWith({
TransactionType? type,
@@ -40,6 +44,8 @@ class TransactionDraft {
Object? transferToAccountId = _sentinel,
Object? merchant = _sentinel,
Object? extraInfo = _sentinel,
Object? obligation = _sentinel,
Object? impulse = _sentinel,
}) {
return TransactionDraft(
type: type ?? this.type,
@@ -55,6 +61,11 @@ class TransactionDraft {
: transferToAccountId as String?,
merchant: identical(merchant, _sentinel) ? this.merchant : merchant as String?,
extraInfo: identical(extraInfo, _sentinel) ? this.extraInfo : extraInfo as String?,
obligation: identical(obligation, _sentinel)
? this.obligation
: obligation as SpendingObligation?,
impulse:
identical(impulse, _sentinel) ? this.impulse : impulse as SpendingImpulse?,
);
}
@@ -98,5 +109,16 @@ class TransactionDraftController extends _$TransactionDraftController {
void setExtraInfo(String? value) => state = state.copyWith(extraInfo: value);
void setObligation(SpendingObligation? value) {
var next = state.copyWith(obligation: value);
if (value == SpendingObligation.required) {
next = next.copyWith(impulse: null);
}
state = next;
}
void setImpulse(SpendingImpulse? value) =>
state = state.copyWith(impulse: value);
void hydrate(TransactionDraft draft) => state = draft;
}
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import '../../../../app/l10n/l10n.dart';
import '../../../../app/theme/app_colors.dart';
import '../../../../app/theme/habit_chip_colors.dart';
import '../../../../core/database/converters/enum_converters.dart';
/// Селекторы шкал «привычек трат» в форме транзакции.
///
/// Обязательность — три chip'а (повторное нажатие сбрасывает в null).
/// Импульсивность — segmented на два значения (также сбрасываемый).
class HabitSelectors extends StatelessWidget {
const HabitSelectors({
super.key,
required this.obligation,
required this.impulse,
required this.onObligationChanged,
required this.onImpulseChanged,
});
final SpendingObligation? obligation;
final SpendingImpulse? impulse;
final ValueChanged<SpendingObligation?> onObligationChanged;
final ValueChanged<SpendingImpulse?> onImpulseChanged;
@override
Widget build(BuildContext context) {
final p = context.palette;
final l10n = context.l10n;
final hc = context.habitColors;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.habitScaleObligationCaps,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: p.ink2,
),
),
const SizedBox(height: 8),
Row(
children: [
for (final o in SpendingObligation.values) ...[
Expanded(
child: _ObligationChip(
label: _obligationLabel(l10n, o),
dotColor: _dotColor(hc, o),
selected: obligation == o,
onTap: () =>
onObligationChanged(obligation == o ? null : o),
),
),
if (o != SpendingObligation.values.last)
const SizedBox(width: 8),
],
],
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: obligation == SpendingObligation.required
? const SizedBox.shrink()
: Column(
key: const ValueKey('impulse'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
Text(
l10n.habitScaleImpulseCaps,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: p.ink2,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: p.paper2,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Expanded(
child: _ImpulseSegment(
label: l10n.habitImpulseConsidered,
selected: impulse == SpendingImpulse.considered,
onTap: () => onImpulseChanged(
impulse == SpendingImpulse.considered
? null
: SpendingImpulse.considered),
),
),
Expanded(
child: _ImpulseSegment(
label: l10n.habitImpulseImpulsive,
icon: Icons.bolt,
selected: impulse == SpendingImpulse.impulsive,
onTap: () => onImpulseChanged(
impulse == SpendingImpulse.impulsive
? null
: SpendingImpulse.impulsive),
),
),
],
),
),
],
),
),
],
);
}
}
Color _dotColor(HabitChipColors hc, SpendingObligation o) => switch (o) {
SpendingObligation.required => hc.requiredDot,
SpendingObligation.optional => hc.optionalDot,
SpendingObligation.unnecessary => hc.unnecessaryDot,
};
String _obligationLabel(AppLocalizations l10n, SpendingObligation o) =>
switch (o) {
SpendingObligation.required => l10n.habitObligationRequired,
SpendingObligation.optional => l10n.habitObligationOptional,
SpendingObligation.unnecessary => l10n.habitObligationUnnecessary,
};
class _ObligationChip extends StatelessWidget {
const _ObligationChip({
required this.label,
required this.dotColor,
required this.selected,
required this.onTap,
});
final String label;
final Color dotColor;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final p = context.palette;
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: selected ? p.accentSoft : null,
border: Border.all(
color: selected ? p.accent : p.line,
width: selected ? 1.5 : 1,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? p.accent : p.ink,
),
),
),
],
),
),
);
}
}
class _ImpulseSegment extends StatelessWidget {
const _ImpulseSegment({
required this.label,
required this.selected,
required this.onTap,
this.icon,
});
final String label;
final bool selected;
final VoidCallback onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final p = context.palette;
return Material(
color: selected ? p.paper : Colors.transparent,
borderRadius: BorderRadius.circular(10),
elevation: selected ? 1 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(
icon,
size: 14,
color: selected ? p.ink : p.ink2,
),
const SizedBox(width: 4),
],
Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? p.ink : p.ink2,
),
),
],
),
),
),
);
}
}
@@ -22,8 +22,6 @@ UserSeeder userSeeder(Ref ref) => UserSeeder(
/// Засевает базовый набор данных при создании нового пользователя:
/// несколько счетов и категорий + демонстрационные транзакции.
///
/// Демо-транзакции временные — удалить, когда появится UI создания транзакций.
class UserSeeder {
UserSeeder({
required this.accountRepo,
@@ -41,6 +39,76 @@ class UserSeeder {
await _seedDemoTransactions(userId, accounts, categories);
}
/// Добавляет демо-транзакции для существующего пользователя.
/// Использует имеющиеся счета/категории; создаёт недостающие.
Future<void> seedDemoTransactionsForUser(String userId) async {
final existingAccounts = await accountRepo.watchByUser(userId).first;
final existingCategories = await categoryRepo.watchByUser(userId).first;
Account? card = existingAccounts
.where((a) => a.type == AccountType.card && !a.archived)
.firstOrNull;
Account? cash = existingAccounts
.where((a) => a.type == AccountType.cash && !a.archived)
.firstOrNull;
Account? savings = existingAccounts
.where((a) => a.type == AccountType.savings && !a.archived)
.firstOrNull;
if (card == null || cash == null || savings == null) {
final seeded = await _seedAccounts(userId);
card ??= seeded.card;
cash ??= seeded.cash;
savings ??= seeded.savings;
}
Category? findCat(List<Category> cats, String name, CategoryType type) =>
cats
.where((c) => c.name == name && c.type == type && !c.archived)
.firstOrNull;
Category? food =
findCat(existingCategories, 'Продукты', CategoryType.expense);
Category? rent =
findCat(existingCategories, 'Жильё', CategoryType.expense);
Category? transport =
findCat(existingCategories, 'Транспорт', CategoryType.expense);
Category? cafe =
findCat(existingCategories, 'Кафе', CategoryType.expense);
Category? entertainment =
findCat(existingCategories, 'Досуг', CategoryType.expense);
Category? salary =
findCat(existingCategories, 'Зарплата', CategoryType.income);
if (food == null ||
rent == null ||
transport == null ||
cafe == null ||
entertainment == null ||
salary == null) {
final seeded = await _seedCategories(userId);
food ??= seeded.food;
rent ??= seeded.rent;
transport ??= seeded.transport;
cafe ??= seeded.cafe;
entertainment ??= seeded.entertainment;
salary ??= seeded.salary;
}
await _seedDemoTransactions(
userId,
_SeededAccounts(card: card, cash: cash, savings: savings),
_SeededCategories(
food: food,
rent: rent,
transport: transport,
cafe: cafe,
entertainment: entertainment,
salary: salary,
),
);
}
Future<_SeededAccounts> _seedAccounts(String userId) async {
final card = await accountRepo.create(
userId: userId,
@@ -122,7 +190,6 @@ class UserSeeder {
);
}
// ── DEMO: удалить, когда появится экран добавления транзакций ──────────────
Future<void> _seedDemoTransactions(
String userId,
_SeededAccounts a,
@@ -149,7 +216,8 @@ class UserSeeder {
amount: 234000,
date: atToday(19, 42),
merchant: 'Лента',
extraInfo: 'Чек №481523'),
extraInfo: 'Чек №481523',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
categoryId: c.cafe.id,
@@ -157,14 +225,17 @@ class UserSeeder {
amount: 48000,
date: atToday(9, 15),
merchant: 'Кофе Хауз',
extraInfo: 'Двойной эспрессо + круассан'),
extraInfo: 'Двойной эспрессо + круассан',
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.impulsive),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 6200,
date: atToday(8, 50),
merchant: 'Метро'),
merchant: 'Метро',
obligation: SpendingObligation.required),
_Demo(
accountId: a.cash.id,
categoryId: c.food.id,
@@ -172,7 +243,8 @@ class UserSeeder {
amount: 112000,
date: atYesterday(21, 8),
merchant: 'Перекрёсток',
extraInfo: 'Чек №209847'),
extraInfo: 'Чек №209847',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
categoryId: c.entertainment.id,
@@ -180,7 +252,9 @@ class UserSeeder {
amount: 65000,
date: atYesterday(19, 30),
merchant: 'Кинотеатр',
extraInfo: '2 билета · зал IMAX'),
extraInfo: '2 билета · зал IMAX',
obligation: SpendingObligation.unnecessary,
impulse: SpendingImpulse.impulsive),
_Demo(
accountId: a.card.id,
transferToAccountId: a.cash.id,
@@ -195,7 +269,8 @@ class UserSeeder {
amount: 3200000,
date: daysAgo(3, 12, 0),
merchant: 'Аренда квартиры',
extraInfo: 'Май 2026 · ул. Ленина 12'),
extraInfo: 'Май 2026 · ул. Ленина 12',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
categoryId: c.salary.id,
@@ -210,7 +285,9 @@ class UserSeeder {
type: TransactionType.expense,
amount: 34000,
date: daysAgo(4, 18, 30),
merchant: 'Яндекс Такси'),
merchant: 'Яндекс Такси',
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.considered),
_Demo(
accountId: a.cash.id,
categoryId: c.cafe.id,
@@ -218,14 +295,17 @@ class UserSeeder {
amount: 72000,
date: daysAgo(5, 14, 0),
merchant: 'Шоколадница',
extraInfo: 'Обед с коллегами'),
extraInfo: 'Обед с коллегами',
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.impulsive),
_Demo(
accountId: a.card.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 89000,
date: daysAgo(5, 9, 20),
merchant: 'Магнит'),
merchant: 'Магнит',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
transferToAccountId: a.savings.id,
@@ -241,28 +321,34 @@ class UserSeeder {
amount: 120000,
date: inPrevMonth(27, 20, 0),
merchant: 'Концерт',
extraInfo: '2 билета'),
extraInfo: '2 билета',
obligation: SpendingObligation.unnecessary,
impulse: SpendingImpulse.impulsive),
_Demo(
accountId: a.cash.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 88000,
date: inPrevMonth(25, 11, 10),
merchant: 'Перекрёсток'),
merchant: 'Перекрёсток',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
categoryId: c.transport.id,
type: TransactionType.expense,
amount: 38000,
date: inPrevMonth(22, 19, 0),
merchant: 'Яндекс Такси'),
merchant: 'Яндекс Такси',
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.considered),
_Demo(
accountId: a.card.id,
categoryId: c.food.id,
type: TransactionType.expense,
amount: 195000,
date: inPrevMonth(19, 17, 40),
merchant: 'Лента'),
merchant: 'Лента',
obligation: SpendingObligation.required),
_Demo(
accountId: a.card.id,
transferToAccountId: a.cash.id,
@@ -276,14 +362,17 @@ class UserSeeder {
type: TransactionType.expense,
amount: 6200,
date: inPrevMonth(14, 8, 45),
merchant: 'Метро'),
merchant: 'Метро',
obligation: SpendingObligation.required),
_Demo(
accountId: a.cash.id,
categoryId: c.cafe.id,
type: TransactionType.expense,
amount: 55000,
date: inPrevMonth(12, 13, 30),
merchant: 'Шоколадница'),
merchant: 'Шоколадница',
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.impulsive),
_Demo(
accountId: a.card.id,
transferToAccountId: a.savings.id,
@@ -306,7 +395,8 @@ class UserSeeder {
amount: 3200000,
date: inPrevMonth(5, 12, 0),
merchant: 'Аренда квартиры',
extraInfo: 'Апрель 2026 · ул. Ленина 12'),
extraInfo: 'Апрель 2026 · ул. Ленина 12',
obligation: SpendingObligation.required),
];
for (final d in demo) {
@@ -320,6 +410,8 @@ class UserSeeder {
merchant: d.merchant,
extraInfo: d.extraInfo,
transferToAccountId: d.transferToAccountId,
obligation: d.obligation,
impulse: d.impulse,
);
}
}
@@ -361,6 +453,8 @@ class _Demo {
this.merchant,
this.extraInfo,
this.transferToAccountId,
this.obligation,
this.impulse,
});
final String accountId;
final String? categoryId;
@@ -370,4 +464,6 @@ class _Demo {
final String? merchant;
final String? extraInfo;
final String? transferToAccountId;
final SpendingObligation? obligation;
final SpendingImpulse? impulse;
}
+84
View File
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
/// Тест миграции v5 → v6 (habit-tracking).
///
/// Поднимаем актуальную (v6) схему на файле, затем «откатываем» её до v5,
/// удаляя новые колонки и проставляя user_version = 5. При повторном открытии
/// срабатывает `onUpgrade(5 → 6)`, который должен снова добавить колонки.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 5 → 6 добавляет obligation/impulse/habitTrackingEnabled',
() async {
const userId = 'user-1';
const accountId = 'account-1';
// 1. Создаём актуальную схему (v6) и наполняем FK-цепочку.
final dbV6 = AppDatabase.forTesting(NativeDatabase(file));
await dbV6.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
await dbV6.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: accountId,
userId: userId,
name: 'Основной',
),
);
// 2. «Откатываем» схему до v5: убираем новые колонки и версию.
await dbV6.customStatement(
'ALTER TABLE transactions DROP COLUMN obligation');
await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse');
await dbV6.customStatement(
'ALTER TABLE settings DROP COLUMN habit_tracking_enabled');
await dbV6.customStatement('PRAGMA user_version = 5');
await dbV6.close();
// 3. Повторное открытие запускает onUpgrade(5 → 6).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4. Колонки снова доступны: запись с оценками проходит round-trip.
final repoRow = await dbMigrated.transactionsDao.findById('tx-1');
expect(repoRow, isNull);
await dbMigrated.transactionsDao.insertTransaction(
TransactionsTableCompanion.insert(
id: 'tx-1',
userId: userId,
accountId: accountId,
amount: 1000,
date: DateTime(2024, 6, 1),
obligation: const Value(SpendingObligation.required),
impulse: const Value(SpendingImpulse.impulsive),
),
);
final tx = await dbMigrated.transactionsDao.findById('tx-1');
expect(tx!.obligation, SpendingObligation.required);
expect(tx.impulse, SpendingImpulse.impulsive);
// settings.habitTrackingEnabled присутствует с дефолтом false.
await dbMigrated.settingsDao.upsertSettings(
SettingsTableCompanion.insert(userId: userId),
);
final settings = await dbMigrated.settingsDao.getSettingsByUser(userId);
expect(settings!.habitTrackingEnabled, isFalse);
});
}
@@ -0,0 +1,122 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/analytics/application/habit_analysis_providers.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
Transaction _tx(
String id,
int amount, {
SpendingObligation? obligation,
SpendingImpulse? impulse,
}) =>
Transaction(
id: id,
userId: 'u',
accountId: 'a',
type: TransactionType.expense,
amount: amount,
date: DateTime(2026, 6, 10),
obligation: obligation,
impulse: impulse,
createdAt: DateTime(2026, 6, 10),
);
void main() {
const userId = 'u';
// 5 расходов: разные сочетания шкал + одна без оценок.
final txs = [
_tx('1', 1000,
obligation: SpendingObligation.required,
impulse: SpendingImpulse.considered),
_tx('2', 2000,
obligation: SpendingObligation.unnecessary,
impulse: SpendingImpulse.impulsive),
_tx('3', 500,
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.impulsive),
_tx('4', 700, obligation: SpendingObligation.required),
_tx('5', 300), // без оценок
];
ProviderContainer makeContainer() => ProviderContainer(
overrides: [
habitMonthTransactionsProvider(userId).overrideWithValue(txs),
],
);
test('habitSumByImpulse суммирует по импульсивности (включая null)', () {
final c = makeContainer();
addTearDown(c.dispose);
final map = c.read(habitSumByImpulseProvider(userId));
expect(map[SpendingImpulse.considered], 1000);
expect(map[SpendingImpulse.impulsive], 2500); // 2000 + 500
expect(map[null], 1000); // tx4 (700) + tx5 (300)
});
test('habitSumByObligation суммирует по обязательности (включая null)', () {
final c = makeContainer();
addTearDown(c.dispose);
final map = c.read(habitSumByObligationProvider(userId));
expect(map[SpendingObligation.required], 1700); // 1000 + 700
expect(map[SpendingObligation.unnecessary], 2000);
expect(map[SpendingObligation.optional], 500);
expect(map[null], 300);
});
test('habitFilteredTransactions без фильтров возвращает все', () {
final c = makeContainer();
addTearDown(c.dispose);
expect(c.read(habitFilteredTransactionsProvider(userId)).length, 5);
});
test('фильтр по импульсу оставляет только impulsive', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['2', '3']));
});
test('фильтр по обязательности (мульти) — required + unnecessary', () {
final c = makeContainer();
addTearDown(c.dispose);
final notifier = c.read(habitObligationFilterProvider.notifier);
notifier.toggle(SpendingObligation.required);
notifier.toggle(SpendingObligation.unnecessary);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['1', '2', '4']));
});
test('фильтры комбинируются (impulsive AND unnecessary)', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive);
c
.read(habitObligationFilterProvider.notifier)
.toggle(SpendingObligation.unnecessary);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), ['2']);
});
test('обязательность=required исключает транзакции без оценки', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitObligationFilterProvider.notifier)
.toggle(SpendingObligation.required);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['1', '4']));
});
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/home/presentation/widgets/habit_chips.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
Transaction _tx({
SpendingObligation? obligation,
SpendingImpulse? impulse,
}) =>
Transaction(
id: 't',
userId: 'u',
accountId: 'a',
type: TransactionType.expense,
amount: 100,
date: DateTime(2026, 6, 1),
obligation: obligation,
impulse: impulse,
createdAt: DateTime(2026, 6, 1),
);
Widget _wrap(Transaction tx) => MaterialApp(
locale: const Locale('en'),
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: HabitChips(tx: tx)),
);
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
testWidgets('обе шкалы null → курсивное "not marked", без пилюль',
(tester) async {
await tester.pumpWidget(_wrap(_tx()));
final text = tester.widget<Text>(find.text('not marked'));
expect(text.style?.fontStyle, FontStyle.italic);
expect(find.byIcon(Icons.bolt), findsNothing);
});
testWidgets('considered не показывается ярлыком', (tester) async {
await tester.pumpWidget(_wrap(_tx(impulse: SpendingImpulse.considered)));
// Нет ярлыка импульса → пустое состояние.
expect(find.text('not marked'), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
testWidgets('obligation + impulsive рендерит обе пилюли с молнией',
(tester) async {
await tester.pumpWidget(_wrap(_tx(
obligation: SpendingObligation.required,
impulse: SpendingImpulse.impulsive,
)));
expect(find.text('Necessary'), findsOneWidget);
expect(find.text('Impulse'), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsOneWidget);
expect(find.text('not marked'), findsNothing);
});
testWidgets('только obligation: пилюля без молнии', (tester) async {
await tester
.pumpWidget(_wrap(_tx(obligation: SpendingObligation.unnecessary)));
expect(find.text('Unnecessary'), findsOneWidget);
expect(find.byIcon(Icons.bolt), findsNothing);
});
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/home/presentation/widgets/tx_row.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
Transaction _tx({
String? extraInfo,
SpendingObligation? obligation,
SpendingImpulse? impulse,
}) =>
Transaction(
id: 't',
userId: 'u',
accountId: 'a',
type: TransactionType.expense,
amount: 100,
// Сегодняшняя дата → подзаголовок не дёргает DateFormat с локалью.
date: DateTime.now(),
merchant: 'Кофейня',
extraInfo: extraInfo,
obligation: obligation,
impulse: impulse,
createdAt: DateTime(2026, 6, 1),
);
Widget _wrap(Transaction tx, {required bool habitTrackingEnabled}) =>
MaterialApp(
locale: const Locale('en'),
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: TxRow(
tx: tx,
category: null,
accountById: const <String, Account>{},
habitTrackingEnabled: habitTrackingEnabled,
),
),
);
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
testWidgets('флаг выключен → показывается extraInfo, нет ярлыков',
(tester) async {
await tester.pumpWidget(
_wrap(_tx(extraInfo: 'комментарий'), habitTrackingEnabled: false),
);
expect(find.text('комментарий'), findsOneWidget);
expect(find.text('not marked'), findsNothing);
});
testWidgets('флаг включён → extraInfo скрыт, показаны ярлыки',
(tester) async {
await tester.pumpWidget(
_wrap(
_tx(extraInfo: 'комментарий', obligation: SpendingObligation.required),
habitTrackingEnabled: true,
),
);
expect(find.text('комментарий'), findsNothing);
expect(find.text('Necessary'), findsOneWidget);
});
testWidgets('флаг включён, оценок нет → "not marked" вместо extraInfo',
(tester) async {
await tester.pumpWidget(
_wrap(_tx(extraInfo: 'комментарий'), habitTrackingEnabled: true),
);
expect(find.text('комментарий'), findsNothing);
expect(find.text('not marked'), findsOneWidget);
});
}
@@ -56,6 +56,8 @@ class _FakeTransactionRepo implements TransactionRepository {
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
@@ -0,0 +1,44 @@
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/features/settings/data/repositories/settings_repository_impl.dart';
void main() {
late AppDatabase db;
late SettingsRepositoryImpl repo;
const userId = 'user-1';
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
repo = SettingsRepositoryImpl(db.settingsDao);
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: userId, name: 'Тест'),
);
});
tearDown(() => db.close());
test('ensureDefaults: habitTrackingEnabled = false по умолчанию', () async {
final settings = await repo.ensureDefaults(userId);
expect(settings.habitTrackingEnabled, isFalse);
});
test('updateHabitTrackingEnabled включает и выключает флаг', () async {
await repo.ensureDefaults(userId);
await repo.updateHabitTrackingEnabled(userId, true);
expect((await repo.getSettings(userId))!.habitTrackingEnabled, isTrue);
await repo.updateHabitTrackingEnabled(userId, false);
expect((await repo.getSettings(userId))!.habitTrackingEnabled, isFalse);
});
test('updateHabitTrackingEnabled создаёт настройки при отсутствии', () async {
// Без предварительного ensureDefaults — метод сам создаёт запись.
await repo.updateHabitTrackingEnabled(userId, true);
final settings = await repo.getSettings(userId);
expect(settings, isNotNull);
expect(settings!.habitTrackingEnabled, isTrue);
});
}
@@ -47,6 +47,8 @@ class FakeTransactionRepository implements TransactionRepository {
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
@@ -9,6 +9,8 @@ import 'package:new_budget/src/features/accounts/application/accounts_controller
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/categories/application/categories_controller.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/settings/application/settings_controller.dart';
import 'package:new_budget/src/features/settings/domain/entities/settings.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
import 'package:new_budget/src/features/transactions/application/transactions_controller.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
@@ -26,6 +28,18 @@ class FakeActiveUserController extends ActiveUserController {
User(id: 'test-uid', name: 'Test', createdAt: DateTime(2024));
}
/// Возвращает фиксированные настройки без обращения к БД (habit-tracking off).
class FakeSettingsController extends SettingsController {
@override
Future<Settings> build(String userId) async => const Settings(
userId: 'test-uid',
baseCurrency: 'RUB',
themeMode: AppThemeMode.system,
locale: 'ru',
firstDayOfMonth: 1,
);
}
class TxCall {
TxCall({
required this.userId,
@@ -64,6 +78,8 @@ class FakeTransactionsController extends TransactionsController {
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
@@ -106,6 +122,8 @@ Widget _buildForm(FakeTransactionsController fakeCtrl) {
return ProviderScope(
overrides: [
activeUserControllerProvider.overrideWith(() => FakeActiveUserController()),
settingsControllerProvider('test-uid')
.overrideWith(() => FakeSettingsController()),
transactionsControllerProvider.overrideWith(() => fakeCtrl),
transactionRepositoryProvider.overrideWithValue(_UnusedTransactionRepo()),
accountsStreamProvider('test-uid').overrideWith(
@@ -197,6 +197,61 @@ void main() {
});
});
// ─── habit-tracking: obligation / impulse ────────────────────────────────────
group('TransactionRepository — habit-tracking', () {
test('create сохраняет obligation и impulse (round-trip)', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: TransactionType.expense,
amount: 5000,
date: DateTime(2024, 5, 1),
obligation: SpendingObligation.unnecessary,
impulse: SpendingImpulse.impulsive,
);
expect(tx.obligation, SpendingObligation.unnecessary);
expect(tx.impulse, SpendingImpulse.impulsive);
final found = await repo.findById(tx.id);
expect(found!.obligation, SpendingObligation.unnecessary);
expect(found.impulse, SpendingImpulse.impulsive);
});
test('create без оценок — поля null', () async {
final tx = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 100,
date: DateTime(2024, 5, 2),
);
expect(tx.obligation, isNull);
expect(tx.impulse, isNull);
});
test('update проставляет obligation/impulse', () async {
final created = await repo.create(
userId: userId,
accountId: accountId,
type: TransactionType.expense,
amount: 700,
date: DateTime(2024, 5, 3),
);
final updated = await repo.update(created.copyWith(
obligation: SpendingObligation.required,
impulse: SpendingImpulse.considered,
));
expect(updated.obligation, SpendingObligation.required);
expect(updated.impulse, SpendingImpulse.considered);
final found = await repo.findById(created.id);
expect(found!.obligation, SpendingObligation.required);
expect(found.impulse, SpendingImpulse.considered);
});
});
// ─── delete ────────────────────────────────────────────────────────────────
group('TransactionRepository.delete', () {