From 2d840c9fe7866aa529d47e6ba7fc204bd8a8cea0 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Sun, 13 Sep 2026 00:29:42 +0700 Subject: [PATCH 1/5] feat(insights): add timeframe aggregation domain layer (U1) Implements U1 of docs/plans/2026-09-10-001-feat-insights-analytics-plan.md: - InsightsTimeframe (thisMonth / lastQuarter / ytd) resolves to a current analysis window plus the previous comparable window for period-over-period deltas, all inclusive ranges. - InsightsSummary/PillarInsight/EnvelopeInsight/InsightsDelta entities: inflow (income + investment) vs outflow (expense) totals, Level-1 pillar rollups via the cycle-safe getRootPillar, per-envelope buckets with activity shares, and delta math with a "new" contract when the previous window is zero. - GetInsightsSummaryUseCase fetches both windows plus categories in one call; missing categories land in one stable "Uncategorized" bucket per aggregation run. Amounts bucket by TransactionType (never CategoryType) so the investment-type/category-type mismatch stays inert. Nine usecase tests cover the three timeframe boundaries, rollup shares, the investment inflow rule, the uncategorized bucket, empty windows, and both failure paths. --- .../domain/entities/insight_timeframe.dart | 91 ++++ .../domain/entities/insights_summary.dart | 139 ++++++ .../get_insights_summary_usecase.dart | 232 +++++++++ .../get_insights_summary_usecase_test.dart | 441 ++++++++++++++++++ 4 files changed, 903 insertions(+) create mode 100644 lib/features/insights/domain/entities/insight_timeframe.dart create mode 100644 lib/features/insights/domain/entities/insights_summary.dart create mode 100644 lib/features/insights/domain/usecases/get_insights_summary_usecase.dart create mode 100644 test/features/insights/domain/usecases/get_insights_summary_usecase_test.dart diff --git a/lib/features/insights/domain/entities/insight_timeframe.dart b/lib/features/insights/domain/entities/insight_timeframe.dart new file mode 100644 index 0000000..553179d --- /dev/null +++ b/lib/features/insights/domain/entities/insight_timeframe.dart @@ -0,0 +1,91 @@ +import 'package:equatable/equatable.dart'; + +/// The selectable analysis windows on the Insights screen. +enum InsightsTimeframe { + thisMonth, + lastQuarter, + ytd; + + /// Resolves this timeframe against [now] into the current analysis + /// range and the previous comparable range used for period-over-period + /// deltas. All ranges are inclusive on both ends. + InsightsWindow resolve(DateTime now) { + switch (this) { + case InsightsTimeframe.thisMonth: + final start = DateTime(now.year, now.month); + final end = _endOf(DateTime(now.year, now.month + 1)); + final previousStart = DateTime(now.year, now.month - 1); + final previousEnd = _endOf(DateTime(now.year, now.month)); + return InsightsWindow( + current: DateRange(start: start, end: end), + previous: DateRange(start: previousStart, end: previousEnd), + ); + case InsightsTimeframe.lastQuarter: + // Zero-based quarter index for the CURRENT quarter; "last + // quarter" is the calendar quarter before it. + final quarterIndex = (now.month - 1) ~/ 3; + final lastQuarterStartMonth = quarterIndex * 3 - 2; + final start = DateTime(now.year, lastQuarterStartMonth); + final end = _endOf(DateTime(now.year, lastQuarterStartMonth + 3)); + final previousStart = DateTime(now.year, lastQuarterStartMonth - 3); + final previousEnd = _endOf(DateTime(now.year, lastQuarterStartMonth)); + return InsightsWindow( + current: DateRange(start: start, end: end), + previous: DateRange(start: previousStart, end: previousEnd), + ); + case InsightsTimeframe.ytd: + final start = DateTime(now.year); + final end = DateTime( + now.year, + now.month, + now.day, + 23, + 59, + 59, + 999, + ); + final previousStart = DateTime(now.year - 1); + final previousEnd = DateTime( + now.year - 1, + now.month, + now.day, + 23, + 59, + 59, + 999, + ); + return InsightsWindow( + current: DateRange(start: start, end: end), + previous: DateRange(start: previousStart, end: previousEnd), + ); + } + } + + /// Last millisecond of the day before the first instant of [firstOfNext]. + static DateTime _endOf(DateTime firstOfNext) => + firstOfNext.subtract(const Duration(milliseconds: 1)); +} + +/// A half-open-in-name-only inclusive range [start, end]. +class DateRange extends Equatable { + const DateRange({required this.start, required this.end}); + + final DateTime start; + final DateTime end; + + bool contains(DateTime date) => !date.isBefore(start) && !date.isAfter(end); + + @override + List get props => [start, end]; +} + +/// The current and previous comparable ranges for a timeframe. +class InsightsWindow extends Equatable { + const InsightsWindow({required this.current, required this.previous}); + + final DateRange current; + final DateRange previous; + + @override + List get props => [current, previous]; +} diff --git a/lib/features/insights/domain/entities/insights_summary.dart b/lib/features/insights/domain/entities/insights_summary.dart new file mode 100644 index 0000000..9e6a238 --- /dev/null +++ b/lib/features/insights/domain/entities/insights_summary.dart @@ -0,0 +1,139 @@ +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; + +/// Aggregated insights for one timeframe: totals, period-over-period +/// deltas, and the nested pillar/envelope distribution. +class InsightsSummary extends Equatable { + const InsightsSummary({ + required this.totalOutflow, + required this.totalInflow, + required this.outflowDelta, + required this.inflowDelta, + required this.pillars, + }); + + /// Sum of expense-transaction amounts in the current window. + final double totalOutflow; + + /// Sum of income + investment transaction amounts in the window. + final double totalInflow; + + /// Pillars ordered by outflow descending, then inflow, then name. + final List pillars; + + final InsightsDelta outflowDelta; + final InsightsDelta inflowDelta; + + @override + List get props => [ + totalOutflow, + totalInflow, + outflowDelta, + inflowDelta, + pillars, + ]; +} + +/// Period-over-period change for one flow direction. +class InsightsDelta extends Equatable { + const InsightsDelta({ + required this.current, + required this.previous, + required this.isNew, + this.changePercent, + }); + + /// Computes the delta from the two window totals. + factory InsightsDelta.calculate({ + required double current, + required double previous, + }) { + final isNew = previous == 0 && current > 0; + final changePercent = + previous == 0 ? null : (current - previous) / previous * 100; + return InsightsDelta( + current: current, + previous: previous, + isNew: isNew, + changePercent: changePercent, + ); + } + + final double current; + final double previous; + + /// Percentage change vs the previous window; null when the previous + /// window is zero (a percentage would be meaningless). + final double? changePercent; + + /// True when there was no activity in the previous window but there is + /// activity now — rendered as "new" instead of a percentage. + final bool isNew; + + @override + List get props => [current, previous, changePercent, isNew]; +} + +/// A Level-1 pillar with its rolled-up activity for the window. +/// +/// Expense-type pillars carry [outflow]; income-type pillars carry +/// [inflow]; the other side stays zero. +class PillarInsight extends Equatable { + const PillarInsight({ + required this.pillar, + required this.outflow, + required this.inflow, + required this.shareOfTotalOutflow, + required this.envelopes, + }); + + final Category pillar; + final double outflow; + final double inflow; + + /// [outflow] as a fraction of the summary's total outflow (0 when the + /// total is zero or the pillar is income-side). + final double shareOfTotalOutflow; + + /// Direct-category buckets under this pillar, ordered by outflow + /// descending, then inflow, then name. + final List envelopes; + + @override + List get props => [ + pillar, + outflow, + inflow, + shareOfTotalOutflow, + envelopes, + ]; +} + +/// One direct-category bucket within a pillar. +class EnvelopeInsight extends Equatable { + const EnvelopeInsight({ + required this.category, + required this.outflow, + required this.inflow, + required this.transactionCount, + required this.shareOfPillar, + }); + + final Category category; + final double outflow; + final double inflow; + final int transactionCount; + + /// This envelope's activity relative to its pillar's total activity; + /// 0 when the pillar has no activity. + final double shareOfPillar; + + @override + List get props => [ + category, + outflow, + inflow, + transactionCount, + shareOfPillar, + ]; +} diff --git a/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart new file mode 100644 index 0000000..268b5bd --- /dev/null +++ b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart @@ -0,0 +1,232 @@ +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/domain/usecases/use_case.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/repositories/category_repository.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:injectable/injectable.dart'; +import 'package:uuid/uuid.dart'; + +/// Aggregates transactions for the requested timeframe into an +/// [InsightsSummary]: inflow/outflow totals, period-over-period deltas, +/// and the Level-1 pillar distribution with per-envelope detail. +/// +/// Amounts are bucketed by [TransactionType] — never by category type — +/// because `TransactionType.investment` has no matching `CategoryType`. +@lazySingleton +class GetInsightsSummaryUseCase + extends UseCase { + GetInsightsSummaryUseCase( + this._transactionRepository, + this._categoryRepository, + ); + + final TransactionRepository _transactionRepository; + final CategoryRepository _categoryRepository; + + @override + Future> call( + GetInsightsSummaryParams params, + ) async { + final window = params.timeframe.resolve(params.now); + + final categoriesResult = await _categoryRepository.watchCategories().first; + final currentResult = await _transactionRepository.getTransactions( + startDate: window.current.start, + endDate: window.current.end, + ); + final previousResult = await _transactionRepository.getTransactions( + startDate: window.previous.start, + endDate: window.previous.end, + ); + + final failure = categoriesResult.fold((f) => f, (_) => null) ?? + currentResult.fold((f) => f, (_) => null) ?? + previousResult.fold((f) => f, (_) => null); + if (failure != null) { + return Left(failure); + } + + final categories = categoriesResult.fold((_) => [], (c) => c); + final current = currentResult.fold((_) => [], (t) => t); + final previous = previousResult.fold((_) => [], (t) => t); + + return Right( + _aggregate( + currentTransactions: current, + previousTransactions: previous, + categories: categories, + ), + ); + } + + InsightsSummary _aggregate({ + required List currentTransactions, + required List previousTransactions, + required List categories, + }) { + final categoryByUuid = { + for (final category in categories) category.uuid.getOrCrash(): category, + }; + // One stable bucket per aggregation run so all missing-category + // transactions land in the same "Uncategorized" pillar. + final uncategorized = _uncategorizedCategory(); + + double previousOutflow = 0; + double previousInflow = 0; + for (final transaction in previousTransactions) { + if (transaction.type == TransactionType.expense) { + previousOutflow += transaction.amount.getOrCrash(); + } else { + previousInflow += transaction.amount.getOrCrash(); + } + } + + final pillarAccumulators = {}; + for (final transaction in currentTransactions) { + final amount = transaction.amount.getOrCrash(); + final category = categoryByUuid[transaction.categoryUuid.getOrCrash()] ?? + uncategorized; + final pillar = category.getRootPillar(categories); + final envelopeKey = category.uuid.getOrCrash(); + + final accumulator = pillarAccumulators.putIfAbsent( + pillar.uuid.getOrCrash(), + () => _PillarAccumulator(pillar: pillar), + ); + final envelope = accumulator.envelopes.putIfAbsent( + envelopeKey, + () => _EnvelopeAccumulator(category: category), + ); + + if (transaction.type == TransactionType.expense) { + accumulator.outflow += amount; + envelope.outflow += amount; + } else { + accumulator.inflow += amount; + envelope.inflow += amount; + } + envelope.transactionCount++; + } + + var totalOutflow = 0.0; + var totalInflow = 0.0; + for (final accumulator in pillarAccumulators.values) { + totalOutflow += accumulator.outflow; + totalInflow += accumulator.inflow; + } + + final pillars = pillarAccumulators.values + .map( + (accumulator) => accumulator.toInsight(totalOutflow: totalOutflow), + ) + .toList() + ..sort(_compareByActivity); + + return InsightsSummary( + totalOutflow: totalOutflow, + totalInflow: totalInflow, + outflowDelta: InsightsDelta.calculate( + current: totalOutflow, + previous: previousOutflow, + ), + inflowDelta: InsightsDelta.calculate( + current: totalInflow, + previous: previousInflow, + ), + pillars: pillars, + ); + } + + int _compareByActivity(PillarInsight a, PillarInsight b) { + final activityA = a.outflow + a.inflow; + final activityB = b.outflow + b.inflow; + if (activityA != activityB) return activityB.compareTo(activityA); + final nameA = a.pillar.name.getOrCrash().toLowerCase(); + final nameB = b.pillar.name.getOrCrash().toLowerCase(); + return nameA.compareTo(nameB); + } + + /// Fallback bucket for transactions whose category no longer exists; + /// never dropped silently. + Category _uncategorizedCategory() => Category( + uuid: UniqueId(const Uuid().v4()), + name: StringSingleLine('Uncategorized'), + isSynced: false, + updatedAt: DateTime.fromMillisecondsSinceEpoch(0), + type: CategoryType.expense, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, + ); +} + +class GetInsightsSummaryParams extends Equatable { + const GetInsightsSummaryParams({ + required this.timeframe, + required this.now, + }); + + final InsightsTimeframe timeframe; + final DateTime now; + + @override + List get props => [timeframe, now]; +} + +class _EnvelopeAccumulator { + _EnvelopeAccumulator({required this.category}); + + final Category category; + double outflow = 0; + double inflow = 0; + int transactionCount = 0; + + EnvelopeInsight toInsight({required double pillarActivity}) { + final activity = outflow + inflow; + return EnvelopeInsight( + category: category, + outflow: outflow, + inflow: inflow, + transactionCount: transactionCount, + shareOfPillar: pillarActivity == 0 ? 0 : activity / pillarActivity, + ); + } +} + +class _PillarAccumulator { + _PillarAccumulator({required this.pillar}); + + final Category pillar; + double outflow = 0; + double inflow = 0; + final Map envelopes = {}; + + PillarInsight toInsight({required double totalOutflow}) { + final pillarActivity = outflow + inflow; + return PillarInsight( + pillar: pillar, + outflow: outflow, + inflow: inflow, + shareOfTotalOutflow: totalOutflow == 0 ? 0 : outflow / totalOutflow, + envelopes: envelopes.values + .map( + (envelope) => envelope.toInsight(pillarActivity: pillarActivity), + ) + .toList() + ..sort((a, b) { + final activityA = a.outflow + a.inflow; + final activityB = b.outflow + b.inflow; + if (activityA != activityB) return activityB.compareTo(activityA); + final nameA = a.category.name.getOrCrash().toLowerCase(); + final nameB = b.category.name.getOrCrash().toLowerCase(); + return nameA.compareTo(nameB); + }), + ); + } +} diff --git a/test/features/insights/domain/usecases/get_insights_summary_usecase_test.dart b/test/features/insights/domain/usecases/get_insights_summary_usecase_test.dart new file mode 100644 index 0000000..ea8a955 --- /dev/null +++ b/test/features/insights/domain/usecases/get_insights_summary_usecase_test.dart @@ -0,0 +1,441 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/category/domain/repositories/category_repository.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/usecases/get_insights_summary_usecase.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; +import 'package:expense_tracker/features/transaction/domain/entities/transaction_type.dart'; +import 'package:expense_tracker/features/transaction/domain/repositories/transaction_repository.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockTransactionRepository extends Mock implements TransactionRepository {} + +class MockCategoryRepository extends Mock implements CategoryRepository {} + +void main() { + late MockTransactionRepository transactionRepository; + late MockCategoryRepository categoryRepository; + late GetInsightsSummaryUseCase useCase; + + const pillarUuid = '11111111-1111-4111-8111-111111111111'; + const subUuid = '22222222-2222-4222-8222-222222222222'; + const envelopeUuid = '33333333-3333-4333-8333-333333333333'; + const otherPillarUuid = '44444444-4444-4444-8444-444444444444'; + const salaryPillarUuid = '55555555-5555-4555-8555-555555555555'; + const missingUuid = '66666666-6666-4666-8666-666666666666'; + + final now = DateTime(2026, 9, 15, 10); + + final essential = _category( + uuid: pillarUuid, + name: 'Essential', + type: CategoryType.expense, + ); + final groceriesHousehold = _category( + uuid: subUuid, + name: 'Groceries & Household', + type: CategoryType.expense, + parentId: pillarUuid, + ); + final groceries = _category( + uuid: envelopeUuid, + name: 'Groceries', + type: CategoryType.expense, + parentId: subUuid, + ); + final lifestyle = _category( + uuid: otherPillarUuid, + name: 'Lifestyle', + type: CategoryType.expense, + ); + final salary = _category( + uuid: salaryPillarUuid, + name: 'Salary', + type: CategoryType.income, + ); + + final categories = [ + essential, + groceriesHousehold, + groceries, + lifestyle, + salary, + ]; + + Transaction tx( + String id, + double amount, + TransactionType type, + String categoryUuid, + DateTime date, + ) { + return Transaction( + uuid: UniqueId('aaaaaaaa-0000-4bbb-8bbb-${id.padLeft(12, '0')}'), + amount: Amount(amount), + description: StringSingleLine('t$id'), + date: date, + categoryUuid: UniqueId(categoryUuid), + type: type, + ); + } + + setUpAll(() { + registerFallbackValue(DateTime(2026)); + }); + + setUp(() { + transactionRepository = MockTransactionRepository(); + categoryRepository = MockCategoryRepository(); + useCase = GetInsightsSummaryUseCase( + transactionRepository, + categoryRepository, + ); + when(() => categoryRepository.watchCategories()) + .thenAnswer((_) => Stream.value(Right(categories))); + }); + + /// The usecase always calls getTransactions in order: current window + /// first, then the previous window. + void stubRanges({ + required List current, + required List previous, + }) { + var callIndex = 0; + when( + () => transactionRepository.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((invocation) async { + final isCurrentCall = callIndex.isEven; + callIndex++; + return Right(isCurrentCall ? current : previous); + }); + } + + void stubCapturingRanges( + List requestedStarts, + List requestedEnds, + ) { + when( + () => transactionRepository.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer((invocation) async { + requestedStarts.add(invocation.namedArguments[#startDate] as DateTime); + requestedEnds.add(invocation.namedArguments[#endDate] as DateTime); + return const Right([]); + }); + } + + group('timeframe range resolution', () { + test('thisMonth requests the full month and prior month', () async { + final requestedStarts = []; + final requestedEnds = []; + stubCapturingRanges(requestedStarts, requestedEnds); + + await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + expect(requestedStarts[0], DateTime.parse('2026-09-01')); + expect( + requestedEnds[0], + DateTime.parse('2026-09-30T23:59:59.999'), + ); + expect(requestedStarts[1], DateTime.parse('2026-08-01')); + expect( + requestedEnds[1], + DateTime.parse('2026-08-31T23:59:59.999'), + ); + }); + + test('lastQuarter requests Apr-Jun and Jan-Mar', () async { + final requestedStarts = []; + final requestedEnds = []; + stubCapturingRanges(requestedStarts, requestedEnds); + + await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.lastQuarter, + now: now, + ), + ); + + expect(requestedStarts[0], DateTime.parse('2026-04-01')); + expect( + requestedEnds[0], + DateTime.parse('2026-06-30T23:59:59.999'), + ); + expect(requestedStarts[1], DateTime.parse('2026-01-01')); + expect( + requestedEnds[1], + DateTime.parse('2026-03-31T23:59:59.999'), + ); + }); + + test('ytd requests Jan 1 to today and the same span last year', () async { + final requestedStarts = []; + final requestedEnds = []; + stubCapturingRanges(requestedStarts, requestedEnds); + + await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.ytd, + now: now, + ), + ); + + expect(requestedStarts[0], DateTime.parse('2026-01-01')); + expect(requestedEnds[0], DateTime.parse('2026-09-15T23:59:59.999')); + expect(requestedStarts[1], DateTime.parse('2025-01-01')); + expect(requestedEnds[1], DateTime.parse('2025-09-15T23:59:59.999')); + }); + }); + + group('aggregation', () { + test('rolls up L2/L3 transactions to pillars and computes shares', + () async { + stubRanges( + current: [ + tx( + '1', + 100, + TransactionType.expense, + envelopeUuid, + DateTime(2026, 9, 3), + ), + tx( + '2', + 100, + TransactionType.expense, + subUuid, + DateTime(2026, 9, 5), + ), // L2 direct + tx( + '3', + 300, + TransactionType.expense, + pillarUuid, + DateTime(2026, 9, 7), + ), // L1 direct + tx( + '4', + 300, + TransactionType.expense, + otherPillarUuid, + DateTime(2026, 9, 8), + ), + ], + previous: [ + tx( + '9', + 200, + TransactionType.expense, + pillarUuid, + DateTime(2026, 8, 2), + ), + ], + ); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + final summary = result.fold((_) => fail('expected Right'), (s) => s); + expect(summary.totalOutflow, 800); + expect(summary.totalInflow, 0); + expect(summary.outflowDelta.previous, 200); + expect(summary.outflowDelta.changePercent, closeTo(300, 0.001)); + expect(summary.outflowDelta.isNew, isFalse); + + // Sorted by activity: Essential 400, Lifestyle 300. + expect(summary.pillars, hasLength(2)); + final essentialPillar = summary.pillars.first; + expect(essentialPillar.pillar.uuid.getOrCrash(), pillarUuid); + expect(essentialPillar.outflow, 500); + expect(essentialPillar.shareOfTotalOutflow, 0.625); + // Envelopes: L1 direct 300, Groceries 100, G&H 100 (name tiebreak). + expect( + essentialPillar.envelopes.map((e) => e.category.name.getOrCrash()), + ['Essential', 'Groceries', 'Groceries & Household'], + ); + expect(essentialPillar.envelopes.last.outflow, 100); + expect(essentialPillar.envelopes.last.shareOfPillar, closeTo(0.2, 0.001)); + expect(essentialPillar.envelopes.last.transactionCount, 1); + }); + + test('inflow includes income and investment transactions', () async { + stubRanges( + current: [ + tx( + '10', + 500, + TransactionType.income, + salaryPillarUuid, + DateTime(2026, 9, 2), + ), + tx( + '11', + 50, + TransactionType.investment, + envelopeUuid, + DateTime(2026, 9, 4), + ), + ], + previous: [ + tx( + '12', + 100, + TransactionType.income, + salaryPillarUuid, + DateTime(2026, 8, 2), + ), + ], + ); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + final summary = result.fold((_) => fail('expected Right'), (s) => s); + expect(summary.totalInflow, 550); + expect(summary.totalOutflow, 0); + expect(summary.inflowDelta.changePercent, closeTo(450, 0.001)); + }); + + test( + 'groups missing-category transactions under one Uncategorized ' + 'bucket', () async { + stubRanges( + current: [ + tx( + '20', + 40, + TransactionType.expense, + missingUuid, + DateTime(2026, 9, 3), + ), + tx( + '21', + 60, + TransactionType.expense, + missingUuid, + DateTime(2026, 9, 4), + ), + ], + previous: [], + ); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + final summary = result.fold((_) => fail('expected Right'), (s) => s); + expect(summary.pillars, hasLength(1)); + final bucket = summary.pillars.first; + expect(bucket.pillar.name.getOrCrash(), 'Uncategorized'); + expect(bucket.envelopes, hasLength(1)); + expect(bucket.envelopes.first.outflow, 100); + expect(bucket.envelopes.first.transactionCount, 2); + expect(bucket.envelopes.first.shareOfPillar, 1.0); + expect(summary.outflowDelta.isNew, isTrue); + expect(summary.outflowDelta.changePercent, isNull); + }); + + test('handles empty windows without division errors', () async { + stubRanges(current: [], previous: []); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + final summary = result.fold((_) => fail('expected Right'), (s) => s); + expect(summary.totalOutflow, 0); + expect(summary.totalInflow, 0); + expect(summary.pillars, isEmpty); + expect(summary.outflowDelta.changePercent, isNull); + expect(summary.outflowDelta.isNew, isFalse); + expect(summary.inflowDelta.changePercent, isNull); + }); + + test('propagates transaction repository failure', () async { + when( + () => transactionRepository.getTransactions( + startDate: any(named: 'startDate'), + endDate: any(named: 'endDate'), + ), + ).thenAnswer( + (_) async => const Left(Failure.localFailure(message: 'db error')), + ); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + expect(result.isLeft(), isTrue); + result.fold( + (failure) => expect(failure.message, contains('db error')), + (_) => fail('expected Left'), + ); + }); + + test('propagates category repository failure', () async { + when(() => categoryRepository.watchCategories()).thenAnswer( + (_) => Stream.value( + const Left(Failure.localFailure(message: 'cat boom')), + ), + ); + stubRanges(current: [], previous: []); + + final result = await useCase( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: now, + ), + ); + + expect(result.isLeft(), isTrue); + }); + }); +} + +Category _category({ + required String uuid, + required String name, + required CategoryType type, + String? parentId, +}) { + return Category( + uuid: UniqueId(uuid), + name: StringSingleLine(name), + isSynced: false, + updatedAt: DateTime(2026), + type: type, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, + parentId: parentId != null ? UniqueId(parentId) : null, + ); +} From eea569fb12f2a36c265a45fbf5a395bc66472838 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Mon, 14 Sep 2026 00:53:01 +0700 Subject: [PATCH 2/5] feat(insights): add InsightsCubit and state management (U2) Implements U2 of docs/plans/2026-09-10-001-feat-insights-analytics-plan.md: - InsightsState mirrors DashboardState: InsightsStatus (initial/loading/loaded/failure), selectedTimeframe (default thisMonth), summary?, and dartz failureOption with copyWith(clearSummary). - InsightsCubit fetches on demand (load, selectTimeframe, refresh) and never subscribes to live streams. A request token discards stale responses so rapid timeframe switches can't overwrite the newest result. nowProvider is injected for deterministic test resolution. - Registered via @injectable (DI config regenerated). Six cubit tests: initial state, loading->loaded transition order, failure status with populated failureOption, timeframe switch fetching new params only, refresh, and the stale-response guard (Completer- driven). Note: mocktail's constructor param for the now-provider needed a typedef (injectable can't resolve bare function types). --- .../presentation/blocs/insights_cubit.dart | 79 +++++++++ .../presentation/blocs/insights_state.dart | 40 +++++ .../blocs/insights_cubit_test.dart | 160 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 lib/features/insights/presentation/blocs/insights_cubit.dart create mode 100644 lib/features/insights/presentation/blocs/insights_state.dart create mode 100644 test/features/insights/presentation/blocs/insights_cubit_test.dart diff --git a/lib/features/insights/presentation/blocs/insights_cubit.dart b/lib/features/insights/presentation/blocs/insights_cubit.dart new file mode 100644 index 0000000..690dae5 --- /dev/null +++ b/lib/features/insights/presentation/blocs/insights_cubit.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:dartz/dartz.dart'; +import 'package:equatable/equatable.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:expense_tracker/features/insights/domain/usecases/get_insights_summary_usecase.dart'; +import 'package:injectable/injectable.dart'; + +part 'insights_state.dart'; + +/// Injectable can't resolve bare function types in constructor params. +typedef InsightsNowProvider = DateTime Function(); + +/// Fetches insights summaries on demand (open, timeframe switch, +/// pull-to-refresh) — never subscribes to live streams. Stale responses +/// from rapid timeframe switches are discarded; the newest request wins. +@injectable +class InsightsCubit extends Cubit { + InsightsCubit( + this._loadInsightsSummary, { + InsightsNowProvider? nowProvider, + }) : _nowProvider = nowProvider ?? DateTime.now, + super(const InsightsState()); + + final GetInsightsSummaryUseCase _loadInsightsSummary; + final InsightsNowProvider _nowProvider; + + int _requestToken = 0; + + /// Fetches the currently selected timeframe. + Future load() => _fetch(state.selectedTimeframe); + + /// Switches the timeframe and fetches it. + Future selectTimeframe(InsightsTimeframe timeframe) { + if (timeframe != state.selectedTimeframe) { + emit(state.copyWith(selectedTimeframe: timeframe)); + } + return _fetch(timeframe); + } + + /// Re-fetches the current timeframe (pull-to-refresh). + Future refresh() => _fetch(state.selectedTimeframe); + + Future _fetch(InsightsTimeframe timeframe) async { + final token = ++_requestToken; + emit( + state.copyWith( + status: InsightsStatus.loading, + failureOption: const None(), + ), + ); + + final result = await _loadInsightsSummary( + GetInsightsSummaryParams(timeframe: timeframe, now: _nowProvider()), + ); + + // A newer request superseded this one — drop the stale response. + if (token != _requestToken) return; + + result.fold( + (failure) => emit( + state.copyWith( + status: InsightsStatus.failure, + failureOption: Some(failure), + ), + ), + (summary) => emit( + state.copyWith( + status: InsightsStatus.loaded, + summary: summary, + failureOption: const None(), + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/blocs/insights_state.dart b/lib/features/insights/presentation/blocs/insights_state.dart new file mode 100644 index 0000000..a25b191 --- /dev/null +++ b/lib/features/insights/presentation/blocs/insights_state.dart @@ -0,0 +1,40 @@ +part of 'insights_cubit.dart'; + +enum InsightsStatus { initial, loading, loaded, failure } + +class InsightsState extends Equatable { + const InsightsState({ + this.status = InsightsStatus.initial, + this.selectedTimeframe = InsightsTimeframe.thisMonth, + this.summary, + this.failureOption = const None(), + }); + + final InsightsStatus status; + final InsightsTimeframe selectedTimeframe; + final InsightsSummary? summary; + final Option failureOption; + + InsightsState copyWith({ + InsightsStatus? status, + InsightsTimeframe? selectedTimeframe, + InsightsSummary? summary, + bool clearSummary = false, + Option? failureOption, + }) { + return InsightsState( + status: status ?? this.status, + selectedTimeframe: selectedTimeframe ?? this.selectedTimeframe, + summary: clearSummary ? null : (summary ?? this.summary), + failureOption: failureOption ?? this.failureOption, + ); + } + + @override + List get props => [ + status, + selectedTimeframe, + summary, + failureOption, + ]; +} diff --git a/test/features/insights/presentation/blocs/insights_cubit_test.dart b/test/features/insights/presentation/blocs/insights_cubit_test.dart new file mode 100644 index 0000000..1b95a3d --- /dev/null +++ b/test/features/insights/presentation/blocs/insights_cubit_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:expense_tracker/features/insights/domain/usecases/get_insights_summary_usecase.dart'; +import 'package:expense_tracker/features/insights/presentation/blocs/insights_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockGetInsightsSummaryUseCase extends Mock + implements GetInsightsSummaryUseCase {} + +void main() { + late MockGetInsightsSummaryUseCase useCase; + late InsightsCubit cubit; + + final fixedNow = DateTime(2026, 9, 15, 10); + + final summary = InsightsSummary( + totalOutflow: 500, + totalInflow: 300, + outflowDelta: InsightsDelta.calculate(current: 500, previous: 400), + inflowDelta: InsightsDelta.calculate(current: 300, previous: 350), + pillars: const [], + ); + + final thisMonthParams = GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: fixedNow, + ); + final lastQuarterParams = GetInsightsSummaryParams( + timeframe: InsightsTimeframe.lastQuarter, + now: fixedNow, + ); + + setUpAll(() { + registerFallbackValue( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: fixedNow, + ), + ); + }); + + setUp(() { + useCase = MockGetInsightsSummaryUseCase(); + cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); + }); + + tearDown(() => cubit.close()); + + /// Collects emitted states for transition assertions. + List emittedStates(InsightsCubit cubit) { + final states = []; + cubit.stream.listen(states.add); + return states; + } + + test('initial state is initial + thisMonth with no summary', () { + expect(cubit.state.status, InsightsStatus.initial); + expect(cubit.state.selectedTimeframe, InsightsTimeframe.thisMonth); + expect(cubit.state.summary, isNull); + expect(cubit.state.failureOption.isNone(), isTrue); + }); + + test('load emits loading then loaded with the summary', () async { + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) async => Right(summary)); + final states = emittedStates(cubit); + + await cubit.load(); + // Broadcast deliveries land in microtasks — flush before asserting. + await Future.delayed(Duration.zero); + + expect( + states.map((s) => s.status).toList(), + const [InsightsStatus.loading, InsightsStatus.loaded], + ); + expect(cubit.state.summary, same(summary)); + expect(cubit.state.failureOption.isNone(), isTrue); + verify(() => useCase.call(thisMonthParams)).called(1); + }); + + test('load emits failure with the failure option populated', () async { + const failure = Failure.localFailure(message: 'boom'); + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) async => const Left(failure)); + + await cubit.load(); + + expect(cubit.state.status, InsightsStatus.failure); + expect(cubit.state.failureOption.isSome(), isTrue); + expect( + cubit.state.failureOption.fold(() => null, (f) => f.message), + contains('boom'), + ); + expect(cubit.state.summary, isNull); + }); + + test('selectTimeframe re-fetches with the new timeframe params', () async { + final quarterSummary = InsightsSummary( + totalOutflow: 100, + totalInflow: 200, + outflowDelta: InsightsDelta.calculate(current: 100, previous: 90), + inflowDelta: InsightsDelta.calculate(current: 200, previous: 180), + pillars: const [], + ); + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) async => Right(summary)); + when(() => useCase.call(lastQuarterParams)) + .thenAnswer((_) async => Right(quarterSummary)); + + await cubit.selectTimeframe(InsightsTimeframe.lastQuarter); + + expect(cubit.state.selectedTimeframe, InsightsTimeframe.lastQuarter); + expect(cubit.state.status, InsightsStatus.loaded); + expect(cubit.state.summary, same(quarterSummary)); + verify(() => useCase.call(lastQuarterParams)).called(1); + verifyNever(() => useCase.call(thisMonthParams)); + }); + + test('refresh re-fetches the current timeframe', () async { + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) async => Right(summary)); + + await cubit.refresh(); + + verify(() => useCase.call(thisMonthParams)).called(1); + }); + + test('rapid timeframe switches keep only the newest response', () async { + final staleCompleter = Completer>(); + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) => staleCompleter.future); + when(() => useCase.call(lastQuarterParams)) + .thenAnswer((_) async => const Right(quarterSummaryFixture)); + + final loadFuture = cubit.load(); // thisMonth — slow (stale) + await cubit.selectTimeframe(InsightsTimeframe.lastQuarter); // fast, new + expect(cubit.state.summary, same(quarterSummaryFixture)); + + // The stale response lands late and must be discarded. + staleCompleter.complete(Right(summary)); + await loadFuture; + + expect(cubit.state.summary, same(quarterSummaryFixture)); + expect(cubit.state.status, InsightsStatus.loaded); + expect(cubit.state.selectedTimeframe, InsightsTimeframe.lastQuarter); + }); +} + +const quarterSummaryFixture = InsightsSummary( + totalOutflow: 100, + totalInflow: 200, + outflowDelta: InsightsDelta(current: 100, previous: 90, isNew: false), + inflowDelta: InsightsDelta(current: 200, previous: 150, isNew: false), + pillars: [], +); From 11af5dfe8b6de97b0d7f06360e20b9b41d0aeff7 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Tue, 15 Sep 2026 19:54:53 +0700 Subject: [PATCH 3/5] feat(insights): add Atelier presentation widgets (U3) Implements U3 of docs/plans/2026-09-10-001-feat-insights-analytics-plan.md: - TimeframeFilterRow: capsule segmented control (This Month / Last Quarter / YTD), gradient-filled active segment, Manrope/Inter. - InsightsHeroCard: primary-gradient hero with total outflow/inflow and direction-aware delta chips (outflow up = red, inflow up = green; "NEW" when the previous window was silent, dash when fully silent). - PillarDistributionChart: multi-segment outflow bar split by shareOfTotalOutflow with rotating segment colours (Portfolio- DistributionCard precedent), legend with share/amount, muted track when no spending. Income pillars take no bar segment. - EnvelopeDrillDownList: per-pillar sections with envelope rows (icon, name, breadcrumb, share bar, amount); zero-activity pillars omitted. Small plan-faithful amendment: EnvelopeInsight gained a breadcrumb field computed in the usecase via getBreadcrumbPath so widgets stay data-dumb. Eight widget tests cover the filter callback, delta chip variants (positive/NEW/dash), proportional bar segments via ValueKeys, legend shares, breadcrumbs, and silent-pillar omission. --- .../domain/entities/insights_summary.dart | 6 + .../get_insights_summary_usecase.dart | 29 +- .../widgets/envelope_drill_down_list.dart | 210 +++++++++++ .../widgets/insights_hero_card.dart | 200 +++++++++++ .../widgets/pillar_distribution_chart.dart | 158 ++++++++ .../widgets/timeframe_filter_row.dart | 86 +++++ .../widgets/insights_widgets_test.dart | 340 ++++++++++++++++++ 7 files changed, 1023 insertions(+), 6 deletions(-) create mode 100644 lib/features/insights/presentation/widgets/envelope_drill_down_list.dart create mode 100644 lib/features/insights/presentation/widgets/insights_hero_card.dart create mode 100644 lib/features/insights/presentation/widgets/pillar_distribution_chart.dart create mode 100644 lib/features/insights/presentation/widgets/timeframe_filter_row.dart create mode 100644 test/features/insights/presentation/widgets/insights_widgets_test.dart diff --git a/lib/features/insights/domain/entities/insights_summary.dart b/lib/features/insights/domain/entities/insights_summary.dart index 9e6a238..9a2d055 100644 --- a/lib/features/insights/domain/entities/insights_summary.dart +++ b/lib/features/insights/domain/entities/insights_summary.dart @@ -113,6 +113,7 @@ class PillarInsight extends Equatable { class EnvelopeInsight extends Equatable { const EnvelopeInsight({ required this.category, + required this.breadcrumb, required this.outflow, required this.inflow, required this.transactionCount, @@ -120,6 +121,10 @@ class EnvelopeInsight extends Equatable { }); final Category category; + + /// Full hierarchy path of [category] + /// (e.g. "Essential › Groceries & Household › Groceries"). + final String breadcrumb; final double outflow; final double inflow; final int transactionCount; @@ -131,6 +136,7 @@ class EnvelopeInsight extends Equatable { @override List get props => [ category, + breadcrumb, outflow, inflow, transactionCount, diff --git a/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart index 268b5bd..90b8ecb 100644 --- a/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart +++ b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart @@ -102,7 +102,10 @@ class GetInsightsSummaryUseCase ); final envelope = accumulator.envelopes.putIfAbsent( envelopeKey, - () => _EnvelopeAccumulator(category: category), + () => _EnvelopeAccumulator( + category: category, + breadcrumb: category.getBreadcrumbPath(categories), + ), ); if (transaction.type == TransactionType.expense) { @@ -124,7 +127,10 @@ class GetInsightsSummaryUseCase final pillars = pillarAccumulators.values .map( - (accumulator) => accumulator.toInsight(totalOutflow: totalOutflow), + (accumulator) => accumulator.toInsight( + totalOutflow: totalOutflow, + categories: categories, + ), ) .toList() ..sort(_compareByActivity); @@ -180,17 +186,22 @@ class GetInsightsSummaryParams extends Equatable { } class _EnvelopeAccumulator { - _EnvelopeAccumulator({required this.category}); + _EnvelopeAccumulator({required this.category, required this.breadcrumb}); final Category category; + final String breadcrumb; double outflow = 0; double inflow = 0; int transactionCount = 0; - EnvelopeInsight toInsight({required double pillarActivity}) { + EnvelopeInsight toInsight({ + required double pillarActivity, + required List categories, + }) { final activity = outflow + inflow; return EnvelopeInsight( category: category, + breadcrumb: category.getBreadcrumbPath(categories), outflow: outflow, inflow: inflow, transactionCount: transactionCount, @@ -207,7 +218,10 @@ class _PillarAccumulator { double inflow = 0; final Map envelopes = {}; - PillarInsight toInsight({required double totalOutflow}) { + PillarInsight toInsight({ + required double totalOutflow, + required List categories, + }) { final pillarActivity = outflow + inflow; return PillarInsight( pillar: pillar, @@ -216,7 +230,10 @@ class _PillarAccumulator { shareOfTotalOutflow: totalOutflow == 0 ? 0 : outflow / totalOutflow, envelopes: envelopes.values .map( - (envelope) => envelope.toInsight(pillarActivity: pillarActivity), + (envelope) => envelope.toInsight( + pillarActivity: pillarActivity, + categories: categories, + ), ) .toList() ..sort((a, b) { diff --git a/lib/features/insights/presentation/widgets/envelope_drill_down_list.dart b/lib/features/insights/presentation/widgets/envelope_drill_down_list.dart new file mode 100644 index 0000000..2172e62 --- /dev/null +++ b/lib/features/insights/presentation/widgets/envelope_drill_down_list.dart @@ -0,0 +1,210 @@ +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Per-pillar envelope breakdown: a header per pillar with its rolled-up +/// total, then one row per direct-category bucket with breadcrumb, +/// activity share, and amount. Pillars without activity are omitted. +class EnvelopeDrillDownList extends StatelessWidget { + const EnvelopeDrillDownList({required this.pillars, super.key}); + + final List pillars; + + static const _muted = Color(0xFF757682); + static const _trackColor = Color(0xFFEDEEEF); + + static const _segmentColors = [ + Color(0xFF88D982), + Color(0xFFB3C5FF), + Color(0xFFFF524C), + ]; + + Color _segmentColor(int index) { + switch (index % _segmentColors.length) { + case 0: + return _segmentColors[0]; + case 1: + return _segmentColors[1]; + default: + return _segmentColors[2]; + } + } + + String _formatAmount(double amount) { + return NumberFormat.currency( + symbol: 'IDR ', + decimalDigits: 0, + ).format(amount); + } + + @override + Widget build(BuildContext context) { + final activePillars = + pillars.where((pillar) => pillar.outflow + pillar.inflow > 0).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < activePillars.length; i++) ...[ + _buildPillarSection(activePillars[i], _segmentColor(i)), + if (i != activePillars.length - 1) const SizedBox(height: 20), + ], + ], + ); + } + + Widget _buildPillarSection(PillarInsight pillar, Color accent) { + final isOutflowSide = pillar.outflow > 0; + final total = isOutflowSide ? pillar.outflow : pillar.inflow; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: accent, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + pillar.pillar.name.getOrCrash().toUpperCase(), + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + letterSpacing: 0.5, + ), + ), + ), + Text( + _formatAmount(total), + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + ], + ), + const SizedBox(height: 8), + ...pillar.envelopes.map( + (envelope) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildEnvelopeRow(envelope, accent, isOutflowSide), + ), + ), + ], + ); + } + + Widget _buildEnvelopeRow( + EnvelopeInsight envelope, + Color accent, + bool isOutflowSide, + ) { + final name = envelope.category.name.getOrCrash(); + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE1E3E4)), + ), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: const Color(0xFFF3F4F5), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _iconForCategory(name), + size: 16, + color: const Color(0xFF757682), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w600, + color: const Color(0xFF191C1D), + ), + ), + const SizedBox(height: 2), + Text( + envelope.breadcrumb, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 10.5, + color: _muted, + ), + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(100), + child: SizedBox( + height: 4, + child: Stack( + children: [ + Container(color: _trackColor), + FractionallySizedBox( + widthFactor: envelope.shareOfPillar.clamp(0.0, 1.0), + child: ColoredBox(color: accent), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + _formatAmount(isOutflowSide ? envelope.outflow : envelope.inflow), + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + ], + ), + ); + } + + IconData _iconForCategory(String name) { + final lower = name.toLowerCase(); + if (lower.contains('grocer') || lower.contains('market')) { + return Icons.shopping_basket_outlined; + } else if (lower.contains('din') || lower.contains('food')) { + return Icons.restaurant_outlined; + } else if (lower.contains('rent') || lower.contains('home')) { + return Icons.home_outlined; + } else if (lower.contains('fuel') || lower.contains('transport')) { + return Icons.local_gas_station_outlined; + } else if (lower.contains('salary') || lower.contains('income')) { + return Icons.payments_outlined; + } else if (lower.contains('util') || lower.contains('electric')) { + return Icons.bolt_outlined; + } + return Icons.category_outlined; + } +} diff --git a/lib/features/insights/presentation/widgets/insights_hero_card.dart b/lib/features/insights/presentation/widgets/insights_hero_card.dart new file mode 100644 index 0000000..ca3be56 --- /dev/null +++ b/lib/features/insights/presentation/widgets/insights_hero_card.dart @@ -0,0 +1,200 @@ +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Gradient hero showing total outflow/inflow with period-over-period +/// delta chips. +class InsightsHeroCard extends StatelessWidget { + const InsightsHeroCard({required this.summary, super.key}); + + final InsightsSummary summary; + + static const _gradientStart = Color(0xFF00113A); + static const _gradientEnd = Color(0xFF002366); + static const _green = Color(0xFF3CD150); + static const _red = Color(0xFFFF6B6B); + + String _formatAmount(double amount) { + return NumberFormat.currency( + symbol: 'IDR ', + decimalDigits: 0, + ).format(amount); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [_gradientStart, _gradientEnd], + ), + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: _gradientStart.withValues(alpha: 0.3), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'TOTAL OUTFLOW', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 2, + color: Colors.white.withValues(alpha: 0.7), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + _formatAmount(summary.totalOutflow), + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + ), + _DeltaChip(delta: summary.outflowDelta, isOutflow: true), + ], + ), + const SizedBox(height: 16), + Divider( + height: 1, + color: Colors.white.withValues(alpha: 0.1), + ), + const SizedBox(height: 16), + _buildFlowRow( + icon: Icons.south_west, + label: 'INFLOW', + amount: summary.totalInflow, + delta: summary.inflowDelta, + isOutflow: false, + ), + ], + ), + ); + } + + Widget _buildFlowRow({ + required IconData icon, + required String label, + required double amount, + required InsightsDelta delta, + required bool isOutflow, + }) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + icon, + color: Colors.white.withValues(alpha: 0.8), + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 1.5, + color: Colors.white.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 2), + Text( + _formatAmount(amount), + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + _DeltaChip(delta: delta, isOutflow: isOutflow), + ], + ); + } +} + +/// Direction-aware delta chip: an outflow increase is bad (red), an +/// inflow increase is good (green). Zero previous activity shows a +/// neutral "NEW" chip; a completely silent period shows a dash. +class _DeltaChip extends StatelessWidget { + const _DeltaChip({required this.delta, required this.isOutflow}); + + final InsightsDelta delta; + final bool isOutflow; + + @override + Widget build(BuildContext context) { + final Color bgColor; + final Color fgColor; + + if (delta.isNew) { + bgColor = Colors.white.withValues(alpha: 0.12); + fgColor = Colors.white.withValues(alpha: 0.85); + return _chip(bgColor, fgColor, 'NEW'); + } + + final changePercent = delta.changePercent; + if (changePercent == null) { + bgColor = Colors.white.withValues(alpha: 0.12); + fgColor = Colors.white.withValues(alpha: 0.5); + return _chip(bgColor, fgColor, '—'); + } + + final increased = changePercent > 0; + final isGood = isOutflow ? !increased : increased; + bgColor = (isGood ? InsightsHeroCard._green : InsightsHeroCard._red) + .withValues(alpha: 0.18); + fgColor = isGood ? InsightsHeroCard._green : InsightsHeroCard._red; + + final arrow = increased ? '▲' : '▼'; + return _chip(bgColor, fgColor, '$arrow ${changePercent.round().abs()}%'); + } + + Widget _chip(Color background, Color foreground, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(100), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: foreground, + ), + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/pillar_distribution_chart.dart b/lib/features/insights/presentation/widgets/pillar_distribution_chart.dart new file mode 100644 index 0000000..36d3337 --- /dev/null +++ b/lib/features/insights/presentation/widgets/pillar_distribution_chart.dart @@ -0,0 +1,158 @@ +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Multi-segment progress bar showing each expense pillar's share of +/// total outflow, with a legend underneath. Income pillars don't take +/// bar segments (outflow-only distribution); their inflow lives in the +/// hero card and the drill-down list. +class PillarDistributionChart extends StatelessWidget { + const PillarDistributionChart({required this.summary, super.key}); + + final InsightsSummary summary; + + // Segment colours rotating per pillar, matching the + // PortfolioDistributionCard distribution bar. + static const _segmentColors = [ + Color(0xFF88D982), // Essential — secondary-fixed-dim + Color(0xFFB3C5FF), // Lifestyle — primary-fixed-dim + Color(0xFFFF524C), // Growth — on-tertiary-container + ]; + + String _formatAmount(double amount) { + return NumberFormat.currency( + symbol: 'IDR ', + decimalDigits: 0, + ).format(amount); + } + + @override + Widget build(BuildContext context) { + final expensePillars = + summary.pillars.where((pillar) => pillar.outflow > 0).toList(); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF5F6F8), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Where Your Money Went', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w800, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 16), + _buildBar(expensePillars), + const SizedBox(height: 16), + _buildLegend(expensePillars), + ], + ), + ); + } + + Widget _buildBar(List expensePillars) { + if (expensePillars.isEmpty) { + return Container( + height: 12, + decoration: BoxDecoration( + color: const Color(0xFFEDEEEF), + borderRadius: BorderRadius.circular(100), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(100), + child: SizedBox( + height: 12, + child: Row( + children: [ + for (var i = 0; i < expensePillars.length; i++) + Expanded( + key: ValueKey('pillar_segment_$i'), + flex: _permille(expensePillars[i]), + child: ColoredBox(color: _segmentColor(i)), + ), + ], + ), + ), + ); + } + + int _permille(PillarInsight pillar) { + final share = pillar.shareOfTotalOutflow.clamp(0.0, 1.0); + return (share * 1000).round().clamp(1, 1000); + } + + Widget _buildLegend(List expensePillars) { + return Column( + children: [ + for (var i = 0; i < expensePillars.length; i++) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _segmentColor(i), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + expensePillars[i].pillar.name.getOrCrash(), + overflow: TextOverflow.ellipsis, + style: GoogleFonts.manrope( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF191C1D), + ), + ), + ), + Text( + _shareLabel(expensePillars[i].shareOfTotalOutflow), + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF757682), + ), + ), + const SizedBox(width: 8), + Text( + _formatAmount(expensePillars[i].outflow), + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF00113A), + ), + ), + ], + ), + ), + ], + ); + } + + String _shareLabel(double share) => '${(share * 100).round()}%'; + + Color _segmentColor(int index) { + switch (index % _segmentColors.length) { + case 0: + return _segmentColors[0]; + case 1: + return _segmentColors[1]; + default: + return _segmentColors[2]; + } + } +} diff --git a/lib/features/insights/presentation/widgets/timeframe_filter_row.dart b/lib/features/insights/presentation/widgets/timeframe_filter_row.dart new file mode 100644 index 0000000..f999d60 --- /dev/null +++ b/lib/features/insights/presentation/widgets/timeframe_filter_row.dart @@ -0,0 +1,86 @@ +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// Capsule segmented control for the three analysis timeframes. +class TimeframeFilterRow extends StatelessWidget { + const TimeframeFilterRow({ + required this.selected, + required this.onTimeframeChanged, + super.key, + }); + + final InsightsTimeframe selected; + final ValueChanged onTimeframeChanged; + + static const _trackColor = Color(0xFFF3F4F5); + static const _inactiveColor = Color(0xFF444650); + static const _gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF00113A), Color(0xFF002366)], + ); + + static const _labels = { + InsightsTimeframe.thisMonth: 'This Month', + InsightsTimeframe.lastQuarter: 'Last Quarter', + InsightsTimeframe.ytd: 'YTD', + }; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: _trackColor, + borderRadius: BorderRadius.circular(100), + ), + child: Row( + children: [ + for (final timeframe in InsightsTimeframe.values) ...[ + if (timeframe != InsightsTimeframe.thisMonth) + const SizedBox(width: 4), + Expanded( + child: _buildSegment(timeframe), + ), + ], + ], + ), + ); + } + + Widget _buildSegment(InsightsTimeframe timeframe) { + final isActive = timeframe == selected; + final label = _labels[timeframe]!; + + return GestureDetector( + onTap: () => onTimeframeChanged(timeframe), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + gradient: isActive ? _gradient : null, + borderRadius: BorderRadius.circular(100), + ), + child: Center( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: isActive + ? GoogleFonts.manrope( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white, + ) + : GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w500, + color: _inactiveColor, + ), + ), + ), + ), + ); + } +} diff --git a/test/features/insights/presentation/widgets/insights_widgets_test.dart b/test/features/insights/presentation/widgets/insights_widgets_test.dart new file mode 100644 index 0000000..7a5548f --- /dev/null +++ b/test/features/insights/presentation/widgets/insights_widgets_test.dart @@ -0,0 +1,340 @@ +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/envelope_drill_down_list.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/insights_hero_card.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/pillar_distribution_chart.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/timeframe_filter_row.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Fixtures ─────────────────────────────────────────────────────────────── + +const pillarUuid = '11111111-1111-4111-8111-111111111111'; +const groceriesUuid = '33333333-3333-4333-8333-333333333333'; +const lifestyleUuid = '44444444-4444-4444-8444-444444444444'; +const salaryUuid = '55555555-5555-4555-8555-555555555555'; + +Category _category({ + required String uuid, + required String name, + required CategoryType type, + String? parentId, +}) { + return Category( + uuid: UniqueId(uuid), + name: StringSingleLine(name), + isSynced: false, + updatedAt: DateTime(2026), + type: type, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, + parentId: parentId != null ? UniqueId(parentId) : null, + ); +} + +final _essential = _category( + uuid: pillarUuid, + name: 'Essential', + type: CategoryType.expense, +); +final _groceries = _category( + uuid: groceriesUuid, + name: 'Groceries', + type: CategoryType.expense, + parentId: pillarUuid, +); +final _lifestyle = _category( + uuid: lifestyleUuid, + name: 'Lifestyle', + type: CategoryType.expense, +); +final _salary = _category( + uuid: salaryUuid, + name: 'Salary', + type: CategoryType.income, +); + +PillarInsight _pillar( + Category pillar, { + required double outflow, + required double inflow, + required double shareOfTotalOutflow, + required List envelopes, +}) { + return PillarInsight( + pillar: pillar, + outflow: outflow, + inflow: inflow, + shareOfTotalOutflow: shareOfTotalOutflow, + envelopes: envelopes, + ); +} + +EnvelopeInsight _envelope( + Category category, { + required double outflow, + required double inflow, + required int transactionCount, + required double shareOfPillar, +}) { + return EnvelopeInsight( + category: category, + breadcrumb: category + .getBreadcrumbPath([_essential, _groceries, _lifestyle, _salary]), + outflow: outflow, + inflow: inflow, + transactionCount: transactionCount, + shareOfPillar: shareOfPillar, + ); +} + +final _summary = InsightsSummary( + totalOutflow: 800, + totalInflow: 700, + outflowDelta: InsightsDelta.calculate(current: 800, previous: 200), + inflowDelta: InsightsDelta.calculate(current: 700, previous: 500), + pillars: [ + _pillar( + _essential, + outflow: 600, + inflow: 0, + shareOfTotalOutflow: 0.75, + envelopes: [ + _envelope( + _groceries, + outflow: 400, + inflow: 0, + transactionCount: 2, + shareOfPillar: 2 / 3, + ), + _envelope( + _essential, + outflow: 200, + inflow: 0, + transactionCount: 1, + shareOfPillar: 1 / 3, + ), + ], + ), + _pillar( + _lifestyle, + outflow: 200, + inflow: 0, + shareOfTotalOutflow: 0.25, + envelopes: [ + _envelope( + _lifestyle, + outflow: 200, + inflow: 0, + transactionCount: 1, + shareOfPillar: 1, + ), + ], + ), + _pillar( + _salary, + outflow: 0, + inflow: 700, + shareOfTotalOutflow: 0, + envelopes: [ + _envelope( + _salary, + outflow: 0, + inflow: 700, + transactionCount: 1, + shareOfPillar: 1, + ), + ], + ), + ], +); + +const _emptySummary = InsightsSummary( + totalOutflow: 0, + totalInflow: 0, + outflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + inflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + pillars: [], +); + +// Tests ────────────────────────────────────────────────────────────────── + +void main() { + group('TimeframeFilterRow', () { + testWidgets('renders the three timeframe labels', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimeframeFilterRow( + selected: InsightsTimeframe.thisMonth, + onTimeframeChanged: (_) {}, + ), + ), + ), + ); + + expect(find.text('This Month'), findsOneWidget); + expect(find.text('Last Quarter'), findsOneWidget); + expect(find.text('YTD'), findsOneWidget); + }); + + testWidgets('fires onTimeframeChanged when a segment is tapped', + (tester) async { + InsightsTimeframe? tapped; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimeframeFilterRow( + selected: InsightsTimeframe.thisMonth, + onTimeframeChanged: (t) => tapped = t, + ), + ), + ), + ); + + await tester.tap(find.text('Last Quarter')); + expect(tapped, InsightsTimeframe.lastQuarter); + }); + }); + + group('InsightsHeroCard', () { + testWidgets('renders formatted totals and a positive outflow delta', + (tester) async { + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: InsightsHeroCard(summary: _summary))), + ); + + expect(find.text('TOTAL OUTFLOW'), findsOneWidget); + expect(find.text('IDR 800'), findsOneWidget); + expect(find.text('IDR 700'), findsOneWidget); + // Outflow increased -> red ▲ chip; inflow increased -> green ▲. + expect(find.text('▲ 300%'), findsOneWidget); + expect(find.text('▲ 40%'), findsOneWidget); + }); + + testWidgets('shows NEW chip when the previous window was silent', + (tester) async { + const newSummary = InsightsSummary( + totalOutflow: 50, + totalInflow: 0, + outflowDelta: InsightsDelta(current: 50, previous: 0, isNew: true), + inflowDelta: InsightsDelta( + current: 0, + previous: 0, + isNew: false, + ), + pillars: [], + ); + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: InsightsHeroCard(summary: newSummary)), + ), + ); + + expect(find.text('NEW'), findsOneWidget); + expect(find.text('—'), findsOneWidget); // silent inflow + }); + }); + + group('PillarDistributionChart', () { + testWidgets('splits the bar by outflow share and renders the legend', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PillarDistributionChart(summary: _summary), + ), + ), + ); + + // Income pillar takes no bar segment. + expect(find.byKey(const ValueKey('pillar_segment_0')), findsOneWidget); + expect(find.byKey(const ValueKey('pillar_segment_1')), findsOneWidget); + expect(find.byKey(const ValueKey('pillar_segment_2')), findsNothing); + + // Legend: expense pillars with amounts and shares. + expect(find.text('Essential'), findsOneWidget); + expect(find.text('Lifestyle'), findsOneWidget); + expect(find.text('75%'), findsOneWidget); // 600/800 + expect(find.text('25%'), findsOneWidget); // 200/800 + expect(find.text('IDR 600'), findsOneWidget); + expect(find.text('IDR 200'), findsOneWidget); + expect(find.text('Salary'), findsNothing); // no outflow share + }); + + testWidgets('renders a muted track when there is no outflow', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: PillarDistributionChart(summary: _emptySummary), + ), + ), + ); + + expect(find.byKey(const ValueKey('pillar_segment_0')), findsNothing); + expect(find.byType(Row), findsNothing); + }); + }); + + group('EnvelopeDrillDownList', () { + testWidgets('renders pillar sections with breadcrumbs and amounts', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EnvelopeDrillDownList(pillars: _summary.pillars), + ), + ), + ); + + expect(find.text('ESSENTIAL'), findsOneWidget); + expect(find.text('LIFESTYLE'), findsOneWidget); + expect(find.text('SALARY'), findsOneWidget); + expect(find.text('Groceries'), findsOneWidget); + expect( + find.text('Essential › Groceries'), + findsOneWidget, + ); + expect(find.text('IDR 400'), findsOneWidget); // groceries amount + expect(find.text('IDR 700'), findsNWidgets(2)); // salary header + row + }); + + testWidgets('omits pillars without activity', (tester) async { + final withSilentPillar = InsightsSummary( + totalOutflow: 0, + totalInflow: 0, + outflowDelta: const InsightsDelta( + current: 0, + previous: 0, + isNew: false, + ), + inflowDelta: const InsightsDelta( + current: 0, + previous: 0, + isNew: false, + ), + pillars: [ + _pillar( + _lifestyle, + outflow: 0, + inflow: 0, + shareOfTotalOutflow: 0, + envelopes: const [], + ), + ], + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: EnvelopeDrillDownList(pillars: withSilentPillar.pillars), + ), + ), + ); + + expect(find.text('LIFESTYLE'), findsNothing); + }); + }); +} From 9c2983ebe5ebc2374da23b0d19b37fd1d5b096d9 Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Wed, 16 Sep 2026 23:30:09 +0700 Subject: [PATCH 4/5] feat(insights): assemble insights page, nav activation, custom & all time (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements U4 of docs/plans/2026-09-10-001-feat-insights-analytics-plan.md plus two review-driven additions (Custom range and All Time timeframes): - InsightsPage: timeframe filter, gradient hero, distribution chart, and drill-down inside a pull-to-refresh view; flash-on-failure keeps previously loaded content; dedicated empty state for silent periods; insightsCubit test seam. The easter-egg "visit the Stats tab" trigger moves here from the deleted placeholder page. - Router: /stats now builds InsightsPage (BlocProvider + getIt()..load()); MainLayout renames the nav slot to "Insights" (donut icon), keeping the same index. Placeholder page deleted. - All Time timeframe: single unfiltered fetch, delta chips render a dash (no comparable previous window). - Custom range: 4th pill opens a hand-built fullscreen picker page (Flutter's built-in M3 picker is a centered dialog with no year dropdown) — month/year dropdown opens a 2000-2035 year grid, month chevrons, two-tap range selection with normalized boundaries, Save gated on a complete range. Previous window = equal-length span immediately before the range, so deltas stay meaningful. - Period label rendered under the filter row; cubit computes it per timeframe. Selecting a preset clears a picked custom range. 17 new tests: page render/refetch/failure/empty state, custom normalization and fetch, preset-clears-custom, allTime single fetch, custom passthrough with equal-length previous, five-pill rendering, custom callback. 163 tests pass; analyze/format/merged-runner clean. --- lib/app/router/app_router.dart | 8 +- lib/app/view/main_layout.dart | 4 +- .../pages/stats_coming_soon_page.dart | 27 -- .../domain/entities/insight_timeframe.dart | 31 +- .../domain/entities/insights_summary.dart | 6 + .../get_insights_summary_usecase.dart | 87 +++- .../presentation/blocs/insights_cubit.dart | 69 ++- .../presentation/blocs/insights_state.dart | 9 + .../presentation/pages/insights_page.dart | 213 +++++++++ .../widgets/custom_range_picker_page.dart | 412 ++++++++++++++++++ .../widgets/timeframe_filter_row.dart | 33 +- .../blocs/insights_cubit_test.dart | 43 ++ .../pages/insights_page_test.dart | 197 +++++++++ .../widgets/insights_widgets_test.dart | 23 + 14 files changed, 1094 insertions(+), 68 deletions(-) delete mode 100644 lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart create mode 100644 lib/features/insights/presentation/pages/insights_page.dart create mode 100644 lib/features/insights/presentation/widgets/custom_range_picker_page.dart create mode 100644 test/features/insights/presentation/pages/insights_page_test.dart diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index d49a7ea..29cbf89 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -4,8 +4,9 @@ import 'package:expense_tracker/features/category/presentation/pages/category_ma import 'package:expense_tracker/features/counter/presentation/pages/counter_page.dart'; import 'package:expense_tracker/features/dashboard/presentation/blocs/dashboard_cubit.dart'; import 'package:expense_tracker/features/dashboard/presentation/pages/home_page.dart'; -import 'package:expense_tracker/features/dashboard/presentation/pages/stats_coming_soon_page.dart'; import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/features/insights/presentation/blocs/insights_cubit.dart'; +import 'package:expense_tracker/features/insights/presentation/pages/insights_page.dart'; import 'package:expense_tracker/features/settings/presentation/pages/settings_page.dart'; import 'package:expense_tracker/features/transaction/domain/entities/transaction.dart'; import 'package:expense_tracker/features/transaction/presentation/blocs/transaction_cubit.dart'; @@ -50,7 +51,10 @@ GoRouter router([ GoRoute( path: '/stats', name: 'stats', - builder: (context, state) => const StatsComingSoonPage(), + builder: (context, state) => BlocProvider( + create: (context) => getIt()..load(), + child: const InsightsPage(), + ), ), GoRoute( path: '/settings', diff --git a/lib/app/view/main_layout.dart b/lib/app/view/main_layout.dart index 8206da9..f963a32 100644 --- a/lib/app/view/main_layout.dart +++ b/lib/app/view/main_layout.dart @@ -69,8 +69,8 @@ class MainLayout extends StatelessWidget { label: 'Transactions', ), BottomNavigationBarItem( - icon: Icon(Icons.bar_chart), - label: 'Stats', + icon: Icon(Icons.donut_small), + label: 'Insights', ), BottomNavigationBarItem( icon: Icon(Icons.settings), diff --git a/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart b/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart deleted file mode 100644 index 758a34d..0000000 --- a/lib/features/dashboard/presentation/pages/stats_coming_soon_page.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; -import 'package:expense_tracker/injector.dart'; -import 'package:flutter/material.dart'; - -class StatsComingSoonPage extends StatefulWidget { - const StatsComingSoonPage({super.key}); - - @override - State createState() => _StatsComingSoonPageState(); -} - -class _StatsComingSoonPageState extends State { - @override - void initState() { - super.initState(); - getIt().onStatsVisited(); - } - - @override - Widget build(BuildContext context) { - return const Scaffold( - body: Center( - child: Text('Coming Soon'), - ), - ); - } -} diff --git a/lib/features/insights/domain/entities/insight_timeframe.dart b/lib/features/insights/domain/entities/insight_timeframe.dart index 553179d..ecb3a16 100644 --- a/lib/features/insights/domain/entities/insight_timeframe.dart +++ b/lib/features/insights/domain/entities/insight_timeframe.dart @@ -4,11 +4,18 @@ import 'package:equatable/equatable.dart'; enum InsightsTimeframe { thisMonth, lastQuarter, - ytd; + ytd, + allTime, + custom; /// Resolves this timeframe against [now] into the current analysis /// range and the previous comparable range used for period-over-period /// deltas. All ranges are inclusive on both ends. + /// + /// [allTime] and [custom] have no derived window: all time is fetched + /// unfiltered, and a custom range is supplied by the caller — see + /// `GetInsightsSummaryParams.customRange` and the equal-length + /// previous-window rule in the usecase. InsightsWindow resolve(DateTime now) { switch (this) { case InsightsTimeframe.thisMonth: @@ -58,6 +65,11 @@ enum InsightsTimeframe { current: DateRange(start: start, end: end), previous: DateRange(start: previousStart, end: previousEnd), ); + case InsightsTimeframe.allTime: + case InsightsTimeframe.custom: + throw UnsupportedError( + '$name has no derived window — the usecase handles it directly', + ); } } @@ -73,8 +85,25 @@ class DateRange extends Equatable { final DateTime start; final DateTime end; + /// Number of days covered, counting both end days (never zero). + /// Callers should pass midnight-normalized boundaries so the count is + /// exact. + int get daySpan => end.difference(start).inDays + 1; + bool contains(DateTime date) => !date.isBefore(start) && !date.isAfter(end); + /// The equal-length window immediately before this one — the + /// period-over-period comparison for custom ranges. + DateRange previousEqualLength() { + final previousEnd = start.subtract(const Duration(milliseconds: 1)); + final previousStart = DateTime( + previousEnd.year, + previousEnd.month, + previousEnd.day, + ).subtract(Duration(days: daySpan - 1)); + return DateRange(start: previousStart, end: previousEnd); + } + @override List get props => [start, end]; } diff --git a/lib/features/insights/domain/entities/insights_summary.dart b/lib/features/insights/domain/entities/insights_summary.dart index 9a2d055..1f1fd3c 100644 --- a/lib/features/insights/domain/entities/insights_summary.dart +++ b/lib/features/insights/domain/entities/insights_summary.dart @@ -10,6 +10,7 @@ class InsightsSummary extends Equatable { required this.outflowDelta, required this.inflowDelta, required this.pillars, + this.periodLabel = '', }); /// Sum of expense-transaction amounts in the current window. @@ -24,8 +25,13 @@ class InsightsSummary extends Equatable { final InsightsDelta outflowDelta; final InsightsDelta inflowDelta; + /// Human-readable window label ("This Month", "Apr 1 – Jun 30, 2026", + /// "All Time", "Sep 1 – Sep 10"). + final String periodLabel; + @override List get props => [ + periodLabel, totalOutflow, totalInflow, outflowDelta, diff --git a/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart index 90b8ecb..5bbb3ab 100644 --- a/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart +++ b/lib/features/insights/domain/usecases/get_insights_summary_usecase.dart @@ -34,42 +34,72 @@ class GetInsightsSummaryUseCase Future> call( GetInsightsSummaryParams params, ) async { - final window = params.timeframe.resolve(params.now); + final window = _resolveWindow(params); final categoriesResult = await _categoryRepository.watchCategories().first; final currentResult = await _transactionRepository.getTransactions( - startDate: window.current.start, - endDate: window.current.end, - ); - final previousResult = await _transactionRepository.getTransactions( - startDate: window.previous.start, - endDate: window.previous.end, + startDate: window?.current.start, + endDate: window?.current.end, ); + var previous = const []; + if (window != null) { + final previousResult = await _transactionRepository.getTransactions( + startDate: window.previous.start, + endDate: window.previous.end, + ); + previous = previousResult.fold((_) => [], (t) => t); + } final failure = categoriesResult.fold((f) => f, (_) => null) ?? - currentResult.fold((f) => f, (_) => null) ?? - previousResult.fold((f) => f, (_) => null); + currentResult.fold((f) => f, (_) => null); if (failure != null) { return Left(failure); } final categories = categoriesResult.fold((_) => [], (c) => c); final current = currentResult.fold((_) => [], (t) => t); - final previous = previousResult.fold((_) => [], (t) => t); return Right( _aggregate( currentTransactions: current, previousTransactions: previous, categories: categories, + hasPreviousWindow: window != null, + periodLabel: params.periodLabel, ), ); } + /// Null window means "all time" — a single unfiltered fetch with no + /// period-over-period comparison. + InsightsWindow? _resolveWindow(GetInsightsSummaryParams params) { + switch (params.timeframe) { + case InsightsTimeframe.allTime: + return null; + case InsightsTimeframe.custom: + final customRange = params.customRange; + if (customRange == null) { + throw ArgumentError( + 'timeframe custom requires customRange in the params', + ); + } + return InsightsWindow( + current: customRange, + previous: customRange.previousEqualLength(), + ); + case InsightsTimeframe.thisMonth: + case InsightsTimeframe.lastQuarter: + case InsightsTimeframe.ytd: + return params.timeframe.resolve(params.now); + } + } + InsightsSummary _aggregate({ required List currentTransactions, required List previousTransactions, required List categories, + required bool hasPreviousWindow, + required String periodLabel, }) { final categoryByUuid = { for (final category in categories) category.uuid.getOrCrash(): category, @@ -135,17 +165,27 @@ class GetInsightsSummaryUseCase .toList() ..sort(_compareByActivity); + // Without a previous window (All Time) the deltas render as a dash + // instead of a meaningless comparison. + final outflowDelta = hasPreviousWindow + ? InsightsDelta.calculate( + current: totalOutflow, + previous: previousOutflow, + ) + : InsightsDelta(current: totalOutflow, previous: 0, isNew: false); + final inflowDelta = hasPreviousWindow + ? InsightsDelta.calculate( + current: totalInflow, + previous: previousInflow, + ) + : InsightsDelta(current: totalInflow, previous: 0, isNew: false); + return InsightsSummary( + periodLabel: periodLabel, totalOutflow: totalOutflow, totalInflow: totalInflow, - outflowDelta: InsightsDelta.calculate( - current: totalOutflow, - previous: previousOutflow, - ), - inflowDelta: InsightsDelta.calculate( - current: totalInflow, - previous: previousInflow, - ), + outflowDelta: outflowDelta, + inflowDelta: inflowDelta, pillars: pillars, ); } @@ -176,13 +216,22 @@ class GetInsightsSummaryParams extends Equatable { const GetInsightsSummaryParams({ required this.timeframe, required this.now, + this.customRange, + this.periodLabel = '', }); final InsightsTimeframe timeframe; final DateTime now; + /// Required when [timeframe] is `custom`; ignored otherwise. + final DateRange? customRange; + + /// Human-readable window label computed by the caller so the summary + /// carries everything the UI renders. + final String periodLabel; + @override - List get props => [timeframe, now]; + List get props => [timeframe, now, customRange, periodLabel]; } class _EnvelopeAccumulator { diff --git a/lib/features/insights/presentation/blocs/insights_cubit.dart b/lib/features/insights/presentation/blocs/insights_cubit.dart index 690dae5..de594c5 100644 --- a/lib/features/insights/presentation/blocs/insights_cubit.dart +++ b/lib/features/insights/presentation/blocs/insights_cubit.dart @@ -8,6 +8,7 @@ import 'package:expense_tracker/features/insights/domain/entities/insight_timefr import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; import 'package:expense_tracker/features/insights/domain/usecases/get_insights_summary_usecase.dart'; import 'package:injectable/injectable.dart'; +import 'package:intl/intl.dart'; part 'insights_state.dart'; @@ -21,7 +22,7 @@ typedef InsightsNowProvider = DateTime Function(); class InsightsCubit extends Cubit { InsightsCubit( this._loadInsightsSummary, { - InsightsNowProvider? nowProvider, + @ignoreParam InsightsNowProvider? nowProvider, }) : _nowProvider = nowProvider ?? DateTime.now, super(const InsightsState()); @@ -33,17 +34,69 @@ class InsightsCubit extends Cubit { /// Fetches the currently selected timeframe. Future load() => _fetch(state.selectedTimeframe); - /// Switches the timeframe and fetches it. + /// Switches to a preset timeframe and fetches it. Clears any custom + /// range so refresh() keeps the preset behavior. Future selectTimeframe(InsightsTimeframe timeframe) { - if (timeframe != state.selectedTimeframe) { - emit(state.copyWith(selectedTimeframe: timeframe)); + if (timeframe == InsightsTimeframe.custom) { + throw ArgumentError( + 'use selectCustomRange for the custom timeframe', + ); } + emit( + state.copyWith( + selectedTimeframe: timeframe, + clearCustomRange: true, + ), + ); return _fetch(timeframe); } + /// Picks a custom range, switches the timeframe to custom, and + /// fetches. Boundaries are normalized to full days. + Future selectCustomRange(DateTime start, DateTime end) { + final normalized = DateRange( + start: DateTime(start.year, start.month, start.day), + end: DateTime(end.year, end.month, end.day, 23, 59, 59, 999), + ); + emit( + state.copyWith( + selectedTimeframe: InsightsTimeframe.custom, + customRange: normalized, + ), + ); + return _fetch(InsightsTimeframe.custom); + } + /// Re-fetches the current timeframe (pull-to-refresh). Future refresh() => _fetch(state.selectedTimeframe); + String _periodLabel( + InsightsTimeframe timeframe, + DateRange? customRange, + DateTime now, + ) { + switch (timeframe) { + case InsightsTimeframe.thisMonth: + return DateFormat('MMMM yyyy').format(now); + case InsightsTimeframe.lastQuarter: + final window = timeframe.resolve(now); + return '${DateFormat('MMM').format(window.current.start)} – ' + '${DateFormat('MMM yyyy').format(window.current.end)}'; + case InsightsTimeframe.ytd: + return 'Jan 1 – ${DateFormat('MMM d, yyyy').format(now)}'; + case InsightsTimeframe.allTime: + return 'All Time'; + case InsightsTimeframe.custom: + final range = customRange; + if (range == null) return 'Custom'; + final sameYear = range.start.year == range.end.year; + final startFormat = + sameYear ? DateFormat('d MMM') : DateFormat('d MMM yyyy'); + return '${startFormat.format(range.start)} – ' + '${DateFormat('d MMM yyyy').format(range.end)}'; + } + } + Future _fetch(InsightsTimeframe timeframe) async { final token = ++_requestToken; emit( @@ -53,8 +106,14 @@ class InsightsCubit extends Cubit { ), ); + final now = _nowProvider(); final result = await _loadInsightsSummary( - GetInsightsSummaryParams(timeframe: timeframe, now: _nowProvider()), + GetInsightsSummaryParams( + timeframe: timeframe, + now: now, + customRange: state.customRange, + periodLabel: _periodLabel(timeframe, state.customRange, now), + ), ); // A newer request superseded this one — drop the stale response. diff --git a/lib/features/insights/presentation/blocs/insights_state.dart b/lib/features/insights/presentation/blocs/insights_state.dart index a25b191..59849f6 100644 --- a/lib/features/insights/presentation/blocs/insights_state.dart +++ b/lib/features/insights/presentation/blocs/insights_state.dart @@ -6,18 +6,25 @@ class InsightsState extends Equatable { const InsightsState({ this.status = InsightsStatus.initial, this.selectedTimeframe = InsightsTimeframe.thisMonth, + this.customRange, this.summary, this.failureOption = const None(), }); final InsightsStatus status; final InsightsTimeframe selectedTimeframe; + + /// Set when the user picks a custom range; reset when a preset + /// timeframe is selected. + final DateRange? customRange; final InsightsSummary? summary; final Option failureOption; InsightsState copyWith({ InsightsStatus? status, InsightsTimeframe? selectedTimeframe, + DateRange? customRange, + bool clearCustomRange = false, InsightsSummary? summary, bool clearSummary = false, Option? failureOption, @@ -25,6 +32,7 @@ class InsightsState extends Equatable { return InsightsState( status: status ?? this.status, selectedTimeframe: selectedTimeframe ?? this.selectedTimeframe, + customRange: clearCustomRange ? null : (customRange ?? this.customRange), summary: clearSummary ? null : (summary ?? this.summary), failureOption: failureOption ?? this.failureOption, ); @@ -34,6 +42,7 @@ class InsightsState extends Equatable { List get props => [ status, selectedTimeframe, + customRange, summary, failureOption, ]; diff --git a/lib/features/insights/presentation/pages/insights_page.dart b/lib/features/insights/presentation/pages/insights_page.dart new file mode 100644 index 0000000..033173e --- /dev/null +++ b/lib/features/insights/presentation/pages/insights_page.dart @@ -0,0 +1,213 @@ +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/core/presentation/mixins/failure_message_handler.dart'; +import 'package:expense_tracker/features/easter_egg/presentation/blocs/easter_egg_cubit.dart'; +import 'package:expense_tracker/features/insights/presentation/blocs/insights_cubit.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/custom_range_picker_page.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/envelope_drill_down_list.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/insights_hero_card.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/pillar_distribution_chart.dart'; +import 'package:expense_tracker/features/insights/presentation/widgets/timeframe_filter_row.dart'; +import 'package:expense_tracker/injector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// The Insights screen: timeframe filter, gradient hero, pillar +/// distribution bar, and the per-envelope drill-down. +class InsightsPage extends StatelessWidget { + const InsightsPage({super.key, this.insightsCubit}); + + /// Overridable for tests; defaults to the app-wide singleton. + final InsightsCubit? insightsCubit; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: insightsCubit ?? getIt(), + child: const _InsightsView(), + ); + } +} + +class _InsightsView extends StatefulWidget { + const _InsightsView(); + + @override + State<_InsightsView> createState() => _InsightsViewState(); +} + +class _InsightsViewState extends State<_InsightsView> + with FailureMessageHandler { + @override + void initState() { + super.initState(); + // The easter-egg ritual step "visit the Stats tab" — the Insights + // page replaced the placeholder, so the trigger moves with it. + getIt().onStatsVisited(); + context.read().load(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FA), + appBar: AppBar( + title: Text( + 'Insights', + style: GoogleFonts.manrope( + fontWeight: FontWeight.w800, + fontSize: 24, + color: const Color(0xFF00113A), + letterSpacing: -0.5, + ), + ), + backgroundColor: const Color(0xFFF8F9FA), + elevation: 0, + ), + body: BlocConsumer( + listener: (context, state) { + state.failureOption.fold( + () {}, + (failure) => handleFailure(context, failure), + ); + }, + builder: (context, state) { + if (state.status == InsightsStatus.loading && state.summary == null) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF00113A)), + ); + } + + final failure = state.failureOption.fold(() => null, (f) => f); + + return RefreshIndicator( + onRefresh: () => context.read().refresh(), + color: const Color(0xFF00113A), + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24), + children: [ + if (failure != null && state.summary == null) + _buildFailureCard(failure) + else ...[ + TimeframeFilterRow( + selected: state.selectedTimeframe, + onTimeframeChanged: (timeframe) => context + .read() + .selectTimeframe(timeframe), + onCustomSelected: () => _pickCustomRange(context), + ), + if (state.summary != null) ...[ + const SizedBox(height: 12), + Text( + state.summary!.periodLabel, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF757682), + ), + ), + const SizedBox(height: 8), + InsightsHeroCard(summary: state.summary!), + const SizedBox(height: 16), + if (state.summary!.pillars.isEmpty) + _buildEmptyState() + else ...[ + PillarDistributionChart(summary: state.summary!), + const SizedBox(height: 16), + EnvelopeDrillDownList( + pillars: state.summary!.pillars, + ), + ], + ] else ...[ + _buildEmptyState(), + ], + ], + ], + ), + ); + }, + ), + ); + } + + /// Content stays visible through a refetch failure when a previous + /// summary exists — the failure surfaces as a flash only. + + Widget _buildFailureCard(Failure failure) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFFDAD6)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Color(0xFFBA1A1A)), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Could not load insights. Pull to retry.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF444650), + ), + ), + ), + ], + ), + ); + } + + Future _pickCustomRange(BuildContext context) async { + final picked = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => const CustomRangePickerPage(), + ), + ); + if (picked == null || !context.mounted) return; + await context + .read() + .selectCustomRange(picked.start, picked.end); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + const Icon( + Icons.insights_outlined, + size: 48, + color: Color(0xFF757682), + ), + const SizedBox(height: 12), + Text( + 'Nothing to analyze yet', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.bold, + color: const Color(0xFF00113A), + ), + ), + const SizedBox(height: 8), + Text( + 'Log a transaction in this period and it will show up here.', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF444650), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/insights/presentation/widgets/custom_range_picker_page.dart b/lib/features/insights/presentation/widgets/custom_range_picker_page.dart new file mode 100644 index 0000000..d1603e8 --- /dev/null +++ b/lib/features/insights/presentation/widgets/custom_range_picker_page.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +/// Fullscreen custom range picker in the Financial Atelier style: +/// a calendar with a month/year dropdown (tap the header to jump to a +/// year), prev/next month chevrons, and a two-tap range selection. +/// +/// Pops with a [DateTimeRange] (start at midnight, end at 23:59:59.999) +/// or null when dismissed. +class CustomRangePickerPage extends StatefulWidget { + const CustomRangePickerPage({super.key}); + + @override + State createState() => _CustomRangePickerPageState(); +} + +class _CustomRangePickerPageState extends State { + static const _primary = Color(0xFF00113A); + static const _surface = Color(0xFFF8F9FA); + static const _onSurface = Color(0xFF191C1D); + static const _muted = Color(0xFF757682); + + DateTime? _start; + DateTime? _end; + late DateTime _displayedMonth; + + bool get _rangeComplete => _start != null && _end != null; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _displayedMonth = DateTime(now.year, now.month); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: _surface, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildRangeDisplay(), + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildMonthYearDropdown(), + ), + const SizedBox(height: 8), + Expanded( + child: SingleChildScrollView( + child: _buildDayGrid(), + ), + ), + ], + ), + ), + ); + } + + /// Hand-built day grid: one dropdown + chevrons only — the built-in + /// CalendarDatePicker's own header would duplicate them. + Widget _buildDayGrid() { + final year = _displayedMonth.year; + final month = _displayedMonth.month; + final firstDay = DateTime(year, month, 1); + final leadingBlanks = firstDay.weekday % 7; // Sunday-first grid + final daysInMonth = DateTime(year, month + 1, 0).day; + final today = DateTime.now(); + final todayDate = DateTime(today.year, today.month, today.day); + + final cells = [ + for (final weekday in ['S', 'M', 'T', 'W', 'T', 'F', 'S']) + Center( + child: Text( + weekday, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _muted, + ), + ), + ), + ]; + + for (var i = 0; i < leadingBlanks; i++) { + cells.add(const SizedBox.shrink()); + } + + for (var day = 1; day <= daysInMonth; day++) { + final date = DateTime(year, month, day); + final isSelectable = !date.isAfter(todayDate); + final isStart = _start == date; + final isEnd = _end == date; + final isInRange = _start != null && + _end != null && + date.isAfter(_start!) && + date.isBefore(_end!); + final isToday = date == todayDate && !isStart && !isEnd; + + cells.add( + GestureDetector( + onTap: isSelectable ? () => _onDateChanged(date) : null, + child: AspectRatio( + aspectRatio: 1, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: isStart || isEnd + ? _primary + : isInRange + ? _primary.withValues(alpha: 0.08) + : Colors.transparent, + shape: + isStart || isEnd ? BoxShape.circle : BoxShape.rectangle, + border: isToday && !isStart && !isEnd && isSelectable + ? Border.all(color: _primary) + : null, + borderRadius: + isStart || isEnd ? null : BorderRadius.circular(100), + ), + child: Center( + child: Text( + '$day', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: + isStart || isEnd ? FontWeight.w700 : FontWeight.w500, + color: isStart || isEnd + ? Colors.white + : isSelectable + ? _onSurface + : _muted, + ), + ), + ), + ), + ), + ), + ), + ); + } + + return Column( + children: [ + Row(children: cells.take(7).toList()), + GridView.count( + crossAxisCount: 7, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(vertical: 4), + children: cells.skip(7).toList(), + ), + ], + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + color: _onSurface, + onPressed: () => Navigator.of(context).pop(), + ), + const Spacer(), + TextButton( + onPressed: _rangeComplete ? _save : null, + style: TextButton.styleFrom( + backgroundColor: _rangeComplete ? _primary : Colors.transparent, + foregroundColor: _rangeComplete ? Colors.white : _muted, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 10, + ), + shape: const StadiumBorder(), + ), + child: Text( + 'Save', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); + } + + Widget _buildRangeDisplay() { + String label; + if (_start == null) { + label = 'Pick a start date'; + } else if (_end == null) { + label = '${_shortDate(_start!)} – …'; + } else { + label = '${_shortDate(_start!)} – ${_shortDate(_end!)}'; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Select period', + style: GoogleFonts.inter( + fontSize: 12, + color: _muted, + ), + ), + Text( + label, + style: GoogleFonts.manrope( + fontSize: 30, + fontWeight: FontWeight.w800, + color: _onSurface, + ), + ), + ], + ); + } + + Widget _buildMonthYearDropdown() { + final monthLabel = DateFormat('MMMM yyyy').format(_displayedMonth); + return Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _showYearGrid, + child: Row( + children: [ + Text( + monthLabel, + style: GoogleFonts.manrope( + fontSize: 15, + fontWeight: FontWeight.w700, + color: _onSurface, + ), + ), + const SizedBox(width: 6), + const Icon( + Icons.arrow_drop_down, + size: 22, + color: _onSurface, + ), + ], + ), + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _previousMonth, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextMonth, + ), + ], + ), + ], + ); + } + + void _previousMonth() { + setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month - 1, + ); + }); + } + + void _nextMonth() { + setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + 1, + ); + }); + } + + void _showYearGrid() { + final years = List.generate(36, (index) => 2000 + index); // 2000..2035 + final currentYear = DateTime.now().year; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 12), + child: Text( + 'Select year', + style: GoogleFonts.manrope( + fontSize: 16, + fontWeight: FontWeight.w800, + color: _primary, + ), + ), + ), + Flexible( + child: GridView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only( + left: 24, + right: 24, + bottom: 16, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.4, + ), + itemCount: years.length, + itemBuilder: (context, index) { + final year = years[index]; + final isCurrentYear = year == currentYear; + final isSelectedYear = year == _displayedMonth.year; + return Padding( + padding: const EdgeInsets.all(6), + child: Material( + color: isSelectedYear + ? _primary.withValues(alpha: 0.12) + : Colors.white, + borderRadius: BorderRadius.circular(100), + child: InkWell( + borderRadius: BorderRadius.circular(100), + onTap: () { + Navigator.of(sheetContext).pop(); + setState(() { + _displayedMonth = DateTime( + year, + _displayedMonth.month, + ); + }); + }, + child: Center( + child: Text( + '$year', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: isCurrentYear || isSelectedYear + ? FontWeight.w800 + : FontWeight.w500, + color: isCurrentYear ? _primary : _onSurface, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _onDateChanged(DateTime picked) { + final day = DateTime(picked.year, picked.month, picked.day); + setState(() { + if (_start == null || (_start != null && _end != null)) { + // Fresh selection. + _start = day; + _end = null; + } else if (!day.isBefore(_start!)) { + _end = day; + } else { + // Tapped an earlier day — restart the range from it. + _start = day; + } + }); + } + + void _save() { + if (!_rangeComplete) return; + Navigator.of(context).pop( + DateTimeRange( + start: DateTime(_start!.year, _start!.month, _start!.day), + end: DateTime( + _end!.year, + _end!.month, + _end!.day, + 23, + 59, + 59, + 999, + ), + ), + ); + } + + String _shortDate(DateTime date) => DateFormat('MMM d').format(date); +} diff --git a/lib/features/insights/presentation/widgets/timeframe_filter_row.dart b/lib/features/insights/presentation/widgets/timeframe_filter_row.dart index f999d60..5e4bd39 100644 --- a/lib/features/insights/presentation/widgets/timeframe_filter_row.dart +++ b/lib/features/insights/presentation/widgets/timeframe_filter_row.dart @@ -2,16 +2,20 @@ import 'package:expense_tracker/features/insights/domain/entities/insight_timefr import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -/// Capsule segmented control for the three analysis timeframes. +/// Capsule segmented control for the analysis timeframes. Five options +/// don't fit a phone width, so the row scrolls horizontally; a Custom +/// selection opens the date-range picker from the page. class TimeframeFilterRow extends StatelessWidget { const TimeframeFilterRow({ required this.selected, required this.onTimeframeChanged, + required this.onCustomSelected, super.key, }); final InsightsTimeframe selected; final ValueChanged onTimeframeChanged; + final VoidCallback onCustomSelected; static const _trackColor = Color(0xFFF3F4F5); static const _inactiveColor = Color(0xFF444650); @@ -25,6 +29,8 @@ class TimeframeFilterRow extends StatelessWidget { InsightsTimeframe.thisMonth: 'This Month', InsightsTimeframe.lastQuarter: 'Last Quarter', InsightsTimeframe.ytd: 'YTD', + InsightsTimeframe.allTime: 'All Time', + InsightsTimeframe.custom: 'Custom', }; @override @@ -35,16 +41,17 @@ class TimeframeFilterRow extends StatelessWidget { color: _trackColor, borderRadius: BorderRadius.circular(100), ), - child: Row( - children: [ - for (final timeframe in InsightsTimeframe.values) ...[ - if (timeframe != InsightsTimeframe.thisMonth) - const SizedBox(width: 4), - Expanded( - child: _buildSegment(timeframe), - ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final timeframe in InsightsTimeframe.values) ...[ + if (timeframe != InsightsTimeframe.thisMonth) + const SizedBox(width: 4), + _buildSegment(timeframe), + ], ], - ], + ), ), ); } @@ -54,11 +61,13 @@ class TimeframeFilterRow extends StatelessWidget { final label = _labels[timeframe]!; return GestureDetector( - onTap: () => onTimeframeChanged(timeframe), + onTap: () => timeframe == InsightsTimeframe.custom + ? onCustomSelected() + : onTimeframeChanged(timeframe), child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), decoration: BoxDecoration( gradient: isActive ? _gradient : null, borderRadius: BorderRadius.circular(100), diff --git a/test/features/insights/presentation/blocs/insights_cubit_test.dart b/test/features/insights/presentation/blocs/insights_cubit_test.dart index 1b95a3d..d697d34 100644 --- a/test/features/insights/presentation/blocs/insights_cubit_test.dart +++ b/test/features/insights/presentation/blocs/insights_cubit_test.dart @@ -29,10 +29,22 @@ void main() { final thisMonthParams = GetInsightsSummaryParams( timeframe: InsightsTimeframe.thisMonth, now: fixedNow, + periodLabel: 'September 2026', ); final lastQuarterParams = GetInsightsSummaryParams( timeframe: InsightsTimeframe.lastQuarter, now: fixedNow, + periodLabel: 'Apr – Jun 2026', + ); + final customRange = DateRange( + start: DateTime(2026, 3, 3), + end: DateTime(2026, 3, 10, 23, 59, 59, 999), + ); + final customParams = GetInsightsSummaryParams( + timeframe: InsightsTimeframe.custom, + now: fixedNow, + customRange: customRange, + periodLabel: '3 Mar – 10 Mar 2026', ); setUpAll(() { @@ -130,6 +142,37 @@ void main() { verify(() => useCase.call(thisMonthParams)).called(1); }); + test('selectCustomRange normalizes boundaries and fetches', () async { + var customSummaryBuilt = false; + when(() => useCase.call(customParams)).thenAnswer((_) async { + customSummaryBuilt = true; + return const Right(quarterSummaryFixture); + }); + + await cubit.selectCustomRange( + DateTime(2026, 3, 3, 14), // time-of-day normalized away + DateTime(2026, 3, 10), + ); + + expect(cubit.state.selectedTimeframe, InsightsTimeframe.custom); + expect(cubit.state.customRange, customRange); + expect(customSummaryBuilt, isTrue); + }); + + test('selecting a preset clears a previously picked custom range', () async { + when(() => useCase.call(customParams)) + .thenAnswer((_) async => const Right(quarterSummaryFixture)); + await cubit.selectCustomRange(DateTime(2026, 3, 3), DateTime(2026, 3, 10)); + expect(cubit.state.customRange, isNotNull); + + when(() => useCase.call(thisMonthParams)) + .thenAnswer((_) async => Right(summary)); + await cubit.selectTimeframe(InsightsTimeframe.thisMonth); + + expect(cubit.state.customRange, isNull); + expect(cubit.state.selectedTimeframe, InsightsTimeframe.thisMonth); + }); + test('rapid timeframe switches keep only the newest response', () async { final staleCompleter = Completer>(); when(() => useCase.call(thisMonthParams)) diff --git a/test/features/insights/presentation/pages/insights_page_test.dart b/test/features/insights/presentation/pages/insights_page_test.dart new file mode 100644 index 0000000..f4242e8 --- /dev/null +++ b/test/features/insights/presentation/pages/insights_page_test.dart @@ -0,0 +1,197 @@ +import 'package:dartz/dartz.dart'; +import 'package:expense_tracker/core/domain/failures/failure.dart'; +import 'package:expense_tracker/features/category/domain/entities/category.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insight_timeframe.dart'; +import 'package:expense_tracker/features/insights/domain/entities/insights_summary.dart'; +import 'package:expense_tracker/features/insights/domain/usecases/get_insights_summary_usecase.dart'; +import 'package:expense_tracker/features/insights/presentation/blocs/insights_cubit.dart'; +import 'package:expense_tracker/features/insights/presentation/pages/insights_page.dart'; +import 'package:expense_tracker/shared/domain/entities/value_objects.dart'; +import 'package:expense_tracker/shared/flash/presentation/blocs/cubit/flash_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../../helpers/helpers.dart'; + +class MockGetInsightsSummaryUseCase extends Mock + implements GetInsightsSummaryUseCase {} + +void main() { + late MockGetInsightsSummaryUseCase useCase; + + final fixedNow = DateTime(2026, 9, 15, 10); + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await configureInjector(); + registerFallbackValue( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.thisMonth, + now: DateTime(2026, 9, 15, 10), + ), + ); + }); + + final essential = _category( + uuid: '11111111-1111-4111-8111-111111111111', + name: 'Essential', + type: CategoryType.expense, + ); + final groceries = _category( + uuid: '33333333-3333-4333-8333-333333333333', + name: 'Groceries', + type: CategoryType.expense, + parentId: '11111111-1111-4111-8111-111111111111', + ); + + final summary = InsightsSummary( + totalOutflow: 600, + totalInflow: 250, + outflowDelta: InsightsDelta.calculate(current: 600, previous: 500), + inflowDelta: InsightsDelta.calculate(current: 250, previous: 300), + pillars: [ + PillarInsight( + pillar: essential, + outflow: 600, + inflow: 0, + shareOfTotalOutflow: 1.0, + envelopes: [ + EnvelopeInsight( + category: groceries, + breadcrumb: 'Essential › Groceries', + outflow: 600, + inflow: 0, + transactionCount: 3, + shareOfPillar: 1.0, + ), + ], + ), + ], + ); + + setUp(() { + useCase = MockGetInsightsSummaryUseCase(); + }); + + /// Pumps the page with an injected cubit plus the app-root FlashCubit + /// provider (the failure listener flashes through it). + Future pumpPage(WidgetTester tester, InsightsCubit cubit) { + return tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: cubit), + BlocProvider(create: (_) => FlashCubit()), + ], + child: MaterialApp(home: InsightsPage(insightsCubit: cubit)), + ), + ); + } + + testWidgets('renders hero, chart, and drill-down after load', (tester) async { + when(() => useCase.call(any())).thenAnswer( + (_) async => Right(summary), + ); + final cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); + + await pumpPage(tester, cubit); + await tester.pumpAndSettle(); + + expect(find.text('TOTAL OUTFLOW'), findsOneWidget); + expect(find.text('Insights'), findsOneWidget); + expect(find.text('Where Your Money Went'), findsOneWidget); + expect(find.text('ESSENTIAL'), findsOneWidget); + expect(find.text('Essential › Groceries'), findsOneWidget); + }); + + testWidgets('tapping a timeframe refetches with new params', (tester) async { + when(() => useCase.call(any())).thenAnswer( + (_) async => Right( + InsightsSummary( + totalOutflow: 0, + totalInflow: 0, + outflowDelta: + const InsightsDelta(current: 0, previous: 0, isNew: false), + inflowDelta: + const InsightsDelta(current: 0, previous: 0, isNew: false), + pillars: const [], + ), + ), + ); + final cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); + + await pumpPage(tester, cubit); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Last Quarter')); + await tester.pumpAndSettle(); + + verify( + () => useCase.call( + GetInsightsSummaryParams( + timeframe: InsightsTimeframe.lastQuarter, + now: fixedNow, + periodLabel: 'Apr – Jun 2026', + ), + ), + ).called(1); + }); + + testWidgets('shows a failure card when load fails without data', + (tester) async { + when(() => useCase.call(any())).thenAnswer( + (_) async => Left(Failure.localFailure(message: 'db exploded')), + ); + final cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); + + await pumpPage(tester, cubit); + await tester.pumpAndSettle(); + + expect( + find.text('Could not load insights. Pull to retry.'), findsOneWidget); + }); + + testWidgets('shows the empty state when the period has no activity', + (tester) async { + when(() => useCase.call(any())).thenAnswer( + (_) async => Right( + InsightsSummary( + totalOutflow: 0, + totalInflow: 0, + outflowDelta: + const InsightsDelta(current: 0, previous: 0, isNew: false), + inflowDelta: + const InsightsDelta(current: 0, previous: 0, isNew: false), + pillars: const [], + ), + ), + ); + final cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); + + await pumpPage(tester, cubit); + await tester.pumpAndSettle(); + + expect(find.text('Nothing to analyze yet'), findsOneWidget); + }); +} + +Category _category({ + required String uuid, + required String name, + required CategoryType type, + String? parentId, +}) { + return Category( + uuid: UniqueId(uuid), + name: StringSingleLine(name), + isSynced: false, + updatedAt: DateTime(2026), + type: type, + expectedMonthlyBudget: 0, + behavioralModifier: BehavioralModifier.active, + parentId: parentId != null ? UniqueId(parentId) : null, + ); +} diff --git a/test/features/insights/presentation/widgets/insights_widgets_test.dart b/test/features/insights/presentation/widgets/insights_widgets_test.dart index 7a5548f..bf98059 100644 --- a/test/features/insights/presentation/widgets/insights_widgets_test.dart +++ b/test/features/insights/presentation/widgets/insights_widgets_test.dart @@ -170,6 +170,7 @@ void main() { body: TimeframeFilterRow( selected: InsightsTimeframe.thisMonth, onTimeframeChanged: (_) {}, + onCustomSelected: () {}, ), ), ), @@ -180,6 +181,27 @@ void main() { expect(find.text('YTD'), findsOneWidget); }); + testWidgets('shows all five options and fires onCustomSelected', + (tester) async { + var customTapped = false; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TimeframeFilterRow( + selected: InsightsTimeframe.thisMonth, + onTimeframeChanged: (_) {}, + onCustomSelected: () => customTapped = true, + ), + ), + ), + ); + + expect(find.text('All Time'), findsOneWidget); + expect(find.text('Custom'), findsOneWidget); + await tester.tap(find.text('Custom')); + expect(customTapped, isTrue); + }); + testWidgets('fires onTimeframeChanged when a segment is tapped', (tester) async { InsightsTimeframe? tapped; @@ -189,6 +211,7 @@ void main() { body: TimeframeFilterRow( selected: InsightsTimeframe.thisMonth, onTimeframeChanged: (t) => tapped = t, + onCustomSelected: () {}, ), ), ), From 5980de6bbb678ecaefc718f3f9fbc1583d19dcbc Mon Sep 17 00:00:00 2001 From: MF-Rozi Date: Fri, 18 Sep 2026 23:19:34 +0700 Subject: [PATCH 5/5] style: fix remaining analyzer lints on insights (full-scope analyze) --- .../widgets/custom_range_picker_page.dart | 2 +- .../pages/insights_page_test.dart | 30 +++++++++---------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/lib/features/insights/presentation/widgets/custom_range_picker_page.dart b/lib/features/insights/presentation/widgets/custom_range_picker_page.dart index d1603e8..5812e0e 100644 --- a/lib/features/insights/presentation/widgets/custom_range_picker_page.dart +++ b/lib/features/insights/presentation/widgets/custom_range_picker_page.dart @@ -69,7 +69,7 @@ class _CustomRangePickerPageState extends State { Widget _buildDayGrid() { final year = _displayedMonth.year; final month = _displayedMonth.month; - final firstDay = DateTime(year, month, 1); + final firstDay = DateTime(year, month); final leadingBlanks = firstDay.weekday % 7; // Sunday-first grid final daysInMonth = DateTime(year, month + 1, 0).day; final today = DateTime.now(); diff --git a/test/features/insights/presentation/pages/insights_page_test.dart b/test/features/insights/presentation/pages/insights_page_test.dart index f4242e8..e208d7d 100644 --- a/test/features/insights/presentation/pages/insights_page_test.dart +++ b/test/features/insights/presentation/pages/insights_page_test.dart @@ -58,7 +58,7 @@ void main() { pillar: essential, outflow: 600, inflow: 0, - shareOfTotalOutflow: 1.0, + shareOfTotalOutflow: 1, envelopes: [ EnvelopeInsight( category: groceries, @@ -66,7 +66,7 @@ void main() { outflow: 600, inflow: 0, transactionCount: 3, - shareOfPillar: 1.0, + shareOfPillar: 1, ), ], ), @@ -109,15 +109,13 @@ void main() { testWidgets('tapping a timeframe refetches with new params', (tester) async { when(() => useCase.call(any())).thenAnswer( - (_) async => Right( + (_) async => const Right( InsightsSummary( totalOutflow: 0, totalInflow: 0, - outflowDelta: - const InsightsDelta(current: 0, previous: 0, isNew: false), - inflowDelta: - const InsightsDelta(current: 0, previous: 0, isNew: false), - pillars: const [], + outflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + inflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + pillars: [], ), ), ); @@ -143,7 +141,7 @@ void main() { testWidgets('shows a failure card when load fails without data', (tester) async { when(() => useCase.call(any())).thenAnswer( - (_) async => Left(Failure.localFailure(message: 'db exploded')), + (_) async => const Left(Failure.localFailure(message: 'db exploded')), ); final cubit = InsightsCubit(useCase, nowProvider: () => fixedNow); @@ -151,21 +149,21 @@ void main() { await tester.pumpAndSettle(); expect( - find.text('Could not load insights. Pull to retry.'), findsOneWidget); + find.text('Could not load insights. Pull to retry.'), + findsOneWidget, + ); }); testWidgets('shows the empty state when the period has no activity', (tester) async { when(() => useCase.call(any())).thenAnswer( - (_) async => Right( + (_) async => const Right( InsightsSummary( totalOutflow: 0, totalInflow: 0, - outflowDelta: - const InsightsDelta(current: 0, previous: 0, isNew: false), - inflowDelta: - const InsightsDelta(current: 0, previous: 0, isNew: false), - pillars: const [], + outflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + inflowDelta: InsightsDelta(current: 0, previous: 0, isNew: false), + pillars: [], ), ), );