Skip to content
Draft
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
6 changes: 6 additions & 0 deletions lib/data/api/hive/service_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ extension AppHiveService on HiveService {
? stringBox.delete('ai.installedModelId')
: stringBox.put('ai.installedModelId', id);

/// Insights: the "your money" feed, stored as a JSON array string. Local
/// only — deliberately NOT part of the Drive backup.
String? get getInsightsFeed => stringBox.get('insights.feed');
Future<void> setInsightsFeed(String json) =>
stringBox.put('insights.feed', json);

/// Settings
String? get getCurrency => stringBox.get('currency');
Future<void> setCurrency(String currency) =>
Expand Down
5 changes: 5 additions & 0 deletions lib/data/bloc/app_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class AppBloc extends AbstractBloc {

set hasBootstrapped(bool value) => notify(() => _hasBootstrapped = value);

/// True on RAM-constrained devices (computed once at bootstrap). Drives
/// visual fallbacks: blur degrades to a flat translucent fill and the
/// aurora background stops animating, keeping budget phones smooth.
bool isLowEndDevice = false;

/// Auth
// Current User
late UserData _currentUser =
Expand Down
12 changes: 12 additions & 0 deletions lib/data/bloc/insight_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import '../data/insight/insight.dart';
import 'abstract.dart';

/// Holds the "your money" insight feed shown interleaved in the chat thread.
/// In-memory mirror of the persisted JSON feed (see InsightCommand).
class InsightBloc extends AbstractBloc {
List<InsightData> _feed = const [];

List<InsightData> get feed => _feed;

void setFeed(List<InsightData> feed) => notify(() => _feed = feed);
}
15 changes: 15 additions & 0 deletions lib/data/command/commands.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import 'dart:developer';

import '../api/ai/device_capability.dart';
import '../api/hive/hive_service.dart';
import '../bloc/ai_bloc.dart';
import '../bloc/app_bloc.dart';
import '../bloc/expense_bloc.dart';
import '../bloc/insight_bloc.dart';
import '../bloc/sync_bloc.dart';
import 'expense/expense_command.dart';
import 'insight/insight_command.dart';
import 'sync/sync_command.dart';

abstract class BaseAppCommand {
Expand All @@ -21,6 +24,8 @@ abstract class BaseAppCommand {

static final blocAi = AiBloc();

static final blocInsight = InsightBloc();

/// add other blocs here

HiveService get hive => _hive;
Expand All @@ -29,6 +34,8 @@ abstract class BaseAppCommand {

ExpenseBloc get expenseBloc => blocExpense;

InsightBloc get insightBloc => blocInsight;

/// init

static Future<void> init() async {
Expand All @@ -52,6 +59,14 @@ abstract class BaseAppCommand {
// Fetch and sync expenses
ExpenseCommand().refresh(loadDummy: false);

// One-time device-capability check drives visual fallbacks (blur, aurora).
final ramMb = await DeviceCapability.physicalRamMb();
blocApp.isLowEndDevice = ramMb != null && ramMb < 4000;

// Load the persisted insight feed and generate today's if due.
InsightCommand().hydrate();
InsightCommand().maybeGenerate();

// Restore sync state and pull remote changes (never blocks bootstrap)
SyncCommand().hydrate();
SyncCommand().syncOnResume();
Expand Down
64 changes: 64 additions & 0 deletions lib/data/command/insight/insight_command.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'dart:convert';

import 'package:flutter/foundation.dart';

import '../../api/hive/service_extension.dart';
import '../../data/insight/insight.dart';
import '../../utils/ai/insight_generator.dart';
import '../commands.dart';

/// Owns the persisted "your money" insight feed. Insights are generated
/// model-free from the user's data, at most one per day, pruned to a recent
/// window, and stored locally (never synced).
class InsightCommand extends BaseAppCommand {
static const _maxFeed = 30;

/// Loads the persisted feed into the bloc at bootstrap.
void hydrate() {
insightBloc.setFeed(_load());
}

/// Generates today's insight if one is due (nothing already created today
/// and the generator finds something worth saying), then persists.
Future<void> maybeGenerate({DateTime? now}) async {
final at = now ?? DateTime.now();
final feed = _load();

if (feed.any((i) => _sameDay(i.date, at))) return;

final insight = InsightGenerator.generate(
expenses: expenseBloc.expenses,
now: at,
currency: appBloc.currency,
);
if (insight == null) return;

final updated = [...feed, insight];
updated.sort((a, b) => a.createdAt.compareTo(b.createdAt));
final pruned = updated.length > _maxFeed
? updated.sublist(updated.length - _maxFeed)
: updated;

insightBloc.setFeed(pruned);
await hive.setInsightsFeed(
jsonEncode(pruned.map((i) => i.toJson()).toList()),
);
}

List<InsightData> _load() {
final raw = hive.getInsightsFeed;
if (raw == null || raw.isEmpty) return const [];
try {
final list = jsonDecode(raw) as List;
return list
.map((e) => InsightData.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
debugPrint('InsightCommand._load: $e');
return const [];
}
}

bool _sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
}
65 changes: 65 additions & 0 deletions lib/data/data/insight/insight.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/// A model-free "your money" message shown in the chat thread. Generated
/// deterministically from StatisticsHelper (no AI model needed), persisted
/// in its own lightweight JSON feed, and NEVER added to ExpenseData or the
/// Drive backup.
enum InsightKind { dailyRecap, categoryMilestone, topCategoryWeek, savings }

/// One labelled bar in an insight's mini chart (fraction is 0..1 of the max).
class InsightBar {
final String label;
final double fraction;

const InsightBar(this.label, this.fraction);

Map<String, dynamic> toJson() => {'l': label, 'f': fraction};

factory InsightBar.fromJson(Map<String, dynamic> json) => InsightBar(
json['l'] as String? ?? '',
(json['f'] as num?)?.toDouble() ?? 0,
);
}

class InsightData {
final String id;
final int createdAt; // epoch millis
final InsightKind kind;
final String text;
final List<InsightBar> bars;
final String? category;

const InsightData({
required this.id,
required this.createdAt,
required this.kind,
required this.text,
this.bars = const [],
this.category,
});

DateTime get date => DateTime.fromMillisecondsSinceEpoch(createdAt);

Map<String, dynamic> toJson() => {
'id': id,
'createdAt': createdAt,
'kind': kind.name,
'text': text,
if (bars.isNotEmpty) 'bars': bars.map((b) => b.toJson()).toList(),
if (category != null) 'category': category,
};

factory InsightData.fromJson(Map<String, dynamic> json) => InsightData(
id: json['id'] as String,
createdAt: json['createdAt'] as int,
kind: InsightKind.values.firstWhere(
(k) => k.name == json['kind'],
orElse: () => InsightKind.dailyRecap,
),
text: json['text'] as String? ?? '',
bars:
(json['bars'] as List?)
?.map((e) => InsightBar.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
category: json['category'] as String?,
);
}
102 changes: 102 additions & 0 deletions lib/data/utils/ai/insight_generator.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'package:intl/intl.dart';

import '../../data/expense/expense.dart';
import '../../data/insight/insight.dart';
import '../statistics_helper.dart';

/// Produces the single most relevant "your money" insight for *now*, computed
/// entirely from [StatisticsHelper] — no AI model involved, so it works for
/// every user. Returns null when there's nothing worth saying. Pure and
/// deterministic (id is keyed to kind + day) so it's unit-testable and
/// dedupes to one insight per day.
class InsightGenerator {
InsightGenerator._();

static InsightData? generate({
required List<ExpenseData> expenses,
required DateTime now,
required String currency,
}) {
if (expenses.isEmpty) return null;
final fmt = NumberFormat('#,##0.##');
String money(double v) => '$currency${fmt.format(v)}';
final dayKey =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
InsightData make(InsightKind kind, String text,
{List<InsightBar> bars = const [], String? category}) =>
InsightData(
id: '${kind.name}_$dayKey',
createdAt: now.millisecondsSinceEpoch,
kind: kind,
text: text,
bars: bars,
category: category,
);

// 1. Yesterday recap — only once there's been activity.
final yesterday = now.subtract(const Duration(days: 1));
final yStats = StatisticsHelper(expenses, period: 'D', referenceDate: yesterday);
if (yStats.totalSpending > 0) {
final top = yStats.topCategory;
final tail = top != null ? ", mostly #${top.key}" : "";
return make(
InsightKind.dailyRecap,
"Yesterday you spent ${money(yStats.totalSpending)}$tail.",
category: top?.key,
);
}

// 2. Category milestone — this month already passed last month's total.
final monthStats = StatisticsHelper(expenses, period: 'M', referenceDate: now);
final lastMonth = DateTime(now.year, now.month - 1, 15);
final lastMonthStats =
StatisticsHelper(expenses, period: 'M', referenceDate: lastMonth);
final lastByCat = {
for (final c in lastMonthStats.categoryStats) c.category: c.totalAmount,
};
for (final c in monthStats.categoryStats) {
final prev = lastByCat[c.category] ?? 0;
if (prev > 0 && c.totalAmount > prev) {
return make(
InsightKind.categoryMilestone,
"You've already spent more on #${c.category} this month "
"(${money(c.totalAmount)}) than all of last month (${money(prev)}).",
category: c.category,
);
}
}

// 3. Top category this week, with a mini chart.
final weekStats = StatisticsHelper(expenses, period: 'W', referenceDate: now);
final weekCats = weekStats.categoryStats;
if (weekStats.totalSpending > 0 && weekCats.isNotEmpty) {
final top = weekCats.first;
final max = top.totalAmount;
final bars = [
for (final c in weekCats.take(3))
InsightBar(c.category, max == 0 ? 0 : c.totalAmount / max),
];
final pct = weekStats.totalSpending == 0
? 0
: (top.totalAmount / weekStats.totalSpending * 100).round();
return make(
InsightKind.topCategoryWeek,
"This week #${top.category} is your top category at "
"${money(top.totalAmount)} ($pct% of spending).",
bars: bars,
category: top.category,
);
}

// 4. Positive savings note for the month.
if (monthStats.totalIncome > 0 && monthStats.savingsRate >= 20) {
return make(
InsightKind.savings,
"You've saved ${monthStats.savingsRate.round()}% of your income "
"this month — ${money(monthStats.totalSaved)} kept. Nice.",
);
}

return null;
}
}
Loading