414 lines
12 KiB
Dart
414 lines
12 KiB
Dart
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<Budget> _budgets = [];
|
|
final List<Expense> _expenses = [];
|
|
final List<Job> _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<double>(0, (sum, expense) => sum + expense.amount);
|
|
final spentToday = _expenses
|
|
.where((e) => e.budgetId == budget.id && _dayOf(e.spentAt) == _today)
|
|
.fold<double>(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<BudgetStatus>> 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<BudgetStatus> status({int? budgetId}) async {
|
|
return _backend.statusOf(_backend._budgetById(budgetId));
|
|
}
|
|
|
|
@override
|
|
Future<BudgetStatus> 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<BudgetStatus> 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<BudgetStatus> select(int budgetId) async {
|
|
_backend._selectedBudgetId = budgetId;
|
|
return _backend.statusOf(_backend._budgetById(budgetId));
|
|
}
|
|
|
|
@override
|
|
Future<BudgetStatus> 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<void> 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<ExpensesPage> 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
|
|
? <Expense>[]
|
|
: 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<double>(0, (sum, expense) => sum + expense.amount),
|
|
budgetId: all ? null : (budgetId ?? _backend._selectedBudgetId),
|
|
items: items,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<BudgetStatus> 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<double> 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<Job>> list() async => List.unmodifiable(_backend._jobs);
|
|
|
|
@override
|
|
Future<Job> create({
|
|
required String name,
|
|
required double salaryAmount,
|
|
required List<int> 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<Job> update({
|
|
required int jobId,
|
|
required String name,
|
|
required double salaryAmount,
|
|
required List<int> 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<void> delete(int jobId) async {
|
|
_backend._jobs.removeWhere((job) => job.id == jobId);
|
|
}
|
|
}
|
|
|
|
class _DemoUserRepository implements UserRepository {
|
|
@override
|
|
Future<AuthUser> me() async => DemoBackend.user;
|
|
}
|