feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+124
View File
@@ -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();
}
+413
View File
@@ -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;
}
+79
View File
@@ -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;
}
+108
View File
@@ -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;
}
+82
View File
@@ -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,
);
}
}
+95
View File
@@ -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;
}
+63
View File
@@ -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();
}