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