Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,7 +51,10 @@ GoRouter router([
GoRoute(
path: '/stats',
name: 'stats',
builder: (context, state) => const StatsComingSoonPage(),
builder: (context, state) => BlocProvider(
create: (context) => getIt<InsightsCubit>()..load(),
child: const InsightsPage(),
),
),
GoRoute(
path: '/settings',
Expand Down
4 changes: 2 additions & 2 deletions lib/app/view/main_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

This file was deleted.

120 changes: 120 additions & 0 deletions lib/features/insights/domain/entities/insight_timeframe.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import 'package:equatable/equatable.dart';

/// The selectable analysis windows on the Insights screen.
enum InsightsTimeframe {
thisMonth,
lastQuarter,
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:
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),
);
case InsightsTimeframe.allTime:
case InsightsTimeframe.custom:
throw UnsupportedError(
'$name has no derived window — the usecase handles it directly',
);
}
}

/// 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;

/// 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<Object?> 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<Object?> get props => [current, previous];
}
151 changes: 151 additions & 0 deletions lib/features/insights/domain/entities/insights_summary.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
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,
this.periodLabel = '',
});

/// 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<PillarInsight> pillars;

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<Object?> get props => [
periodLabel,
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<Object?> 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<EnvelopeInsight> envelopes;

@override
List<Object?> 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.breadcrumb,
required this.outflow,
required this.inflow,
required this.transactionCount,
required this.shareOfPillar,
});

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;

/// This envelope's activity relative to its pillar's total activity;
/// 0 when the pillar has no activity.
final double shareOfPillar;

@override
List<Object?> get props => [
category,
breadcrumb,
outflow,
inflow,
transactionCount,
shareOfPillar,
];
}
Loading
Loading