feat(proj): init
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.statusCode});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
bool get isUnauthorized => statusCode == 401;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Thin JSON transport over the PleasePayMe REST API.
|
||||
///
|
||||
/// Keeps auth concerns out of repositories: the token is supplied lazily so a
|
||||
/// re-login does not require rebuilding the whole object graph.
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required String baseUrl,
|
||||
required String? Function() tokenProvider,
|
||||
http.Client? httpClient,
|
||||
this.onUnauthorized,
|
||||
this.timeout = const Duration(seconds: 15),
|
||||
}) : _baseUrl = baseUrl.replaceAll(RegExp(r'/$'), ''),
|
||||
_tokenProvider = tokenProvider,
|
||||
_http = httpClient ?? http.Client();
|
||||
|
||||
final String _baseUrl;
|
||||
final String? Function() _tokenProvider;
|
||||
final http.Client _http;
|
||||
final void Function()? onUnauthorized;
|
||||
final Duration timeout;
|
||||
|
||||
Future<Map<String, dynamic>> getJson(String path, {Map<String, String>? query}) async {
|
||||
return asMap(await _send('GET', path, query: query));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> postJson(
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
return asMap(await _send('POST', path, body: body, query: query));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> putJson(String path, {Map<String, dynamic>? body}) async {
|
||||
return asMap(await _send('PUT', path, body: body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> patchJson(String path, {Map<String, dynamic>? body}) async {
|
||||
return asMap(await _send('PATCH', path, body: body));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteJson(String path, {Map<String, String>? query}) async {
|
||||
return asMap(await _send('DELETE', path, query: query));
|
||||
}
|
||||
|
||||
Future<Object?> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, dynamic>? body,
|
||||
Map<String, String>? query,
|
||||
}) async {
|
||||
final uri = Uri.parse('$_baseUrl$path').replace(
|
||||
queryParameters: query?.isEmpty ?? true ? null : query,
|
||||
);
|
||||
|
||||
final request = http.Request(method, uri);
|
||||
request.headers['Accept'] = 'application/json';
|
||||
final token = _tokenProvider();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers['Content-Type'] = 'application/json';
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
late final http.Response response;
|
||||
try {
|
||||
final streamed = await _http.send(request).timeout(timeout);
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} on Exception catch (error) {
|
||||
throw ApiException('Нет связи с сервером: $error');
|
||||
}
|
||||
|
||||
if (response.statusCode == 401) {
|
||||
onUnauthorized?.call();
|
||||
throw const ApiException('Сессия истекла, войдите заново', statusCode: 401);
|
||||
}
|
||||
|
||||
final raw = utf8.decode(response.bodyBytes);
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
throw ApiException(_extractError(raw, response.statusCode), statusCode: response.statusCode);
|
||||
}
|
||||
|
||||
if (response.statusCode == 204 || raw.trim().isEmpty) return null;
|
||||
|
||||
try {
|
||||
return jsonDecode(raw);
|
||||
} on FormatException {
|
||||
throw ApiException('Сервер вернул не JSON (HTTP ${response.statusCode})');
|
||||
}
|
||||
}
|
||||
|
||||
String _extractError(String raw, int statusCode) {
|
||||
try {
|
||||
final parsed = asMap(jsonDecode(raw));
|
||||
final detail = asStringOrNull(parsed['detail']) ?? asStringOrNull(parsed['title']);
|
||||
if (detail != null) return detail;
|
||||
} on FormatException {
|
||||
// Fall through to the raw payload.
|
||||
}
|
||||
return raw.trim().isEmpty ? 'Ошибка запроса (HTTP $statusCode)' : raw.trim();
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class AuthUser {
|
||||
const AuthUser({
|
||||
required this.userId,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
this.username,
|
||||
this.photoUrl,
|
||||
});
|
||||
|
||||
factory AuthUser.fromJson(Map<String, dynamic> json) {
|
||||
return AuthUser(
|
||||
userId: asInt(json['user_id']),
|
||||
firstName: asStringOrNull(json['first_name']),
|
||||
lastName: asStringOrNull(json['last_name']),
|
||||
username: asStringOrNull(json['username']),
|
||||
photoUrl: asStringOrNull(json['photo_url']),
|
||||
);
|
||||
}
|
||||
|
||||
static AuthUser? tryDecode(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return AuthUser.fromJson(asMap(jsonDecode(raw)));
|
||||
} on FormatException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final int userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? username;
|
||||
final String? photoUrl;
|
||||
|
||||
String get displayName {
|
||||
final full = [firstName, lastName].whereType<String>().join(' ').trim();
|
||||
if (full.isNotEmpty) return full;
|
||||
if (username != null) return '@$username';
|
||||
return 'Пользователь $userId';
|
||||
}
|
||||
|
||||
String get handle => username != null ? '@$username' : 'id $userId';
|
||||
|
||||
String get initials {
|
||||
final source = firstName?.trim().isNotEmpty == true
|
||||
? firstName!.trim()
|
||||
: username?.trim() ?? '';
|
||||
if (source.isEmpty) return '';
|
||||
return source.substring(0, 1);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'user_id': userId,
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'username': username,
|
||||
'photo_url': photoUrl,
|
||||
};
|
||||
|
||||
String encode() => jsonEncode(toJson());
|
||||
}
|
||||
|
||||
class AuthSession {
|
||||
const AuthSession({required this.accessToken, required this.user});
|
||||
|
||||
factory AuthSession.fromJson(Map<String, dynamic> json) {
|
||||
return AuthSession(
|
||||
accessToken: asString(json['access_token']),
|
||||
user: AuthUser.fromJson(asMap(json['user'])),
|
||||
);
|
||||
}
|
||||
|
||||
final String accessToken;
|
||||
final AuthUser user;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class Budget {
|
||||
const Budget({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.totalAmount,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
required this.currency,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
factory Budget.fromJson(Map<String, dynamic> json) {
|
||||
return Budget(
|
||||
id: asInt(json['id']),
|
||||
userId: asInt(json['user_id']),
|
||||
name: asString(json['name']),
|
||||
totalAmount: asDouble(json['total_amount']),
|
||||
startDate: asDate(json['start_date']),
|
||||
endDate: asDate(json['end_date']),
|
||||
currency: asString(json['currency'], fallback: 'RUB'),
|
||||
isActive: asBool(json['is_active']),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int userId;
|
||||
final String name;
|
||||
final double totalAmount;
|
||||
final DateTime startDate;
|
||||
final DateTime endDate;
|
||||
final String currency;
|
||||
final bool isActive;
|
||||
}
|
||||
|
||||
/// Budget plus the server-computed daily envelope math.
|
||||
class BudgetStatus {
|
||||
const BudgetStatus({
|
||||
required this.budget,
|
||||
required this.today,
|
||||
required this.daysLeft,
|
||||
required this.totalSpent,
|
||||
required this.remaining,
|
||||
required this.dailyLimit,
|
||||
required this.spentToday,
|
||||
required this.remainingToday,
|
||||
required this.isOverDaily,
|
||||
required this.isOverBudget,
|
||||
required this.isExpired,
|
||||
required this.selected,
|
||||
});
|
||||
|
||||
factory BudgetStatus.fromJson(Map<String, dynamic> json) {
|
||||
return BudgetStatus(
|
||||
budget: Budget.fromJson(asMap(json['budget'])),
|
||||
today: asDate(json['today']),
|
||||
daysLeft: asInt(json['days_left']),
|
||||
totalSpent: asDouble(json['total_spent']),
|
||||
remaining: asDouble(json['remaining']),
|
||||
dailyLimit: asDouble(json['daily_limit']),
|
||||
spentToday: asDouble(json['spent_today']),
|
||||
remainingToday: asDouble(json['remaining_today']),
|
||||
isOverDaily: asBool(json['is_over_daily']),
|
||||
isOverBudget: asBool(json['is_over_budget']),
|
||||
isExpired: asBool(json['is_expired']),
|
||||
selected: asBool(json['selected']),
|
||||
);
|
||||
}
|
||||
|
||||
final Budget budget;
|
||||
final DateTime today;
|
||||
final int daysLeft;
|
||||
final double totalSpent;
|
||||
final double remaining;
|
||||
final double dailyLimit;
|
||||
final double spentToday;
|
||||
final double remainingToday;
|
||||
final bool isOverDaily;
|
||||
final bool isOverBudget;
|
||||
final bool isExpired;
|
||||
final bool selected;
|
||||
|
||||
/// 0..1 — share of the budget already spent.
|
||||
double get spentProgress {
|
||||
if (budget.totalAmount <= 0) return 0;
|
||||
return (totalSpent / budget.totalAmount).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 0..1 — share of today's envelope already spent.
|
||||
double get dailyProgress {
|
||||
if (dailyLimit <= 0) return spentToday > 0 ? 1 : 0;
|
||||
return (spentToday / dailyLimit).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
class BudgetsList {
|
||||
const BudgetsList({required this.items});
|
||||
|
||||
factory BudgetsList.fromJson(Map<String, dynamic> json) {
|
||||
return BudgetsList(
|
||||
items: asList(json['items']).map(BudgetStatus.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final List<BudgetStatus> items;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
class Expense {
|
||||
const Expense({
|
||||
required this.id,
|
||||
required this.budgetId,
|
||||
required this.amount,
|
||||
required this.spentAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
factory Expense.fromJson(Map<String, dynamic> json) {
|
||||
return Expense(
|
||||
id: asInt(json['id']),
|
||||
budgetId: asInt(json['budget_id']),
|
||||
amount: asDouble(json['amount']),
|
||||
spentAt: asDate(json['spent_at']),
|
||||
note: asStringOrNull(json['note']),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int budgetId;
|
||||
final double amount;
|
||||
final DateTime spentAt;
|
||||
final String? note;
|
||||
}
|
||||
|
||||
class ExpensesPage {
|
||||
const ExpensesPage({
|
||||
required this.page,
|
||||
required this.totalPages,
|
||||
required this.totalCount,
|
||||
required this.pageSize,
|
||||
required this.totalSum,
|
||||
required this.items,
|
||||
this.budgetId,
|
||||
});
|
||||
|
||||
factory ExpensesPage.fromJson(Map<String, dynamic> json) {
|
||||
return ExpensesPage(
|
||||
page: asInt(json['page'], fallback: 1),
|
||||
totalPages: asInt(json['total_pages'], fallback: 1),
|
||||
totalCount: asInt(json['total_count']),
|
||||
pageSize: asInt(json['page_size'], fallback: 20),
|
||||
totalSum: asDouble(json['total_sum']),
|
||||
budgetId: json['budget_id'] == null ? null : asInt(json['budget_id']),
|
||||
items: asList(json['items']).map(Expense.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static const empty = ExpensesPage(
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
totalCount: 0,
|
||||
pageSize: 20,
|
||||
totalSum: 0,
|
||||
items: [],
|
||||
);
|
||||
|
||||
final int page;
|
||||
final int totalPages;
|
||||
final int totalCount;
|
||||
final int pageSize;
|
||||
final double totalSum;
|
||||
final int? budgetId;
|
||||
final List<Expense> items;
|
||||
|
||||
bool get hasMore => page < totalPages;
|
||||
|
||||
ExpensesPage copyWithItems(List<Expense> items, {int? page}) {
|
||||
return ExpensesPage(
|
||||
page: page ?? this.page,
|
||||
totalPages: totalPages,
|
||||
totalCount: totalCount,
|
||||
pageSize: pageSize,
|
||||
totalSum: totalSum,
|
||||
budgetId: budgetId,
|
||||
items: items,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:please_pay_me/data/models/json.dart';
|
||||
|
||||
enum WeekendPolicy {
|
||||
beforeWeekend('before_weekend', 'До выходных'),
|
||||
afterWeekend('after_weekend', 'После выходных');
|
||||
|
||||
const WeekendPolicy(this.wire, this.label);
|
||||
|
||||
factory WeekendPolicy.fromWire(Object? value) {
|
||||
return WeekendPolicy.values.firstWhere(
|
||||
(policy) => policy.wire == asString(value),
|
||||
orElse: () => WeekendPolicy.beforeWeekend,
|
||||
);
|
||||
}
|
||||
|
||||
final String wire;
|
||||
final String label;
|
||||
}
|
||||
|
||||
class UpcomingPay {
|
||||
const UpcomingPay({
|
||||
required this.date,
|
||||
required this.scheduledDay,
|
||||
required this.percent,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
factory UpcomingPay.fromJson(Map<String, dynamic> json) {
|
||||
return UpcomingPay(
|
||||
date: asDate(json['date']),
|
||||
scheduledDay: asInt(json['scheduled_day']),
|
||||
percent: asDouble(json['percent']),
|
||||
amount: asDouble(json['amount']),
|
||||
);
|
||||
}
|
||||
|
||||
final DateTime date;
|
||||
final int scheduledDay;
|
||||
final double percent;
|
||||
final double amount;
|
||||
}
|
||||
|
||||
class Job {
|
||||
const Job({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
required this.salaryAmount,
|
||||
required this.currency,
|
||||
required this.payDays,
|
||||
required this.firstPayPercent,
|
||||
required this.weekendPolicy,
|
||||
required this.isActive,
|
||||
required this.nextPays,
|
||||
});
|
||||
|
||||
factory Job.fromJson(Map<String, dynamic> json) {
|
||||
final rawDays = json['pay_days'];
|
||||
return Job(
|
||||
id: asInt(json['id']),
|
||||
userId: asInt(json['user_id']),
|
||||
name: asString(json['name']),
|
||||
salaryAmount: asDouble(json['salary_amount']),
|
||||
currency: asString(json['currency'], fallback: 'RUB'),
|
||||
payDays: rawDays is List ? rawDays.map(asInt).toList() : const [],
|
||||
firstPayPercent: asDouble(json['first_pay_percent']),
|
||||
weekendPolicy: WeekendPolicy.fromWire(json['weekend_policy']),
|
||||
isActive: asBool(json['is_active']),
|
||||
nextPays: asList(json['next_pays']).map(UpcomingPay.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final int userId;
|
||||
final String name;
|
||||
final double salaryAmount;
|
||||
final String currency;
|
||||
final List<int> payDays;
|
||||
final double firstPayPercent;
|
||||
final WeekendPolicy weekendPolicy;
|
||||
final bool isActive;
|
||||
final List<UpcomingPay> nextPays;
|
||||
|
||||
UpcomingPay? get nextPay => nextPays.isEmpty ? null : nextPays.first;
|
||||
}
|
||||
|
||||
class JobsList {
|
||||
const JobsList({required this.items});
|
||||
|
||||
factory JobsList.fromJson(Map<String, dynamic> json) {
|
||||
return JobsList(items: asList(json['items']).map(Job.fromJson).toList());
|
||||
}
|
||||
|
||||
final List<Job> items;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// Tolerant JSON coercion helpers.
|
||||
///
|
||||
/// The API is generated from C# records, so numbers may arrive as `int` or
|
||||
/// `double` and nullable strings as `null`; parsing must not crash the UI.
|
||||
library;
|
||||
|
||||
int asInt(Object? value, {int fallback = 0}) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
double asDouble(Object? value, {double fallback = 0}) {
|
||||
if (value is double) return value;
|
||||
if (value is num) return value.toDouble();
|
||||
if (value is String) return double.tryParse(value.replaceAll(',', '.')) ?? fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
bool asBool(Object? value, {bool fallback = false}) {
|
||||
if (value is bool) return value;
|
||||
if (value is String) return value.toLowerCase() == 'true';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
String asString(Object? value, {String fallback = ''}) {
|
||||
if (value is String) return value;
|
||||
if (value == null) return fallback;
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
String? asStringOrNull(Object? value) {
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Parses `YYYY-MM-DD` (and full ISO timestamps) as a local calendar day.
|
||||
DateTime asDate(Object? value) {
|
||||
final raw = asString(value);
|
||||
if (raw.isEmpty) return DateTime.now();
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) return DateTime.now();
|
||||
return DateTime(parsed.year, parsed.month, parsed.day);
|
||||
}
|
||||
|
||||
Map<String, dynamic> asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return value.cast<String, dynamic>();
|
||||
return const {};
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> asList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.map(asMap).toList();
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD` — the format every date-typed endpoint expects.
|
||||
String formatIsoDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
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/models/json.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
class ApiBudgetRepository implements BudgetRepository {
|
||||
const ApiBudgetRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<List<BudgetStatus>> list() async {
|
||||
final json = await _client.getJson('/api/me/budgets');
|
||||
return BudgetsList.fromJson(json).items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> status({int? budgetId}) async {
|
||||
final json = await _client.getJson(
|
||||
'/api/me/budget',
|
||||
query: {if (budgetId != null) 'budget_id': '$budgetId'},
|
||||
);
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/budgets', body: {
|
||||
'name': name,
|
||||
'total_amount': totalAmount,
|
||||
'end_date': formatIsoDate(endDate),
|
||||
'start_date': startDate == null ? null : formatIsoDate(startDate),
|
||||
'is_active': true,
|
||||
'select': true,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
}) async {
|
||||
final json = await _client.putJson('/api/me/budgets/$budgetId', body: {
|
||||
if (name != null) 'name': name,
|
||||
if (totalAmount != null) 'total_amount': totalAmount,
|
||||
if (endDate != null) 'end_date': formatIsoDate(endDate),
|
||||
if (startDate != null) 'start_date': formatIsoDate(startDate),
|
||||
'reset_expenses': resetExpenses,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> select(int budgetId) async {
|
||||
final json = await _client.postJson('/api/me/budgets/$budgetId/select');
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> setActive(int budgetId, {required bool isActive}) async {
|
||||
final json = await _client.patchJson(
|
||||
'/api/me/budgets/$budgetId/active',
|
||||
body: {'is_active': isActive},
|
||||
);
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int budgetId) => _client.deleteJson('/api/me/budgets/$budgetId');
|
||||
}
|
||||
|
||||
class ApiExpenseRepository implements ExpenseRepository {
|
||||
const ApiExpenseRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<ExpensesPage> page({
|
||||
required int page,
|
||||
int pageSize = 20,
|
||||
int? budgetId,
|
||||
bool all = false,
|
||||
}) async {
|
||||
final json = await _client.getJson('/api/me/expenses', query: {
|
||||
'page': '$page',
|
||||
'page_size': '$pageSize',
|
||||
if (all) 'all': 'true' else if (budgetId != null) 'budget_id': '$budgetId',
|
||||
});
|
||||
return ExpensesPage.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BudgetStatus> create({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/expenses', body: {
|
||||
'amount': amount,
|
||||
'note': note,
|
||||
'spent_at': spentAt == null ? null : formatIsoDate(spentAt),
|
||||
'budget_id': budgetId,
|
||||
});
|
||||
return BudgetStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> undoLast({int? budgetId}) async {
|
||||
final json = await _client.deleteJson(
|
||||
'/api/me/expenses/last',
|
||||
query: {if (budgetId != null) 'budget_id': '$budgetId'},
|
||||
);
|
||||
return asDouble(json['deleted_amount']);
|
||||
}
|
||||
}
|
||||
|
||||
class ApiJobRepository implements JobRepository {
|
||||
const ApiJobRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<List<Job>> list() async {
|
||||
final json = await _client.getJson('/api/me/jobs');
|
||||
return JobsList.fromJson(json).items;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Job> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) async {
|
||||
final json = await _client.postJson('/api/me/jobs', body: {
|
||||
'name': name,
|
||||
'salary_amount': salaryAmount,
|
||||
'pay_days': payDays,
|
||||
'first_pay_percent': firstPayPercent,
|
||||
'weekend_policy': weekendPolicy.wire,
|
||||
'is_active': true,
|
||||
});
|
||||
return Job.fromJson(json);
|
||||
}
|
||||
|
||||
@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 json = await _client.putJson('/api/me/jobs/$jobId', body: {
|
||||
'name': name,
|
||||
'salary_amount': salaryAmount,
|
||||
'pay_days': payDays,
|
||||
'first_pay_percent': firstPayPercent,
|
||||
'weekend_policy': weekendPolicy.wire,
|
||||
'is_active': isActive,
|
||||
});
|
||||
return Job.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(int jobId) => _client.deleteJson('/api/me/jobs/$jobId');
|
||||
}
|
||||
|
||||
class ApiUserRepository implements UserRepository {
|
||||
const ApiUserRepository(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
@override
|
||||
Future<AuthUser> me() async => AuthUser.fromJson(await _client.getJson('/api/me'));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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';
|
||||
|
||||
/// Contracts the UI depends on. Implemented by the REST backend and by the
|
||||
/// in-memory demo backend used for previews and tests.
|
||||
abstract interface class BudgetRepository {
|
||||
Future<List<BudgetStatus>> list();
|
||||
|
||||
Future<BudgetStatus> status({int? budgetId});
|
||||
|
||||
Future<BudgetStatus> create({
|
||||
required String name,
|
||||
required double totalAmount,
|
||||
required DateTime endDate,
|
||||
DateTime? startDate,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> update({
|
||||
required int budgetId,
|
||||
String? name,
|
||||
double? totalAmount,
|
||||
DateTime? endDate,
|
||||
DateTime? startDate,
|
||||
bool resetExpenses = false,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> select(int budgetId);
|
||||
|
||||
Future<BudgetStatus> setActive(int budgetId, {required bool isActive});
|
||||
|
||||
Future<void> delete(int budgetId);
|
||||
}
|
||||
|
||||
abstract interface class ExpenseRepository {
|
||||
Future<ExpensesPage> page({
|
||||
required int page,
|
||||
int pageSize = 20,
|
||||
int? budgetId,
|
||||
bool all = false,
|
||||
});
|
||||
|
||||
Future<BudgetStatus> create({
|
||||
required double amount,
|
||||
String? note,
|
||||
DateTime? spentAt,
|
||||
int? budgetId,
|
||||
});
|
||||
|
||||
Future<double> undoLast({int? budgetId});
|
||||
}
|
||||
|
||||
abstract interface class JobRepository {
|
||||
Future<List<Job>> list();
|
||||
|
||||
Future<Job> create({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
Future<void> delete(int jobId);
|
||||
}
|
||||
|
||||
abstract interface class UserRepository {
|
||||
Future<AuthUser> me();
|
||||
}
|
||||
Reference in New Issue
Block a user