import 'package:please_pay_me/data/api/api_client.dart'; import 'package:please_pay_me/data/models/auth_user.dart'; import 'package:please_pay_me/data/models/budget.dart'; import 'package:please_pay_me/data/models/expense.dart'; import 'package:please_pay_me/data/models/job.dart'; import 'package:please_pay_me/data/repositories/repositories.dart'; /// In-memory backend used for Widgetbook previews, widget tests and for /// running the app without a server (`PPM_DEMO=true`). /// /// Mirrors the envelope math of `IBudgetService` closely enough that screens /// behave the same as against the real API. class DemoBackend { DemoBackend({DateTime? today, bool seed = true}) : _today = _dayOf(today ?? DateTime.now()) { if (seed) _seed(); } /// Backend without any data — used for empty-state previews and tests. factory DemoBackend.empty({DateTime? today}) => DemoBackend(today: today, seed: false); final DateTime _today; final List _budgets = []; final List _expenses = []; final List _jobs = []; int _selectedBudgetId = 1; int _nextExpenseId = 100; int _nextBudgetId = 3; int _nextJobId = 2; static const user = AuthUser( userId: 1, firstName: 'Владимир', username: 'pleasepayme', ); BudgetRepository get budgets => _DemoBudgetRepository(this); ExpenseRepository get expenses => _DemoExpenseRepository(this); JobRepository get jobs => _DemoJobRepository(this); UserRepository get users => _DemoUserRepository(); void _seed() { _budgets.addAll([ Budget( id: 1, userId: 1, name: 'До аванса', totalAmount: 42000, startDate: _today.subtract(const Duration(days: 6)), endDate: _today.add(const Duration(days: 8)), currency: 'RUB', isActive: true, ), Budget( id: 2, userId: 1, name: 'Отпуск', totalAmount: 90000, startDate: _today.subtract(const Duration(days: 40)), endDate: _today.subtract(const Duration(days: 5)), currency: 'RUB', isActive: false, ), ]); _expenses.addAll([ Expense(id: 1, budgetId: 1, amount: 1840, note: 'Продукты', spentAt: _today), Expense(id: 2, budgetId: 1, amount: 250, note: 'Кофе', spentAt: _today), Expense( id: 3, budgetId: 1, amount: 640, note: 'Такси', spentAt: _today.subtract(const Duration(days: 1)), ), Expense( id: 4, budgetId: 1, amount: 3200, note: 'Аптека', spentAt: _today.subtract(const Duration(days: 2)), ), Expense( id: 5, budgetId: 2, amount: 15000, note: 'Билеты', spentAt: _today.subtract(const Duration(days: 20)), ), ]); _jobs.add( Job( id: 1, userId: 1, name: 'Основная работа', salaryAmount: 180000, currency: 'RUB', payDays: const [5, 20], firstPayPercent: 40, weekendPolicy: WeekendPolicy.beforeWeekend, isActive: true, nextPays: [ UpcomingPay( date: _today.add(const Duration(days: 8)), scheduledDay: 20, percent: 60, amount: 108000, ), UpcomingPay( date: _today.add(const Duration(days: 23)), scheduledDay: 5, percent: 40, amount: 72000, ), ], ), ); } Budget _budgetById(int? id) { if (_budgets.isEmpty) { throw const ApiException('Сначала создайте бюджет'); } final budgetId = id ?? _selectedBudgetId; return _budgets.firstWhere( (budget) => budget.id == budgetId, orElse: () => _budgets.first, ); } BudgetStatus statusOf(Budget budget) { final spent = _expenses .where((expense) => expense.budgetId == budget.id) .fold(0, (sum, expense) => sum + expense.amount); final spentToday = _expenses .where((e) => e.budgetId == budget.id && _dayOf(e.spentAt) == _today) .fold(0, (sum, expense) => sum + expense.amount); final daysLeft = budget.endDate.difference(_today).inDays + 1; final safeDays = daysLeft < 1 ? 0 : daysLeft; final remaining = budget.totalAmount - spent; final dailyLimit = safeDays == 0 ? 0.0 : (remaining <= 0 ? 0.0 : remaining / safeDays); return BudgetStatus( budget: budget, today: _today, daysLeft: safeDays, totalSpent: spent, remaining: remaining, dailyLimit: dailyLimit, spentToday: spentToday, remainingToday: dailyLimit - spentToday, isOverDaily: spentToday > dailyLimit, isOverBudget: remaining < 0, isExpired: safeDays == 0, selected: budget.id == _selectedBudgetId, ); } static DateTime _dayOf(DateTime value) => DateTime(value.year, value.month, value.day); } class _DemoBudgetRepository implements BudgetRepository { const _DemoBudgetRepository(this._backend); final DemoBackend _backend; @override Future> list() async { return _backend._budgets.map(_backend.statusOf).toList() ..sort((a, b) { if (a.selected != b.selected) return a.selected ? -1 : 1; return b.budget.endDate.compareTo(a.budget.endDate); }); } @override Future status({int? budgetId}) async { return _backend.statusOf(_backend._budgetById(budgetId)); } @override Future create({ required String name, required double totalAmount, required DateTime endDate, DateTime? startDate, }) async { final budget = Budget( id: _backend._nextBudgetId++, userId: 1, name: name, totalAmount: totalAmount, startDate: startDate ?? _backend._today, endDate: endDate, currency: 'RUB', isActive: true, ); _backend._budgets.add(budget); _backend._selectedBudgetId = budget.id; return _backend.statusOf(budget); } @override Future update({ required int budgetId, String? name, double? totalAmount, DateTime? endDate, DateTime? startDate, bool resetExpenses = false, }) async { final index = _backend._budgets.indexWhere((budget) => budget.id == budgetId); final current = _backend._budgets[index]; final updated = Budget( id: current.id, userId: current.userId, name: name ?? current.name, totalAmount: totalAmount ?? current.totalAmount, startDate: startDate ?? current.startDate, endDate: endDate ?? current.endDate, currency: current.currency, isActive: current.isActive, ); _backend._budgets[index] = updated; if (resetExpenses) { _backend._expenses.removeWhere((expense) => expense.budgetId == budgetId); } return _backend.statusOf(updated); } @override Future select(int budgetId) async { _backend._selectedBudgetId = budgetId; return _backend.statusOf(_backend._budgetById(budgetId)); } @override Future setActive(int budgetId, {required bool isActive}) async { final index = _backend._budgets.indexWhere((budget) => budget.id == budgetId); final current = _backend._budgets[index]; final updated = Budget( id: current.id, userId: current.userId, name: current.name, totalAmount: current.totalAmount, startDate: current.startDate, endDate: current.endDate, currency: current.currency, isActive: isActive, ); _backend._budgets[index] = updated; return _backend.statusOf(updated); } @override Future delete(int budgetId) async { _backend._budgets.removeWhere((budget) => budget.id == budgetId); _backend._expenses.removeWhere((expense) => expense.budgetId == budgetId); if (_backend._selectedBudgetId == budgetId && _backend._budgets.isNotEmpty) { _backend._selectedBudgetId = _backend._budgets.first.id; } } } class _DemoExpenseRepository implements ExpenseRepository { const _DemoExpenseRepository(this._backend); final DemoBackend _backend; @override Future page({ required int page, int pageSize = 20, int? budgetId, bool all = false, }) async { final scope = all ? _backend._expenses : _backend._expenses .where((e) => e.budgetId == (budgetId ?? _backend._selectedBudgetId)); final sorted = scope.toList() ..sort((a, b) { final byDate = b.spentAt.compareTo(a.spentAt); return byDate != 0 ? byDate : b.id.compareTo(a.id); }); final from = (page - 1) * pageSize; final items = from >= sorted.length ? [] : sorted.sublist(from, (from + pageSize).clamp(0, sorted.length)); return ExpensesPage( page: page, totalPages: sorted.isEmpty ? 1 : (sorted.length / pageSize).ceil(), totalCount: sorted.length, pageSize: pageSize, totalSum: sorted.fold(0, (sum, expense) => sum + expense.amount), budgetId: all ? null : (budgetId ?? _backend._selectedBudgetId), items: items, ); } @override Future create({ required double amount, String? note, DateTime? spentAt, int? budgetId, }) async { final budget = _backend._budgetById(budgetId); _backend._expenses.add( Expense( id: _backend._nextExpenseId++, budgetId: budget.id, amount: amount, note: note, spentAt: spentAt ?? _backend._today, ), ); return _backend.statusOf(budget); } @override Future undoLast({int? budgetId}) async { final budget = _backend._budgetById(budgetId); final scoped = _backend._expenses.where((e) => e.budgetId == budget.id).toList(); if (scoped.isEmpty) return 0; scoped.sort((a, b) => b.id.compareTo(a.id)); final last = scoped.first; _backend._expenses.removeWhere((expense) => expense.id == last.id); return last.amount; } } class _DemoJobRepository implements JobRepository { const _DemoJobRepository(this._backend); final DemoBackend _backend; @override Future> list() async => List.unmodifiable(_backend._jobs); @override Future create({ required String name, required double salaryAmount, required List payDays, required double firstPayPercent, required WeekendPolicy weekendPolicy, }) async { final job = Job( id: _backend._nextJobId++, userId: 1, name: name, salaryAmount: salaryAmount, currency: 'RUB', payDays: payDays, firstPayPercent: firstPayPercent, weekendPolicy: weekendPolicy, isActive: true, nextPays: const [], ); _backend._jobs.add(job); return job; } @override Future update({ required int jobId, required String name, required double salaryAmount, required List payDays, required double firstPayPercent, required WeekendPolicy weekendPolicy, bool isActive = true, }) async { final index = _backend._jobs.indexWhere((job) => job.id == jobId); final current = _backend._jobs[index]; final updated = Job( id: current.id, userId: current.userId, name: name, salaryAmount: salaryAmount, currency: current.currency, payDays: payDays, firstPayPercent: firstPayPercent, weekendPolicy: weekendPolicy, isActive: isActive, nextPays: current.nextPays, ); _backend._jobs[index] = updated; return updated; } @override Future delete(int jobId) async { _backend._jobs.removeWhere((job) => job.id == jobId); } } class _DemoUserRepository implements UserRepository { @override Future me() async => DemoBackend.user; }