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