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
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import 'package:keep_track/core/cache/local_cache.dart';
import 'package:keep_track/features/finance/modules/budget/data/models/budget_model.dart';
import 'package:keep_track/features/finance/modules/budget/data/models/month_plan_model.dart';
import 'package:uuid/uuid.dart';
import '../month_plan_datasource.dart';
Expand All @@ -10,6 +9,7 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource {

static const _box = 'month_plans';
static const _budgetsBox = 'budgets';
static const _categoriesBox = 'budget_categories';

MonthPlanDataSourceLocal(this._cache);

Expand Down Expand Up @@ -61,22 +61,37 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource {
orElse: () => throw StateError('Source month plan $sourceMonth not found'),
);

final allBudgetEntries = await _cache.getAll(_budgetsBox);
final sourceBudgets = allBudgetEntries
.map((e) => BudgetModel.fromJson(e))
.where((b) => source.budgetIds.contains(b.id))
.toList();

final allCategoryEntries = await _cache.getAll(_categoriesBox);
final newBudgetIds = <String>[];
for (final budget in sourceBudgets) {

for (final sourceBudgetId in source.budgetIds) {
final budgetData = await _cache.get(_budgetsBox, sourceBudgetId);
if (budgetData == null) continue;

final newBudgetId = _uuid.v4();
final newBudget = BudgetModel.fromJson({
...budget.toJson(),
final newBudgetData = {
...budgetData,
'id': newBudgetId,
'month': targetMonth,
});
await _cache.put(_budgetsBox, newBudgetId, newBudget.toJson());
'status': 'active',
'closedAt': null,
}..remove('closedAt');
await _cache.put(_budgetsBox, newBudgetId, newBudgetData);
newBudgetIds.add(newBudgetId);

// Duplicate categories for the new budget (structure only, no spent data)
final srcCats = allCategoryEntries
.where((e) => e['budgetId'] == sourceBudgetId)
.toList();
for (final cat in srcCats) {
final newCatId = _uuid.v4();
final newCat = {
...cat,
'id': newCatId,
'budgetId': newBudgetId,
};
await _cache.put(_categoriesBox, newCatId, newCat);
}
}

final existing = allPlans.firstWhere(
Expand Down Expand Up @@ -111,7 +126,17 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource {
final data = await _cache.get(_box, id);
if (data != null) {
final plan = MonthPlanModel.fromJson(data);
final allCats = await _cache.getAll(_categoriesBox);
for (final budgetId in plan.budgetIds) {
// Delete categories belonging to this budget
final catIds = allCats
.where((c) => c['budgetId'] == budgetId)
.map((c) => c['id'] as String?)
.whereType<String>()
.toList();
for (final catId in catIds) {
await _cache.delete(_categoriesBox, catId);
}
await _cache.delete(_budgetsBox, budgetId);
}
}
Expand Down Expand Up @@ -146,4 +171,42 @@ class MonthPlanDataSourceLocal implements MonthPlanDataSource {
return plan;
}
}

@override
Future<MonthPlanModel> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId) async {
final all = await _getAll();
try {
return all.firstWhere((p) => p.month == month && p.budgetProfileId == profileId);
} catch (_) {
final id = _uuid.v4();
final plan = MonthPlanModel.fromJson({
'id': id,
'month': month,
'budgetProfileId': profileId,
'budgetIds': <String>[],
});
await _cache.put(_box, id, plan.toJson());
return plan;
}
}

@override
Future<MonthPlanModel> closeMonthPlan(String id) async {
final data = await _cache.get(_box, id);
if (data == null) throw StateError('MonthPlan $id not found');
final plan = MonthPlanModel.fromJson(data);
final closed = MonthPlanModel.fromJson({...plan.toJson(), 'status': 'closed'});
await _cache.put(_box, id, closed.toJson());
return closed;
}

@override
Future<MonthPlanModel> reopenMonthPlan(String id) async {
final data = await _cache.get(_box, id);
if (data == null) throw StateError('MonthPlan $id not found');
final plan = MonthPlanModel.fromJson(data);
final reopened = MonthPlanModel.fromJson({...plan.toJson(), 'status': 'active'});
await _cache.put(_box, id, reopened.toJson());
return reopened;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,13 @@ abstract class MonthPlanDataSource {

/// Get or create the plan for a custom budget profile.
Future<MonthPlanModel> getOrCreatePlanForProfile(String profileId);

/// Get or create a plan for a monthly profile scoped to a specific month.
Future<MonthPlanModel> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId);

/// Set the plan's status to closed.
Future<MonthPlanModel> closeMonthPlan(String id);

/// Set the plan's status back to active.
Future<MonthPlanModel> reopenMonthPlan(String id);
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,53 @@
import '../../domain/entities/month_plan.dart';
import 'budget_model.dart';

/// MonthPlan model - DTO for Supabase
/// MonthPlan model - DTO for local cache
class MonthPlanModel extends MonthPlan {
MonthPlanModel({
super.id,
super.month,
super.budgetProfileId,
super.userId,
super.notes,
super.status = MonthPlanStatus.active,
super.budgets = const [],
super.budgetIds = const [],
super.createdAt,
super.updatedAt,
});

/// Create model from entity
factory MonthPlanModel.fromEntity(MonthPlan plan) {
return MonthPlanModel(
id: plan.id,
month: plan.month,
budgetProfileId: plan.budgetProfileId,
userId: plan.userId,
notes: plan.notes,
status: plan.status,
budgets: plan.budgets,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
);
}

/// Create model from JSON (NestJS camelCase response, budgets loaded separately)
factory MonthPlanModel.fromJson(Map<String, dynamic> json) {
final rawIds = json['budgetIds'];
final parsedIds = rawIds is List
? rawIds.map((e) => e.toString()).toList()
: <String>[];

final rawStatus = json['status'] as String?;
final status = rawStatus == 'closed'
? MonthPlanStatus.closed
: MonthPlanStatus.active;

return MonthPlanModel(
id: json['id'] as String?,
month: json['month'] as String?,
budgetProfileId: json['budgetProfileId'] as String?,
userId: json['userId'] as String?,
notes: json['notes'] as String?,
status: status,
budgets: const [],
budgetIds: parsedIds,
createdAt: json['createdAt'] != null
Expand All @@ -53,36 +59,27 @@ class MonthPlanModel extends MonthPlan {
);
}

/// NestJS API request body
Map<String, dynamic> toApiJson() {
return {
if (month != null) 'month': month,
if (budgetProfileId != null) 'budgetProfileId': budgetProfileId,
if (notes != null) 'notes': notes,
};
}

/// Cache serialisation (local storage)
Map<String, dynamic> toJson() {
return {
if (id != null) 'id': id,
if (month != null) 'month': month,
if (budgetProfileId != null) 'budgetProfileId': budgetProfileId,
if (userId != null) 'userId': userId,
if (notes != null) 'notes': notes,
'status': status.name,
'budgetIds': budgetIds,
if (createdAt != null) 'createdAt': createdAt!.toIso8601String(),
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}

/// Return a new model with hydrated budgets attached
MonthPlanModel withBudgets(List<BudgetModel> newBudgets) {
return MonthPlanModel(
id: id,
month: month,
userId: userId,
notes: notes,
status: status,
budgets: newBudgets,
createdAt: createdAt,
updatedAt: updatedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,34 @@ class MonthPlanRepositoryImpl implements MonthPlanRepository {
);
}
}

@override
Future<Result<MonthPlan>> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId) async {
try {
final plan = await dataSource.getOrCreateMonthPlanForMonthlyProfile(month, profileId);
return Result.success(plan);
} catch (e) {
return Result.error(UnknownFailure(message: 'Failed to get or create monthly profile plan: $e'));
}
}

@override
Future<Result<MonthPlan>> closeMonthPlan(String id) async {
try {
final plan = await dataSource.closeMonthPlan(id);
return Result.success(plan);
} catch (e) {
return Result.error(UnknownFailure(message: 'Failed to close month plan: $e'));
}
}

@override
Future<Result<MonthPlan>> reopenMonthPlan(String id) async {
try {
final plan = await dataSource.reopenMonthPlan(id);
return Result.success(plan);
} catch (e) {
return Result.error(UnknownFailure(message: 'Failed to reopen month plan: $e'));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
import 'budget.dart';

enum MonthPlanStatus {
active,
closed;

String get displayName => switch (this) {
MonthPlanStatus.active => 'Active',
MonthPlanStatus.closed => 'Closed',
};
}

/// MonthPlan entity - Parent container for all budget groups in a given month.
///
/// A MonthPlan groups all Budget objects (income and expense) for a single
/// YYYY-MM month, enabling month-level planning and copying.
class MonthPlan {
final String? id;
final String? month; // YYYY-MM for monthly plans; null for profile plans
final String? budgetProfileId; // null for monthly plans; set for profile plans
final String? userId;
final String? notes;
final List<Budget> budgets; // All budgets for this month
final MonthPlanStatus status;
final List<Budget> budgets;
final DateTime? createdAt;
final DateTime? updatedAt;

final List<String> budgetIds; // Raw IDs from month_plan.budgetIds
final List<String> budgetIds;

const MonthPlan({
this.id,
this.month,
this.budgetProfileId,
this.userId,
this.notes,
this.status = MonthPlanStatus.active,
this.budgets = const [],
this.budgetIds = const [],
this.createdAt,
this.updatedAt,
});

bool get isClosed => status == MonthPlanStatus.closed;

/// Total planned income across all income budgets
double get totalPlannedIncome =>
budgets.fold(0.0, (sum, b) => sum + b.totalBudgetedIncome);
Expand Down Expand Up @@ -62,6 +73,7 @@ class MonthPlan {
String? budgetProfileId,
String? userId,
String? notes,
MonthPlanStatus? status,
List<Budget>? budgets,
List<String>? budgetIds,
DateTime? createdAt,
Expand All @@ -73,6 +85,7 @@ class MonthPlan {
budgetProfileId: budgetProfileId ?? this.budgetProfileId,
userId: userId ?? this.userId,
notes: notes ?? this.notes,
status: status ?? this.status,
budgets: budgets ?? this.budgets,
budgetIds: budgetIds ?? this.budgetIds,
createdAt: createdAt ?? this.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,13 @@ abstract class MonthPlanRepository {

/// Get or create the plan for a custom budget profile.
Future<Result<MonthPlan>> getOrCreatePlanForProfile(String profileId);

/// Get or create a plan for a monthly profile scoped to a specific month.
Future<Result<MonthPlan>> getOrCreateMonthPlanForMonthlyProfile(String month, String profileId);

/// Set the plan's status to closed.
Future<Result<MonthPlan>> closeMonthPlan(String id);

/// Set the plan's status back to active.
Future<Result<MonthPlan>> reopenMonthPlan(String id);
}
Loading
Loading