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
15 changes: 7 additions & 8 deletions lib/features/insights/data/insights_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,20 @@ class CategorySpend {
}

class InsightsRepository {
// Total spend per category for [month] in a group (deleted expenses excluded).
Future<List<CategorySpend>> monthlyByCategory({
// Total spend per category between [from] (inclusive) and [toExclusive]
// (exclusive) in a group, deleted expenses excluded.
Future<List<CategorySpend>> byCategory({
required String groupId,
required DateTime month,
required DateTime from,
required DateTime toExclusive,
}) async {
final first = DateTime(month.year, month.month, 1);
final next = DateTime(month.year, month.month + 1, 1);

final data = await supabase
.from('expenses')
.select('amount, categories(name, icon)')
.eq('group_id', groupId)
.isFilter('deleted_at', null)
.gte('occurred_on', isoDate(first))
.lt('occurred_on', isoDate(next));
.gte('occurred_on', isoDate(from))
.lt('occurred_on', isoDate(toExclusive));

final totals = <String, ({String icon, Decimal total})>{};
for (final row in data as List) {
Expand Down
27 changes: 27 additions & 0 deletions lib/features/insights/logic/insights_range.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// A selectable time window for the Insights breakdown.
///
/// Boundaries are local-midnight dates computed with calendar arithmetic
/// (`DateTime(y, m, d ± n)`), never `Duration` — see the DST note in
/// `core/dates.dart`. `from` is inclusive, `toExclusive` is the start of the
/// day after "today", so a `>= from` / `< toExclusive` filter covers today.
typedef DateWindow = ({DateTime from, DateTime toExclusive});

enum InsightsRange {
lastWeek('Last week'),
monthToDate('Month to date'),
yearToDate('Year to date');

const InsightsRange(this.label);

final String label;

DateWindow window(DateTime now) {
final toExclusive = DateTime(now.year, now.month, now.day + 1);
final from = switch (this) {
InsightsRange.lastWeek => DateTime(now.year, now.month, now.day - 6),
InsightsRange.monthToDate => DateTime(now.year, now.month, 1),
InsightsRange.yearToDate => DateTime(now.year, 1, 1),
};
return (from: from, toExclusive: toExclusive);
}
}
16 changes: 12 additions & 4 deletions lib/features/insights/providers/insights_provider.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import 'package:tally/features/insights/data/insights_repository.dart';
import 'package:tally/features/insights/logic/insights_range.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Current-month spend per category for a group.
typedef InsightsArgs = ({String groupId, InsightsRange range});

// Spend per category for a group over the selected [InsightsRange]. Keyed by
// (groupId, range) so each range stays cached independently.
final insightsProvider =
FutureProvider.family<List<CategorySpend>, String>((ref, groupId) {
return InsightsRepository()
.monthlyByCategory(groupId: groupId, month: DateTime.now());
FutureProvider.family<List<CategorySpend>, InsightsArgs>((ref, args) {
final w = args.range.window(DateTime.now());
return InsightsRepository().byCategory(
groupId: args.groupId,
from: w.from,
toExclusive: w.toExclusive,
);
});
115 changes: 83 additions & 32 deletions lib/features/insights/ui/insights_screen.dart
Original file line number Diff line number Diff line change
@@ -1,54 +1,105 @@
import 'package:decimal/decimal.dart';
import 'package:tally/core/dates.dart';
import 'package:tally/core/icons.dart';
import 'package:tally/core/money.dart';
import 'package:tally/core/widgets/page_body.dart';
import 'package:tally/features/insights/data/insights_repository.dart';
import 'package:tally/features/insights/logic/insights_range.dart';
import 'package:tally/features/insights/providers/insights_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
class InsightsScreen extends ConsumerWidget {

class InsightsScreen extends ConsumerStatefulWidget {
const InsightsScreen({super.key, required this.groupId});
final String groupId;

@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<InsightsScreen> createState() => _InsightsScreenState();
}

class _InsightsScreenState extends ConsumerState<InsightsScreen> {
InsightsRange _range = InsightsRange.monthToDate;

@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final spendAsync = ref.watch(insightsProvider(groupId));
final monthLabel = DateFormat('MMMM yyyy').format(DateTime.now());
final spendAsync =
ref.watch(insightsProvider((groupId: widget.groupId, range: _range)));

final now = DateTime.now();
final window = _range.window(now);
final lastDay = DateTime(now.year, now.month, now.day);
final spanLabel = '${formatDay(window.from)} – ${formatDayYear(lastDay)}';

return Scaffold(
appBar: AppBar(title: const Text('Insights')),
body: PageBody(
child: spendAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (spend) {
if (spend.isEmpty) {
return Center(
child: Text('No spending in $monthLabel yet',
style: theme.textTheme.titleMedium),
);
}
final total = spend.fold(Decimal.zero, (s, c) => s + c.total);
return ListView(
padding: const EdgeInsets.all(24),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
child: Row(
children: [
Text(monthLabel, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
formatCurrency(total),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
Expanded(
child: Text('Spending', style: theme.textTheme.titleMedium),
),
DropdownButton<InsightsRange>(
value: _range,
onChanged: (r) {
if (r != null) setState(() => _range = r);
},
items: [
for (final r in InsightsRange.values)
DropdownMenuItem(value: r, child: Text(r.label)),
],
),
const SizedBox(height: 24),
...spend.map((c) => _CategoryBar(spend: c, total: total)),
],
);
},
),
),
),
Expanded(
child: PageBody(
child: spendAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (spend) {
if (spend.isEmpty) {
return Center(
child: Text(
'No spending for ${_range.label.toLowerCase()} yet',
style: theme.textTheme.titleMedium,
),
);
}
final total =
spend.fold(Decimal.zero, (s, c) => s + c.total);
return ListView(
padding: const EdgeInsets.all(24),
children: [
Text(_range.label, style: theme.textTheme.titleSmall),
const SizedBox(height: 2),
Text(
spanLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
formatCurrency(total),
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 24),
...spend.map((c) => _CategoryBar(spend: c, total: total)),
],
);
},
),
),
),
],
),
);
}
Expand Down
29 changes: 29 additions & 0 deletions test/features/insights/insights_range_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tally/features/insights/logic/insights_range.dart';

void main() {
group('InsightsRange.window', () {
test('mid-month reference date', () {
final now = DateTime(2026, 9, 15, 13, 30);

expect(InsightsRange.lastWeek.window(now),
(from: DateTime(2026, 9, 9), toExclusive: DateTime(2026, 9, 16)));
expect(InsightsRange.monthToDate.window(now),
(from: DateTime(2026, 9, 1), toExclusive: DateTime(2026, 9, 16)));
expect(InsightsRange.yearToDate.window(now),
(from: DateTime(2026, 1, 1), toExclusive: DateTime(2026, 9, 16)));
});

test('last week rolls back across a month boundary', () {
final now = DateTime(2026, 3, 3);
expect(InsightsRange.lastWeek.window(now).from, DateTime(2026, 2, 25));
});

test('last week rolls back across a year boundary', () {
final now = DateTime(2026, 1, 2);
expect(InsightsRange.lastWeek.window(now).from, DateTime(2025, 12, 27));
expect(InsightsRange.yearToDate.window(now).from, DateTime(2026, 1, 1));
expect(InsightsRange.monthToDate.window(now).from, DateTime(2026, 1, 1));
});
});
}
49 changes: 43 additions & 6 deletions test/features/screens_smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:tally/features/groups/providers/groups_provider.dart';
import 'package:tally/features/groups/ui/group_detail_screen.dart';
import 'package:tally/features/import/providers/import_providers.dart';
import 'package:tally/features/insights/data/insights_repository.dart';
import 'package:tally/features/insights/logic/insights_range.dart';
import 'package:tally/features/insights/providers/insights_provider.dart';
import 'package:tally/features/insights/ui/insights_screen.dart';
import 'package:tally/features/realtime/group_realtime_provider.dart';
Expand Down Expand Up @@ -97,11 +98,14 @@ void main() {
await tester.pumpWidget(_host(
const InsightsScreen(groupId: _gid),
[
insightsProvider(_gid).overrideWith((ref) async => [
CategorySpend(name: 'Rent', icon: 'home', total: dec('1000')),
CategorySpend(
name: 'Groceries', icon: 'shopping_cart', total: dec('250')),
]),
insightsProvider((groupId: _gid, range: InsightsRange.monthToDate))
.overrideWith((ref) async => [
CategorySpend(name: 'Rent', icon: 'home', total: dec('1000')),
CategorySpend(
name: 'Groceries',
icon: 'shopping_cart',
total: dec('250')),
]),
],
));
await tester.pumpAndSettle();
Expand All @@ -110,13 +114,46 @@ void main() {
expect(tester.takeException(), isNull);
});

testWidgets('InsightsScreen switches the range via the dropdown',
(tester) async {
_desktop(tester);
addTearDown(tester.view.reset);
await tester.pumpWidget(_host(
const InsightsScreen(groupId: _gid),
[
insightsProvider((groupId: _gid, range: InsightsRange.monthToDate))
.overrideWith((ref) async =>
[CategorySpend(name: 'Rent', icon: 'home', total: dec('1000'))]),
insightsProvider((groupId: _gid, range: InsightsRange.yearToDate))
.overrideWith((ref) async => [
CategorySpend(
name: 'Flights', icon: 'flight', total: dec('4200')),
]),
],
));
await tester.pumpAndSettle();
expect(find.text('Rent'), findsOneWidget);

await tester.tap(find.byType(DropdownButton<InsightsRange>));
await tester.pumpAndSettle();
await tester.tap(find.text('Year to date').last);
await tester.pumpAndSettle();

expect(find.text('Flights'), findsOneWidget);
expect(find.text('Rent'), findsNothing);
expect(tester.takeException(), isNull);
});

testWidgets('InsightsScreen shows an empty message with no spend',
(tester) async {
_desktop(tester);
addTearDown(tester.view.reset);
await tester.pumpWidget(_host(
const InsightsScreen(groupId: _gid),
[insightsProvider(_gid).overrideWith((ref) async => <CategorySpend>[])],
[
insightsProvider((groupId: _gid, range: InsightsRange.monthToDate))
.overrideWith((ref) async => <CategorySpend>[]),
],
));
await tester.pumpAndSettle();
expect(find.textContaining('No spending'), findsOneWidget);
Expand Down
Loading