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