diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 122b217..29ce449 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 87710dc..a8b4d33 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index b932922..280a50a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8bb3405..7d3285e 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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 => 'Демо транзакции добавлены'; } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6f6c9d7..1997a8d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -362,5 +362,22 @@ "type": "int" } } - } + }, + + "habitTrackingTile": "Анализ привычек трат", + "habitObligationRequired": "Обязательно", + "habitObligationOptional": "Не обязательно", + "habitObligationUnnecessary": "Не нужно", + "habitObligationOptionalShort": "Можно без", + "habitImpulseImpulsive": "Импульсивно", + "habitImpulseConsidered": "Обдуманно", + "habitNotMarked": "не отмечено", + "habitFilterAll": "Все", + "habitScaleImpulseCaps": "ИМПУЛЬСИВНОСТЬ", + "habitScaleObligationCaps": "ОБЯЗАТЕЛЬНОСТЬ", + "habitAnalysisTitle": "Транзакции", + "habitAnalysisTile": "Анализ привычек", + + "profileSeedDemoTile": "Заполнить демо транзакциями", + "profileSeedDemoSuccess": "Демо транзакции добавлены" } diff --git a/lib/src/app/router/app_router.dart b/lib/src/app/router/app_router.dart index 2f7436b..15534e1 100644 --- a/lib/src/app/router/app_router.dart +++ b/lib/src/app/router/app_router.dart @@ -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(state, const HabitAnalysisScreen()), + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) => AppScaffold( navigationShell: navigationShell, diff --git a/lib/src/app/router/app_routes.dart b/lib/src/app/router/app_routes.dart index c8d8fc1..3d79772 100644 --- a/lib/src/app/router/app_routes.dart +++ b/lib/src/app/router/app_routes.dart @@ -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'; diff --git a/lib/src/app/theme/app_theme.dart b/lib/src/app/theme/app_theme.dart index c5d6731..985af71 100644 --- a/lib/src/app/theme/app_theme.dart +++ b/lib/src/app/theme/app_theme.dart @@ -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( diff --git a/lib/src/app/theme/habit_chip_colors.dart b/lib/src/app/theme/habit_chip_colors.dart new file mode 100644 index 0000000..f231fc0 --- /dev/null +++ b/lib/src/app/theme/habit_chip_colors.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; + +/// Семантические цвета ярлыков «привычек трат» (habit-tracking). +/// +/// Не относятся к брендовой палитре [AppPalette], но хардкод в виджетах +/// запрещён — поэтому отдельный [ThemeExtension] рядом с палитрой. +/// +/// Для каждого значения шкалы — пара «фон / текст-иконка». Дополнительно +/// цветные точки-индикаторы для фильтров аналитики (§2.4 плана). +@immutable +class HabitChipColors extends ThemeExtension { + 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? 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()!; +} diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index 75ffcea..026a478 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -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); + } }, ); diff --git a/lib/src/core/database/converters/enum_converters.dart b/lib/src/core/database/converters/enum_converters.dart index 9247315..8057b5c 100644 --- a/lib/src/core/database/converters/enum_converters.dart +++ b/lib/src/core/database/converters/enum_converters.dart @@ -48,6 +48,39 @@ class TransactionTypeConverter extends TypeConverter { String toSql(TransactionType value) => value.name; } +// --------------------------------------------------------------------------- +// SpendingObligation (habit-tracking — шкала «обязательность») +// --------------------------------------------------------------------------- +enum SpendingObligation { required, optional, unnecessary } + +class SpendingObligationConverter + extends TypeConverter { + 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 { + const SpendingImpulseConverter(); + + @override + SpendingImpulse fromSql(String fromDb) => + SpendingImpulse.values.firstWhere((e) => e.name == fromDb); + + @override + String toSql(SpendingImpulse value) => value.name; +} + // --------------------------------------------------------------------------- // ThemeMode // --------------------------------------------------------------------------- diff --git a/lib/src/core/database/tables/settings_table.dart b/lib/src/core/database/tables/settings_table.dart index f346120..6784708 100644 --- a/lib/src/core/database/tables/settings_table.dart +++ b/lib/src/core/database/tables/settings_table.dart @@ -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 get primaryKey => {userId}; } diff --git a/lib/src/core/database/tables/transactions_table.dart b/lib/src/core/database/tables/transactions_table.dart index b6ff9f8..3ca2cee 100644 --- a/lib/src/core/database/tables/transactions_table.dart +++ b/lib/src/core/database/tables/transactions_table.dart @@ -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 для ручных). diff --git a/lib/src/features/analytics/application/habit_analysis_providers.dart b/lib/src/features/analytics/application/habit_analysis_providers.dart new file mode 100644 index 0000000..b126983 --- /dev/null +++ b/lib/src/features/analytics/application/habit_analysis_providers.dart @@ -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 build() => const {}; + + void toggle(SpendingObligation value) { + final next = Set.from(state); + if (!next.add(value)) next.remove(value); + state = next; + } + + void clear() => state = const {}; +} + +// --------------------------------------------------------------------------- +// Агрегаты +// --------------------------------------------------------------------------- + +/// Расходные транзакции за выбранный месяц (без учёта фильтров привычек). +@riverpod +List 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 []; + + return all; +} + +/// Суммы расходов по импульсивности (включая ключ `null` = «без оценки»). +@riverpod +Map habitSumByImpulse(Ref ref, String userId) { + final txs = ref.watch(habitMonthTransactionsProvider(userId)); + final map = {}; + for (final t in txs) { + map[t.impulse] = (map[t.impulse] ?? 0) + t.amount; + } + return map; +} + +/// Суммы расходов по обязательности (включая ключ `null` = «без оценки»). +@riverpod +Map habitSumByObligation(Ref ref, String userId) { + final txs = ref.watch(habitMonthTransactionsProvider(userId)); + final map = {}; + for (final t in txs) { + map[t.obligation] = (map[t.obligation] ?? 0) + t.amount; + } + return map; +} + +/// Транзакции с применёнными обоими фильтрами (для сводки и списка). +@riverpod +List 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(); +} diff --git a/lib/src/features/analytics/presentation/screens/analytics_screen.dart b/lib/src/features/analytics/presentation/screens/analytics_screen.dart index b1fc7c0..ef737c9 100644 --- a/lib/src/features/analytics/presentation/screens/analytics_screen.dart +++ b/lib/src/features/analytics/presentation/screens/analytics_screen.dart @@ -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), + ], + ), + ), ); } } diff --git a/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart new file mode 100644 index 0000000..2c9de4c --- /dev/null +++ b/lib/src/features/analytics/presentation/screens/habit_analysis_screen.dart @@ -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 []; + final categoryById = {for (final c in categories) c.id: c}; + final accounts = + ref.watch(accountsStreamProvider(userId)).value ?? const []; + 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(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(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 items; + final int totalSpentMinor; +} + +List<_DayGroup> _groupByDay( + List txs, + String locale, + AppLocalizations l10n, +) { + final sorted = [...txs]..sort((a, b) => b.date.compareTo(a.date)); + final map = >{}; + 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(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; +} diff --git a/lib/src/features/home/presentation/screens/home_screen.dart b/lib/src/features/home/presentation/screens/home_screen.dart index 4b917d4..728f9e3 100644 --- a/lib/src/features/home/presentation/screens/home_screen.dart +++ b/lib/src/features/home/presentation/screens/home_screen.dart @@ -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 categoryById; final Map 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, ), ], ); diff --git a/lib/src/features/home/presentation/widgets/habit_chips.dart b/lib/src/features/home/presentation/widgets/habit_chips.dart new file mode 100644 index 0000000..35a9e99 --- /dev/null +++ b/lib/src/features/home/presentation/widgets/habit_chips.dart @@ -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 = []; + 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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/features/home/presentation/widgets/tx_row.dart b/lib/src/features/home/presentation/widgets/tx_row.dart index 2983a8c..89add93 100644 --- a/lib/src/features/home/presentation/widgets/tx_row.dart +++ b/lib/src/features/home/presentation/widgets/tx_row.dart @@ -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 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!, diff --git a/lib/src/features/profile/presentation/screens/profile_screen.dart b/lib/src/features/profile/presentation/screens/profile_screen.dart index 5ef21c8..502ee1b 100644 --- a/lib/src/features/profile/presentation/screens/profile_screen.dart +++ b/lib/src/features/profile/presentation/screens/profile_screen.dart @@ -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 createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + bool _seedingDemo = false; + + Future _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), ], ), ), diff --git a/lib/src/features/settings/application/settings_controller.dart b/lib/src/features/settings/application/settings_controller.dart index c676f1f..01f2317 100644 --- a/lib/src/features/settings/application/settings_controller.dart +++ b/lib/src/features/settings/application/settings_controller.dart @@ -53,6 +53,10 @@ class SettingsController extends _$SettingsController { (repo) => repo.updateFirstDayOfMonth(userId, day), ); + Future setHabitTrackingEnabled(bool enabled) => _update( + (repo) => repo.updateHabitTrackingEnabled(userId, enabled), + ); + /// Полная перезапись настроек. Future saveSettings(Settings settings) => _update( (repo) => repo.upsertSettings(settings), diff --git a/lib/src/features/settings/data/mappers/settings_mapper.dart b/lib/src/features/settings/data/mappers/settings_mapper.dart index 4aa88d5..2fc4b93 100644 --- a/lib/src/features/settings/data/mappers/settings_mapper.dart +++ b/lib/src/features/settings/data/mappers/settings_mapper.dart @@ -9,5 +9,6 @@ extension SettingsMapper on SettingsTableData { themeMode: themeMode, // уже AppThemeMode — конвертер в Drift-таблице locale: locale, firstDayOfMonth: firstDayOfMonth, + habitTrackingEnabled: habitTrackingEnabled, ); } diff --git a/lib/src/features/settings/data/repositories/settings_repository_impl.dart b/lib/src/features/settings/data/repositories/settings_repository_impl.dart index f14a89d..bf2ccc2 100644 --- a/lib/src/features/settings/data/repositories/settings_repository_impl.dart +++ b/lib/src/features/settings/data/repositories/settings_repository_impl.dart @@ -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 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, ); diff --git a/lib/src/features/settings/domain/entities/settings.dart b/lib/src/features/settings/domain/entities/settings.dart index cd87d0c..735bd3b 100644 --- a/lib/src/features/settings/domain/entities/settings.dart +++ b/lib/src/features/settings/domain/entities/settings.dart @@ -22,5 +22,8 @@ abstract class Settings with _$Settings { /// Первый день рабочей недели/месячного периода (1 = понедельник/1-е число). required int firstDayOfMonth, + + /// Включён ли «анализ привычек трат» (habit-tracking). + @Default(false) bool habitTrackingEnabled, }) = _Settings; } diff --git a/lib/src/features/settings/domain/repositories/settings_repository.dart b/lib/src/features/settings/domain/repositories/settings_repository.dart index dae9635..3e12030 100644 --- a/lib/src/features/settings/domain/repositories/settings_repository.dart +++ b/lib/src/features/settings/domain/repositories/settings_repository.dart @@ -30,4 +30,7 @@ abstract interface class SettingsRepository { /// Изменить первый день месяца/недели (1–28). Future updateFirstDayOfMonth(String userId, int day); + + /// Включить/выключить «анализ привычек трат». + Future updateHabitTrackingEnabled(String userId, bool enabled); } diff --git a/lib/src/features/transactions/application/transactions_controller.dart b/lib/src/features/transactions/application/transactions_controller.dart index 1315708..a45295c 100644 --- a/lib/src/features/transactions/application/transactions_controller.dart +++ b/lib/src/features/transactions/application/transactions_controller.dart @@ -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, diff --git a/lib/src/features/transactions/data/mappers/transaction_mapper.dart b/lib/src/features/transactions/data/mappers/transaction_mapper.dart index aac60ab..253beca 100644 --- a/lib/src/features/transactions/data/mappers/transaction_mapper.dart +++ b/lib/src/features/transactions/data/mappers/transaction_mapper.dart @@ -14,6 +14,8 @@ extension TransactionMapper on TransactionsTableData { merchant: merchant, extraInfo: extraInfo, transferToAccountId: transferToAccountId, + obligation: obligation, + impulse: impulse, rawMessageId: rawMessageId, autoApplied: autoApplied, appliedByRuleId: appliedByRuleId, diff --git a/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart index 38eb585..86f42cc 100644 --- a/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart +++ b/lib/src/features/transactions/data/repositories/transaction_repository_impl.dart @@ -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), diff --git a/lib/src/features/transactions/domain/entities/transaction.dart b/lib/src/features/transactions/domain/entities/transaction.dart index 86279ef..cf43fbf 100644 --- a/lib/src/features/transactions/domain/entities/transaction.dart +++ b/lib/src/features/transactions/domain/entities/transaction.dart @@ -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, diff --git a/lib/src/features/transactions/domain/repositories/transaction_repository.dart b/lib/src/features/transactions/domain/repositories/transaction_repository.dart index 6cdf8d9..4bbc204 100644 --- a/lib/src/features/transactions/domain/repositories/transaction_repository.dart +++ b/lib/src/features/transactions/domain/repositories/transaction_repository.dart @@ -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, diff --git a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart index 48de0f4..b4509bc 100644 --- a/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart +++ b/lib/src/features/transactions/presentation/screens/transaction_form_screen.dart @@ -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), diff --git a/lib/src/features/transactions/presentation/state/transaction_draft.dart b/lib/src/features/transactions/presentation/state/transaction_draft.dart index c2c3adb..ae39c7a 100644 --- a/lib/src/features/transactions/presentation/state/transaction_draft.dart +++ b/lib/src/features/transactions/presentation/state/transaction_draft.dart @@ -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; } diff --git a/lib/src/features/transactions/presentation/widgets/habit_selectors.dart b/lib/src/features/transactions/presentation/widgets/habit_selectors.dart new file mode 100644 index 0000000..3c9d207 --- /dev/null +++ b/lib/src/features/transactions/presentation/widgets/habit_selectors.dart @@ -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 onObligationChanged; + final ValueChanged 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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/features/user/application/user_seeder.dart b/lib/src/features/user/application/user_seeder.dart index 758d9ef..29cdfcc 100644 --- a/lib/src/features/user/application/user_seeder.dart +++ b/lib/src/features/user/application/user_seeder.dart @@ -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 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 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 _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; } diff --git a/test/core/database/migration_v6_test.dart b/test/core/database/migration_v6_test.dart new file mode 100644 index 0000000..fd3a8b3 --- /dev/null +++ b/test/core/database/migration_v6_test.dart @@ -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); + }); +} diff --git a/test/features/analytics/habit_analysis_providers_test.dart b/test/features/analytics/habit_analysis_providers_test.dart new file mode 100644 index 0000000..d4e2f8a --- /dev/null +++ b/test/features/analytics/habit_analysis_providers_test.dart @@ -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'])); + }); +} diff --git a/test/features/home/habit_chips_test.dart b/test/features/home/habit_chips_test.dart new file mode 100644 index 0000000..6c20fee --- /dev/null +++ b/test/features/home/habit_chips_test.dart @@ -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(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); + }); +} diff --git a/test/features/home/tx_row_habit_test.dart b/test/features/home/tx_row_habit_test.dart new file mode 100644 index 0000000..2ea470c --- /dev/null +++ b/test/features/home/tx_row_habit_test.dart @@ -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 {}, + 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); + }); +} diff --git a/test/features/notification_parsing/application/inbox_controller_test.dart b/test/features/notification_parsing/application/inbox_controller_test.dart index ceff4f1..abb82a2 100644 --- a/test/features/notification_parsing/application/inbox_controller_test.dart +++ b/test/features/notification_parsing/application/inbox_controller_test.dart @@ -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, diff --git a/test/features/settings/settings_habit_tracking_test.dart b/test/features/settings/settings_habit_tracking_test.dart new file mode 100644 index 0000000..b6b1292 --- /dev/null +++ b/test/features/settings/settings_habit_tracking_test.dart @@ -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); + }); +} diff --git a/test/features/transactions/application/transactions_controller_test.dart b/test/features/transactions/application/transactions_controller_test.dart index 7907149..544b791 100644 --- a/test/features/transactions/application/transactions_controller_test.dart +++ b/test/features/transactions/application/transactions_controller_test.dart @@ -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, diff --git a/test/features/transactions/presentation/transaction_form_screen_test.dart b/test/features/transactions/presentation/transaction_form_screen_test.dart index 57e0e2e..454adb3 100644 --- a/test/features/transactions/presentation/transaction_form_screen_test.dart +++ b/test/features/transactions/presentation/transaction_form_screen_test.dart @@ -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 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( diff --git a/test/features/transactions/repository/transaction_repository_test.dart b/test/features/transactions/repository/transaction_repository_test.dart index 2268dac..f809c8e 100644 --- a/test/features/transactions/repository/transaction_repository_test.dart +++ b/test/features/transactions/repository/transaction_repository_test.dart @@ -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', () {