Files
please-pay-me/mobile/lib/data/demo/demo_backend.dart
T
2026-09-21 04:06:43 +03:00

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;
}