feat(proj): init
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user