delete CloudKit and added BalanceFlutter
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
This commit is contained in:
1 parent
a715eca947
commit
2c278044bb
214 files changed
+13411
-2290
No files matched your search
@@ -0,0 +1,685 @@
|
||||
/// Finance values use hundredths of a currency unit internally. JSON retains
|
||||
/// the existing iOS / Android / Go synchronization protocol.
|
||||
enum TransactionKind {
|
||||
expense,
|
||||
income;
|
||||
|
||||
String get title => this == income ? 'Доход' : 'Расход';
|
||||
static TransactionKind parse(String value) =>
|
||||
value == 'income' ? income : expense;
|
||||
}
|
||||
|
||||
int moneyToMinor(num value) {
|
||||
if (!value.isFinite) throw const FormatException('Invalid money amount');
|
||||
return (value * 100).round();
|
||||
}
|
||||
|
||||
/// Parse user-entered amounts without passing through binary floating point.
|
||||
/// An empty value, exponent, sign or more than two decimal places is rejected.
|
||||
int parseMoney(String value) {
|
||||
final normalized = value.trim().replaceAll(',', '.');
|
||||
if (!RegExp(r'^\d+(?:\.\d{1,2})?$').hasMatch(normalized)) {
|
||||
throw const FormatException(
|
||||
'Введите положительную сумму, максимум 2 знака после запятой',
|
||||
);
|
||||
}
|
||||
final parts = normalized.split('.');
|
||||
return int.parse(parts[0]) * 100 +
|
||||
(parts.length == 1 ? 0 : int.parse(parts[1].padRight(2, '0')));
|
||||
}
|
||||
|
||||
String formatMinor(int value) {
|
||||
final absolute = value.abs();
|
||||
return '${value < 0 ? '-' : ''}${absolute ~/ 100}.${(absolute % 100).toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
DateTime _date(dynamic value) => DateTime.parse(value as String);
|
||||
String _timestamp(DateTime value) => value.toUtc().toIso8601String();
|
||||
|
||||
class TransactionRecord {
|
||||
final String id;
|
||||
final int amountMinor;
|
||||
final DateTime date;
|
||||
final String note,
|
||||
categoryName,
|
||||
categoryIcon,
|
||||
categoryEmoji,
|
||||
categoryColorName;
|
||||
final bool isBalanceAdjustment;
|
||||
final String kindRawValue;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const TransactionRecord({
|
||||
required this.id,
|
||||
required this.amountMinor,
|
||||
required this.date,
|
||||
required this.categoryName,
|
||||
required this.updatedAt,
|
||||
this.note = '',
|
||||
this.categoryIcon = 'circle.fill',
|
||||
this.categoryEmoji = '',
|
||||
this.categoryColorName = 'auto',
|
||||
this.isBalanceAdjustment = false,
|
||||
this.kindRawValue = 'expense',
|
||||
});
|
||||
|
||||
double get amount => amountMinor / 100;
|
||||
TransactionKind get kind => TransactionKind.parse(kindRawValue);
|
||||
Map<String, dynamic> payload() => {
|
||||
'amount': amount,
|
||||
'date': _timestamp(date),
|
||||
'note': note,
|
||||
'categoryName': categoryName,
|
||||
'categoryIcon': categoryIcon,
|
||||
'categoryEmoji': categoryEmoji,
|
||||
'categoryColorName': categoryColorName,
|
||||
'isBalanceAdjustment': isBalanceAdjustment,
|
||||
'kindRawValue': kindRawValue,
|
||||
};
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'updatedAt': _timestamp(updatedAt),
|
||||
...payload(),
|
||||
'amountMinor': amountMinor,
|
||||
};
|
||||
factory TransactionRecord.fromJson(Map<String, dynamic> json) =>
|
||||
TransactionRecord.fromPayload(
|
||||
id: json['id'] as String,
|
||||
updatedAt: _date(json['updatedAt']),
|
||||
payload: json,
|
||||
).copyWith(amountMinor: json['amountMinor'] as int?);
|
||||
factory TransactionRecord.fromPayload({
|
||||
required String id,
|
||||
required DateTime updatedAt,
|
||||
required Map<String, dynamic> payload,
|
||||
}) => TransactionRecord(
|
||||
id: id,
|
||||
updatedAt: updatedAt,
|
||||
amountMinor: moneyToMinor(payload['amount'] as num),
|
||||
date: _date(payload['date']),
|
||||
categoryName: payload['categoryName'] as String,
|
||||
note: payload['note'] as String? ?? '',
|
||||
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
|
||||
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
|
||||
categoryColorName: payload['categoryColorName'] as String? ?? 'auto',
|
||||
isBalanceAdjustment: payload['isBalanceAdjustment'] as bool? ?? false,
|
||||
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
|
||||
);
|
||||
TransactionRecord copyWith({
|
||||
String? id,
|
||||
int? amountMinor,
|
||||
DateTime? date,
|
||||
String? note,
|
||||
String? categoryName,
|
||||
String? categoryIcon,
|
||||
String? categoryEmoji,
|
||||
String? categoryColorName,
|
||||
bool? isBalanceAdjustment,
|
||||
String? kindRawValue,
|
||||
DateTime? updatedAt,
|
||||
}) => TransactionRecord(
|
||||
id: id ?? this.id,
|
||||
amountMinor: amountMinor ?? this.amountMinor,
|
||||
date: date ?? this.date,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
note: note ?? this.note,
|
||||
categoryName: categoryName ?? this.categoryName,
|
||||
categoryIcon: categoryIcon ?? this.categoryIcon,
|
||||
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
|
||||
categoryColorName: categoryColorName ?? this.categoryColorName,
|
||||
isBalanceAdjustment: isBalanceAdjustment ?? this.isBalanceAdjustment,
|
||||
kindRawValue: kindRawValue ?? this.kindRawValue,
|
||||
);
|
||||
}
|
||||
|
||||
class CategoryRecord {
|
||||
final String id, name, icon, emoji, colorName, kindRawValue;
|
||||
final DateTime createdAt, updatedAt;
|
||||
const CategoryRecord({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.colorName,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.emoji = '',
|
||||
this.kindRawValue = 'expense',
|
||||
});
|
||||
TransactionKind get kind => TransactionKind.parse(kindRawValue);
|
||||
Map<String, dynamic> payload() => {
|
||||
'name': name,
|
||||
'icon': icon,
|
||||
'emoji': emoji,
|
||||
'colorName': colorName,
|
||||
'kindRawValue': kindRawValue,
|
||||
'createdAt': _timestamp(createdAt),
|
||||
};
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'updatedAt': _timestamp(updatedAt),
|
||||
...payload(),
|
||||
};
|
||||
factory CategoryRecord.fromJson(Map<String, dynamic> json) =>
|
||||
CategoryRecord.fromPayload(
|
||||
id: json['id'] as String,
|
||||
updatedAt: _date(json['updatedAt']),
|
||||
payload: json,
|
||||
);
|
||||
factory CategoryRecord.fromPayload({
|
||||
required String id,
|
||||
required DateTime updatedAt,
|
||||
required Map<String, dynamic> payload,
|
||||
}) => CategoryRecord(
|
||||
id: id,
|
||||
updatedAt: updatedAt,
|
||||
name: payload['name'] as String,
|
||||
icon: payload['icon'] as String? ?? 'star.fill',
|
||||
emoji: payload['emoji'] as String? ?? '',
|
||||
colorName: payload['colorName'] as String? ?? 'indigo',
|
||||
kindRawValue: payload['kindRawValue'] as String? ?? 'expense',
|
||||
createdAt: payload['createdAt'] == null
|
||||
? updatedAt
|
||||
: _date(payload['createdAt']),
|
||||
);
|
||||
CategoryRecord copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? icon,
|
||||
String? emoji,
|
||||
String? colorName,
|
||||
String? kindRawValue,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) => CategoryRecord(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
icon: icon ?? this.icon,
|
||||
emoji: emoji ?? this.emoji,
|
||||
colorName: colorName ?? this.colorName,
|
||||
kindRawValue: kindRawValue ?? this.kindRawValue,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
class BudgetRecord {
|
||||
final String id, categoryName, categoryIcon, categoryEmoji;
|
||||
final int limitMinor;
|
||||
final DateTime monthStart, updatedAt;
|
||||
const BudgetRecord({
|
||||
required this.id,
|
||||
required this.categoryName,
|
||||
required this.limitMinor,
|
||||
required this.monthStart,
|
||||
required this.updatedAt,
|
||||
this.categoryIcon = 'circle.fill',
|
||||
this.categoryEmoji = '',
|
||||
});
|
||||
double get limit => limitMinor / 100;
|
||||
Map<String, dynamic> payload() => {
|
||||
'categoryName': categoryName,
|
||||
'categoryIcon': categoryIcon,
|
||||
'categoryEmoji': categoryEmoji,
|
||||
'limit': limit,
|
||||
'monthStart': _timestamp(monthStart),
|
||||
};
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'updatedAt': _timestamp(updatedAt),
|
||||
...payload(),
|
||||
'limitMinor': limitMinor,
|
||||
};
|
||||
factory BudgetRecord.fromJson(Map<String, dynamic> json) =>
|
||||
BudgetRecord.fromPayload(
|
||||
id: json['id'] as String,
|
||||
updatedAt: _date(json['updatedAt']),
|
||||
payload: json,
|
||||
).copyWith(limitMinor: json['limitMinor'] as int?);
|
||||
factory BudgetRecord.fromPayload({
|
||||
required String id,
|
||||
required DateTime updatedAt,
|
||||
required Map<String, dynamic> payload,
|
||||
}) => BudgetRecord(
|
||||
id: id,
|
||||
updatedAt: updatedAt,
|
||||
categoryName: payload['categoryName'] as String,
|
||||
categoryIcon: payload['categoryIcon'] as String? ?? 'circle.fill',
|
||||
categoryEmoji: payload['categoryEmoji'] as String? ?? '',
|
||||
limitMinor: moneyToMinor(payload['limit'] as num),
|
||||
monthStart: _date(payload['monthStart']),
|
||||
);
|
||||
BudgetRecord copyWith({
|
||||
String? id,
|
||||
String? categoryName,
|
||||
String? categoryIcon,
|
||||
String? categoryEmoji,
|
||||
int? limitMinor,
|
||||
DateTime? monthStart,
|
||||
DateTime? updatedAt,
|
||||
}) => BudgetRecord(
|
||||
id: id ?? this.id,
|
||||
categoryName: categoryName ?? this.categoryName,
|
||||
categoryIcon: categoryIcon ?? this.categoryIcon,
|
||||
categoryEmoji: categoryEmoji ?? this.categoryEmoji,
|
||||
limitMinor: limitMinor ?? this.limitMinor,
|
||||
monthStart: monthStart ?? this.monthStart,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
class Tombstone {
|
||||
final String entity, id;
|
||||
final DateTime updatedAt;
|
||||
const Tombstone({
|
||||
required this.entity,
|
||||
required this.id,
|
||||
required this.updatedAt,
|
||||
});
|
||||
String get recordID => id;
|
||||
Map<String, dynamic> toJson() => {
|
||||
'entity': entity,
|
||||
'id': id,
|
||||
'deleted': true,
|
||||
'updatedAt': _timestamp(updatedAt),
|
||||
};
|
||||
factory Tombstone.fromJson(Map<String, dynamic> json) => Tombstone(
|
||||
entity: json['entity'] as String,
|
||||
id: json['id'] as String,
|
||||
updatedAt: _date(json['updatedAt']),
|
||||
);
|
||||
}
|
||||
|
||||
class FinanceSummary {
|
||||
final int incomeMinor, expensesMinor;
|
||||
const FinanceSummary({
|
||||
required this.incomeMinor,
|
||||
required this.expensesMinor,
|
||||
});
|
||||
int get balanceMinor => incomeMinor - expensesMinor;
|
||||
double get income => incomeMinor / 100;
|
||||
double get expenses => expensesMinor / 100;
|
||||
double get balance => balanceMinor / 100;
|
||||
}
|
||||
|
||||
class CategorySpending {
|
||||
final String name, icon, emoji, colorName;
|
||||
final int amountMinor;
|
||||
const CategorySpending({
|
||||
required this.name,
|
||||
required this.icon,
|
||||
required this.emoji,
|
||||
required this.colorName,
|
||||
required this.amountMinor,
|
||||
});
|
||||
double get amount => amountMinor / 100;
|
||||
}
|
||||
|
||||
class MonthlyTotal {
|
||||
final DateTime month;
|
||||
final FinanceSummary summary;
|
||||
const MonthlyTotal(this.month, this.summary);
|
||||
}
|
||||
|
||||
class FinanceMath {
|
||||
static int totalBalance(Iterable<TransactionRecord> items) => items.fold(
|
||||
0,
|
||||
(value, item) =>
|
||||
value +
|
||||
(item.kind == TransactionKind.income
|
||||
? item.amountMinor
|
||||
: -item.amountMinor),
|
||||
);
|
||||
static DateTime monthStart(DateTime date) => date.isUtc
|
||||
? DateTime.utc(date.year, date.month)
|
||||
: DateTime(date.year, date.month);
|
||||
static DateTime nextMonth(DateTime date) => date.isUtc
|
||||
? DateTime.utc(date.year, date.month + 1)
|
||||
: DateTime(date.year, date.month + 1);
|
||||
static bool _inPeriod(TransactionRecord item, DateTime start, DateTime end) =>
|
||||
!item.isBalanceAdjustment &&
|
||||
!item.date.isBefore(start) &&
|
||||
item.date.isBefore(end);
|
||||
static FinanceSummary summary(
|
||||
Iterable<TransactionRecord> items,
|
||||
DateTime start,
|
||||
DateTime endExclusive,
|
||||
) {
|
||||
var income = 0;
|
||||
var expenses = 0;
|
||||
for (final item in items) {
|
||||
if (!_inPeriod(item, start, endExclusive)) continue;
|
||||
if (item.kind == TransactionKind.income) {
|
||||
income += item.amountMinor;
|
||||
} else {
|
||||
expenses += item.amountMinor;
|
||||
}
|
||||
}
|
||||
return FinanceSummary(incomeMinor: income, expensesMinor: expenses);
|
||||
}
|
||||
|
||||
static List<CategorySpending> spending(
|
||||
Iterable<TransactionRecord> items,
|
||||
DateTime start,
|
||||
DateTime endExclusive,
|
||||
) {
|
||||
final grouped = <String, CategorySpending>{};
|
||||
for (final item in items) {
|
||||
if (item.kind != TransactionKind.expense ||
|
||||
!_inPeriod(item, start, endExclusive)) {
|
||||
continue;
|
||||
}
|
||||
final existing = grouped[item.categoryName];
|
||||
grouped[item.categoryName] = CategorySpending(
|
||||
name: item.categoryName,
|
||||
icon: existing?.icon ?? item.categoryIcon,
|
||||
emoji: existing?.emoji ?? item.categoryEmoji,
|
||||
colorName: existing?.colorName ?? item.categoryColorName,
|
||||
amountMinor: (existing?.amountMinor ?? 0) + item.amountMinor,
|
||||
);
|
||||
}
|
||||
return grouped.values.toList()..sort((a, b) {
|
||||
final byAmount = b.amountMinor.compareTo(a.amountMinor);
|
||||
return byAmount == 0 ? a.name.compareTo(b.name) : byAmount;
|
||||
});
|
||||
}
|
||||
|
||||
/// Includes empty months, ordered from oldest to newest.
|
||||
static List<MonthlyTotal> monthlyTrend(
|
||||
Iterable<TransactionRecord> items,
|
||||
DateTime through, {
|
||||
int months = 6,
|
||||
}) {
|
||||
if (months < 0) throw ArgumentError.value(months, 'months');
|
||||
final snapshot = items.toList();
|
||||
return List.generate(months, (index) {
|
||||
final month = through.isUtc
|
||||
? DateTime.utc(through.year, through.month - months + 1 + index)
|
||||
: DateTime(through.year, through.month - months + 1 + index);
|
||||
return MonthlyTotal(month, summary(snapshot, month, nextMonth(month)));
|
||||
});
|
||||
}
|
||||
|
||||
static int budgetUsed(
|
||||
BudgetRecord budget,
|
||||
Iterable<TransactionRecord> items,
|
||||
) {
|
||||
// Native clients serialize local month starts as UTC instants. In positive
|
||||
// offsets January 1 becomes December 31 on the wire; recover the local
|
||||
// calendar month before building its spending interval.
|
||||
final start = monthStart(budget.monthStart.toLocal());
|
||||
return items
|
||||
.where(
|
||||
(item) =>
|
||||
item.categoryName == budget.categoryName &&
|
||||
item.kind == TransactionKind.expense &&
|
||||
_inPeriod(item, start, nextMonth(start)),
|
||||
)
|
||||
.fold(0, (value, item) => value + item.amountMinor);
|
||||
}
|
||||
|
||||
static String escapeCsv(String value) => '"${value.replaceAll('"', '""')}"';
|
||||
static String exportCsv(Iterable<TransactionRecord> items) {
|
||||
final ordered = items.toList()..sort((a, b) => b.date.compareTo(a.date));
|
||||
return [
|
||||
'Дата,Тип,Категория,Сумма,Заметка',
|
||||
...ordered.map(
|
||||
(item) => [
|
||||
_timestamp(item.date),
|
||||
item.isBalanceAdjustment ? 'Корректировка' : item.kind.title,
|
||||
item.categoryName,
|
||||
formatMinor(item.amountMinor),
|
||||
item.note,
|
||||
].map(escapeCsv).join(','),
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryDesignOptions {
|
||||
static const icons = <String>[
|
||||
'star.fill',
|
||||
'heart.fill',
|
||||
'sparkles',
|
||||
'bolt.fill',
|
||||
'flame.fill',
|
||||
'drop.fill',
|
||||
'cart.fill',
|
||||
'bag.fill',
|
||||
'creditcard.fill',
|
||||
'banknote.fill',
|
||||
'gift.fill',
|
||||
'tag.fill',
|
||||
'cup.and.saucer.fill',
|
||||
'fork.knife',
|
||||
'takeoutbag.and.cup.and.straw.fill',
|
||||
'birthday.cake.fill',
|
||||
'car.fill',
|
||||
'bus.fill',
|
||||
'tram.fill',
|
||||
'bicycle',
|
||||
'airplane',
|
||||
'fuelpump.fill',
|
||||
'house.fill',
|
||||
'bed.double.fill',
|
||||
'lightbulb.fill',
|
||||
'wifi',
|
||||
'phone.fill',
|
||||
'printer.fill',
|
||||
'pawprint.fill',
|
||||
'leaf.fill',
|
||||
'figure.walk',
|
||||
'figure.run',
|
||||
'dumbbell.fill',
|
||||
'cross.case.fill',
|
||||
'pills.fill',
|
||||
'stethoscope',
|
||||
'book.fill',
|
||||
'graduationcap.fill',
|
||||
'laptopcomputer',
|
||||
'briefcase.fill',
|
||||
'gamecontroller.fill',
|
||||
'music.note',
|
||||
'film.fill',
|
||||
'camera.fill',
|
||||
'paintpalette.fill',
|
||||
'tshirt.fill',
|
||||
'hammer.fill',
|
||||
'wrench.and.screwdriver.fill',
|
||||
'shippingbox.fill',
|
||||
'globe.europe.africa.fill',
|
||||
];
|
||||
static const emojis = <String>[
|
||||
'🍔',
|
||||
'☕️',
|
||||
'🛒',
|
||||
'🚕',
|
||||
'✈️',
|
||||
'🏠',
|
||||
'💡',
|
||||
'📱',
|
||||
'💻',
|
||||
'🎮',
|
||||
'🎬',
|
||||
'🎵',
|
||||
'📚',
|
||||
'🎓',
|
||||
'💊',
|
||||
'🏋️',
|
||||
'🐶',
|
||||
'🐱',
|
||||
'👕',
|
||||
'🎁',
|
||||
'💰',
|
||||
'💳',
|
||||
'📈',
|
||||
'🔧',
|
||||
'🌿',
|
||||
'❤️',
|
||||
'⭐️',
|
||||
'🎯',
|
||||
'🧾',
|
||||
'🏖️',
|
||||
];
|
||||
static const colors = <String>[
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'mint',
|
||||
'teal',
|
||||
'cyan',
|
||||
'blue',
|
||||
'indigo',
|
||||
'purple',
|
||||
'pink',
|
||||
'gray',
|
||||
'coral',
|
||||
'peach',
|
||||
'gold',
|
||||
'lime',
|
||||
'forest',
|
||||
'turquoise',
|
||||
'sky',
|
||||
'navy',
|
||||
'lavender',
|
||||
'magenta',
|
||||
'brown',
|
||||
'slate',
|
||||
];
|
||||
}
|
||||
|
||||
class BuiltInCategories {
|
||||
static final _epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
static final expense = List<CategoryRecord>.unmodifiable([
|
||||
CategoryRecord(
|
||||
id: 'food',
|
||||
name: 'Продукты',
|
||||
icon: 'cart.fill',
|
||||
colorName: 'orange',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'transport',
|
||||
name: 'Транспорт',
|
||||
icon: 'car.fill',
|
||||
colorName: 'blue',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'home',
|
||||
name: 'Дом',
|
||||
icon: 'house.fill',
|
||||
colorName: 'indigo',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'entertainment',
|
||||
name: 'Развлечения',
|
||||
icon: 'gamecontroller.fill',
|
||||
colorName: 'pink',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'health',
|
||||
name: 'Здоровье',
|
||||
icon: 'cross.case.fill',
|
||||
colorName: 'red',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'shopping',
|
||||
name: 'Покупки',
|
||||
icon: 'bag.fill',
|
||||
colorName: 'purple',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'subscriptions',
|
||||
name: 'Подписки',
|
||||
icon: 'repeat',
|
||||
colorName: 'cyan',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'education',
|
||||
name: 'Образование',
|
||||
icon: 'book.fill',
|
||||
colorName: 'mint',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'other-expense',
|
||||
name: 'Другое',
|
||||
icon: 'ellipsis.circle.fill',
|
||||
colorName: 'gray',
|
||||
kindRawValue: 'expense',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
]);
|
||||
static final income = List<CategoryRecord>.unmodifiable([
|
||||
CategoryRecord(
|
||||
id: 'salary',
|
||||
name: 'Зарплата',
|
||||
icon: 'banknote.fill',
|
||||
colorName: 'green',
|
||||
kindRawValue: 'income',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'freelance',
|
||||
name: 'Подработка',
|
||||
icon: 'laptopcomputer',
|
||||
colorName: 'teal',
|
||||
kindRawValue: 'income',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'investments',
|
||||
name: 'Инвестиции',
|
||||
icon: 'chart.line.uptrend.xyaxis',
|
||||
colorName: 'blue',
|
||||
kindRawValue: 'income',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'gift',
|
||||
name: 'Подарок',
|
||||
icon: 'gift.fill',
|
||||
colorName: 'pink',
|
||||
kindRawValue: 'income',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
CategoryRecord(
|
||||
id: 'other-income',
|
||||
name: 'Другое',
|
||||
icon: 'plus.circle.fill',
|
||||
colorName: 'gray',
|
||||
kindRawValue: 'income',
|
||||
createdAt: _epoch,
|
||||
updatedAt: _epoch,
|
||||
),
|
||||
]);
|
||||
static List<CategoryRecord> forKind(String kind) =>
|
||||
kind == 'income' ? income : expense;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart'
|
||||
show LicenseRegistry, LicenseEntryWithLineBreaks;
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'services/app_store.dart';
|
||||
import 'services/background.dart';
|
||||
import 'ui/app.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
LicenseRegistry.addLicense(() async* {
|
||||
yield LicenseEntryWithLineBreaks([
|
||||
'Roboto',
|
||||
], await rootBundle.loadString('assets/fonts/LICENSE.txt'));
|
||||
});
|
||||
final store = AppStore();
|
||||
try {
|
||||
await store.initialize();
|
||||
try {
|
||||
await configureBackgroundSync();
|
||||
} catch (_) {
|
||||
store.error =
|
||||
'Не удалось включить фоновую синхронизацию. Синхронизация при открытии приложения доступна.';
|
||||
}
|
||||
runApp(BalanceApp(store: store));
|
||||
} catch (error) {
|
||||
runApp(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.storage_outlined, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Не удалось открыть хранилище',
|
||||
style: TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SelectableText('$error'),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: main,
|
||||
child: const Text('Повторить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../domain/finance.dart';
|
||||
import 'storage.dart';
|
||||
import 'runtime_lock.dart';
|
||||
import 'sync_api.dart';
|
||||
|
||||
class AuthSession {
|
||||
AuthSession(this.data) {
|
||||
if (userId.isEmpty ||
|
||||
email.isEmpty ||
|
||||
accessToken.isEmpty ||
|
||||
refreshToken.isEmpty ||
|
||||
expiresAt.millisecondsSinceEpoch <= 0) {
|
||||
throw const FormatException('Invalid saved session');
|
||||
}
|
||||
}
|
||||
final Map<String, dynamic> data;
|
||||
String get userId => (data['user'] as Map)['id'] as String;
|
||||
String get email => (data['user'] as Map)['email'] as String;
|
||||
String get accessToken => data['accessToken'] as String;
|
||||
String get refreshToken => data['refreshToken'] as String;
|
||||
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
|
||||
}
|
||||
|
||||
/// All durable mutations and account transitions share one queue. A pull page
|
||||
/// writes records, tombstones, dirty changes and cursor in one database commit.
|
||||
class AppStore extends ChangeNotifier with WidgetsBindingObserver {
|
||||
AppStore({
|
||||
SnapshotStorage? storage,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
SyncApi Function(String serverUrl)? apiFactory,
|
||||
this.background = false,
|
||||
}) : _storage = storage,
|
||||
_injectedStorage = storage != null,
|
||||
_apiFactory = apiFactory,
|
||||
_secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
SnapshotStorage? _storage;
|
||||
final bool background;
|
||||
final bool _injectedStorage;
|
||||
final RuntimeLock _runtimeLock = RuntimeLock();
|
||||
AuthSession? _volatileSession;
|
||||
String? _volatileRevision;
|
||||
String? _volatileServer;
|
||||
final SyncApi Function(String serverUrl)? _apiFactory;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
SharedPreferences? _preferences;
|
||||
Future<void> _tail = Future<void>.value();
|
||||
Timer? _timer;
|
||||
bool _disposed = false;
|
||||
bool ready = false;
|
||||
bool syncing = false;
|
||||
String? error;
|
||||
String? syncMessage;
|
||||
DateTime? lastSyncAt;
|
||||
String serverUrl = '';
|
||||
String currency = 'RUB';
|
||||
String theme = 'system';
|
||||
AuthSession? session;
|
||||
String? sessionPersistenceWarning;
|
||||
String get email => session?.email ?? '';
|
||||
String _deviceId = '';
|
||||
String _scope = '';
|
||||
int _cursor = 0;
|
||||
final Map<String, TransactionRecord> _transactions = {};
|
||||
final Map<String, CategoryRecord> _categories = {};
|
||||
final Map<String, BudgetRecord> _budgets = {};
|
||||
final Map<String, Map<String, dynamic>> _tombstones = {};
|
||||
final Map<String, Map<String, dynamic>> _dirty = {};
|
||||
|
||||
List<TransactionRecord> get transactions =>
|
||||
_transactions.values.toList()..sort((a, b) => b.date.compareTo(a.date));
|
||||
List<CategoryRecord> get categories =>
|
||||
_categories.values.toList()..sort((a, b) => a.name.compareTo(b.name));
|
||||
List<BudgetRecord> get budgets =>
|
||||
_budgets.values.toList()
|
||||
..sort((a, b) => b.monthStart.compareTo(a.monthStart));
|
||||
int get balanceMinor => _transactions.values.fold(
|
||||
0,
|
||||
(sum, t) =>
|
||||
sum + (t.kindRawValue == 'income' ? t.amountMinor : -t.amountMinor),
|
||||
);
|
||||
int get pendingChangeCount => _dirty.length;
|
||||
int get cursor => _cursor;
|
||||
static String newId() => const Uuid().v4();
|
||||
|
||||
Future<T> _exclusive<T>(Future<T> Function() body) {
|
||||
final result = _tail.then((_) async {
|
||||
if (_disposed) throw StateError('AppStore is closed');
|
||||
if (!_injectedStorage) await _runtimeLock.acquire();
|
||||
try {
|
||||
if (ready) await _reloadSharedState();
|
||||
return await body();
|
||||
} finally {
|
||||
if (!_injectedStorage) await _runtimeLock.release();
|
||||
}
|
||||
});
|
||||
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
error = null;
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> initialize() => _exclusive(() async {
|
||||
if (ready) return;
|
||||
_preferences = await SharedPreferences.getInstance();
|
||||
await _preferences!.reload();
|
||||
serverUrl = _preferences!.getString('serverUrl') ?? serverUrl;
|
||||
currency = _preferences!.getString('currency') ?? currency;
|
||||
theme = _preferences!.getString('theme') ?? theme;
|
||||
_deviceId = _preferences!.getString('deviceId') ?? newId();
|
||||
await _preferences!.setString('deviceId', _deviceId);
|
||||
_storage ??= await LedgerStorage.open();
|
||||
await _restoreSession();
|
||||
await _loadScope();
|
||||
if (!background) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_timer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
_backgroundSync();
|
||||
});
|
||||
}
|
||||
ready = true;
|
||||
_notify();
|
||||
if (!background) _backgroundSync();
|
||||
});
|
||||
|
||||
Future<void> _reloadSharedState() async {
|
||||
await _preferences!.reload();
|
||||
final nextServer = _preferences!.getString('serverUrl') ?? '';
|
||||
if (nextServer != serverUrl) {
|
||||
_apiInstance?.close();
|
||||
_apiInstance = null;
|
||||
serverUrl = nextServer;
|
||||
}
|
||||
currency = _preferences!.getString('currency') ?? 'RUB';
|
||||
theme = _preferences!.getString('theme') ?? 'system';
|
||||
_deviceId = _preferences!.getString('deviceId') ?? _deviceId;
|
||||
await _restoreSession();
|
||||
await _loadScope();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> _restoreSession() async {
|
||||
session = null;
|
||||
final revision = _preferences!.getString('sessionRevision.$serverUrl');
|
||||
if (_volatileServer == serverUrl &&
|
||||
_volatileRevision == revision &&
|
||||
_volatileSession != null) {
|
||||
session = _volatileSession;
|
||||
return;
|
||||
}
|
||||
_volatileSession = null;
|
||||
_volatileRevision = null;
|
||||
_volatileServer = null;
|
||||
String? saved;
|
||||
try {
|
||||
if (!(_preferences!.getBool('sessionDisabled.$serverUrl') ?? false)) {
|
||||
saved = await _secureStorage.read(key: _credentialKey);
|
||||
}
|
||||
} catch (_) {
|
||||
_sessionStorageUnavailable();
|
||||
}
|
||||
if (saved != null) {
|
||||
try {
|
||||
session = AuthSession(
|
||||
Map<String, dynamic>.from(jsonDecode(saved) as Map),
|
||||
);
|
||||
} catch (_) {
|
||||
await _deleteCredentials();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _sessionStorageUnavailable() {
|
||||
sessionPersistenceWarning =
|
||||
'Защищённое хранилище недоступно. Вход действует до закрытия приложения.';
|
||||
syncMessage = sessionPersistenceWarning;
|
||||
}
|
||||
|
||||
Future<void> _saveCredentials(Map<String, dynamic> value) async {
|
||||
try {
|
||||
await _secureStorage.write(key: _credentialKey, value: jsonEncode(value));
|
||||
await _preferences!.setBool('sessionDisabled.$serverUrl', false);
|
||||
await _preferences!.setString('sessionRevision.$serverUrl', newId());
|
||||
_volatileSession = null;
|
||||
_volatileRevision = null;
|
||||
_volatileServer = null;
|
||||
} catch (_) {
|
||||
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
|
||||
_volatileRevision = newId();
|
||||
await _preferences!.setString(
|
||||
'sessionRevision.$serverUrl',
|
||||
_volatileRevision!,
|
||||
);
|
||||
_volatileSession = AuthSession(value);
|
||||
_volatileServer = serverUrl;
|
||||
_sessionStorageUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteCredentials() async {
|
||||
// A failed secure delete must not restore a signed-out account next launch.
|
||||
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
|
||||
await _preferences!.setString('sessionRevision.$serverUrl', newId());
|
||||
_volatileSession = null;
|
||||
_volatileRevision = null;
|
||||
_volatileServer = null;
|
||||
try {
|
||||
await _secureStorage.delete(key: _credentialKey);
|
||||
} catch (_) {
|
||||
_sessionStorageUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
String get _credentialKey =>
|
||||
'balance.session.${Uri.encodeComponent(serverUrl)}';
|
||||
String _scopeFor(String? userId) => '$serverUrl|${userId ?? 'guest'}';
|
||||
SyncApi? _apiInstance;
|
||||
SyncApi get _api => _apiInstance ??=
|
||||
_apiFactory?.call(serverUrl) ??
|
||||
SyncApi(
|
||||
serverUrl: serverUrl,
|
||||
allowInsecureHttp: [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'10.0.2.2',
|
||||
].contains(Uri.parse(serverUrl).host),
|
||||
);
|
||||
|
||||
List<CategoryRecord> categoriesForKind(Object kind) {
|
||||
final raw = kind is TransactionKind ? kind.name : kind.toString();
|
||||
final combined = <String, CategoryRecord>{
|
||||
for (final category in BuiltInCategories.forKind(raw))
|
||||
category.name: category,
|
||||
for (final category in categories.where((c) => c.kindRawValue == raw))
|
||||
category.name: category,
|
||||
};
|
||||
return combined.values.toList();
|
||||
}
|
||||
|
||||
Future<void> _loadScope() async {
|
||||
_scope = _scopeFor(session?.userId);
|
||||
final raw = await _storage!.read(_scope);
|
||||
_transactions.clear();
|
||||
_categories.clear();
|
||||
_budgets.clear();
|
||||
_dirty.clear();
|
||||
_tombstones.clear();
|
||||
_cursor = 0;
|
||||
lastSyncAt = null;
|
||||
if (raw == null) return;
|
||||
for (final item in (raw['transactions'] as List? ?? [])) {
|
||||
final record = TransactionRecord.fromJson(
|
||||
Map<String, dynamic>.from(item as Map),
|
||||
);
|
||||
_transactions[record.id] = record;
|
||||
}
|
||||
for (final item in (raw['categories'] as List? ?? [])) {
|
||||
final record = CategoryRecord.fromJson(
|
||||
Map<String, dynamic>.from(item as Map),
|
||||
);
|
||||
_categories[record.id] = record;
|
||||
}
|
||||
for (final item in (raw['budgets'] as List? ?? [])) {
|
||||
final record = BudgetRecord.fromJson(
|
||||
Map<String, dynamic>.from(item as Map),
|
||||
);
|
||||
_budgets[record.id] = record;
|
||||
}
|
||||
for (final item in (raw['dirty'] as List? ?? [])) {
|
||||
final change = Map<String, dynamic>.from(item as Map);
|
||||
_dirty[_key(change)] = change;
|
||||
}
|
||||
for (final item in (raw['tombstones'] as List? ?? [])) {
|
||||
final change = Map<String, dynamic>.from(item as Map);
|
||||
_tombstones[_key(change)] = change;
|
||||
}
|
||||
_cursor = (raw['cursor'] as num?)?.toInt() ?? 0;
|
||||
lastSyncAt = DateTime.tryParse(raw['lastSyncAt'] as String? ?? '');
|
||||
}
|
||||
|
||||
Map<String, dynamic> _snapshot() => {
|
||||
'transactions': _transactions.values.map((e) => e.toJson()).toList(),
|
||||
'categories': _categories.values.map((e) => e.toJson()).toList(),
|
||||
'budgets': _budgets.values.map((e) => e.toJson()).toList(),
|
||||
'dirty': _dirty.values.toList(),
|
||||
'tombstones': _tombstones.values.toList(),
|
||||
'cursor': _cursor,
|
||||
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||
};
|
||||
Future<void> _persist() => _storage!.write(_scope, _snapshot());
|
||||
String _key(Map<String, dynamic> change) =>
|
||||
'${change['entity']}:${change['id']}';
|
||||
Map<String, dynamic> _change(
|
||||
String entity,
|
||||
String id,
|
||||
DateTime time,
|
||||
Map<String, dynamic>? payload,
|
||||
) => {
|
||||
'entity': entity,
|
||||
'id': id,
|
||||
'updatedAt': time.toUtc().toIso8601String(),
|
||||
'deleted': payload == null,
|
||||
'payload': ?payload,
|
||||
};
|
||||
DateTime _now() {
|
||||
// Strictly advance local timestamps even for successive edits in one tick.
|
||||
var now = DateTime.now().toUtc();
|
||||
for (final change in _dirty.values) {
|
||||
final previous = DateTime.parse(change['updatedAt'] as String);
|
||||
if (!now.isAfter(previous)) {
|
||||
now = previous.add(const Duration(microseconds: 1));
|
||||
}
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
void _queueChange(Map<String, dynamic> change) {
|
||||
final key = _key(change);
|
||||
_dirty[key] = change;
|
||||
if (change['deleted'] == true) {
|
||||
_tombstones[key] = change;
|
||||
} else {
|
||||
_tombstones.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _edited() async {
|
||||
try {
|
||||
await _persist();
|
||||
} catch (_) {
|
||||
await _loadScope();
|
||||
rethrow;
|
||||
}
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> saveTransaction(TransactionRecord record) =>
|
||||
_exclusive(() async {
|
||||
if (record.amountMinor <= 0) {
|
||||
throw ArgumentError('Сумма должна быть больше нуля');
|
||||
}
|
||||
final saved = record.copyWith(updatedAt: _now());
|
||||
_transactions[saved.id] = saved;
|
||||
_queueChange(
|
||||
_change('transaction', saved.id, saved.updatedAt, saved.payload()),
|
||||
);
|
||||
await _edited();
|
||||
});
|
||||
Future<void> deleteTransaction(String id) => _delete('transaction', id);
|
||||
Future<void> deleteBudget(String id) => _delete('budget', id);
|
||||
Future<void> deleteCategory(String id) => _delete('category', id);
|
||||
Future<void> _delete(String entity, String id) => _exclusive(() async {
|
||||
if (entity == 'transaction') _transactions.remove(id);
|
||||
if (entity == 'category') {
|
||||
final category = _categories.remove(id);
|
||||
if (category != null && category.kindRawValue == 'expense') {
|
||||
for (final budget
|
||||
in _budgets.values
|
||||
.where((b) => b.categoryName == category.name)
|
||||
.toList()) {
|
||||
_budgets.remove(budget.id);
|
||||
_queueChange(_change('budget', budget.id, _now(), null));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entity == 'budget') _budgets.remove(id);
|
||||
_queueChange(_change(entity, id, _now(), null));
|
||||
await _edited();
|
||||
});
|
||||
|
||||
Future<void> saveCategory(CategoryRecord record) => _exclusive(() async {
|
||||
if (record.name.trim().isEmpty) {
|
||||
throw ArgumentError('Введите название категории');
|
||||
}
|
||||
final saved = record.copyWith(name: record.name.trim(), updatedAt: _now());
|
||||
if (_categories.values.any(
|
||||
(c) =>
|
||||
c.id != saved.id &&
|
||||
c.name == saved.name &&
|
||||
c.kindRawValue == saved.kindRawValue,
|
||||
)) {
|
||||
throw ArgumentError('Такая категория уже существует');
|
||||
}
|
||||
final previous = _categories[saved.id];
|
||||
_categories[saved.id] = saved;
|
||||
_queueChange(
|
||||
_change('category', saved.id, saved.updatedAt, saved.payload()),
|
||||
);
|
||||
if (previous != null) {
|
||||
for (final tx in _transactions.values.toList()) {
|
||||
if (tx.categoryName != previous.name ||
|
||||
tx.kindRawValue != previous.kindRawValue) {
|
||||
continue;
|
||||
}
|
||||
final changed = tx.copyWith(
|
||||
categoryName: saved.name,
|
||||
categoryIcon: saved.icon,
|
||||
categoryEmoji: saved.emoji,
|
||||
categoryColorName: saved.colorName,
|
||||
updatedAt: saved.updatedAt,
|
||||
);
|
||||
_transactions[tx.id] = changed;
|
||||
_queueChange(
|
||||
_change('transaction', tx.id, changed.updatedAt, changed.payload()),
|
||||
);
|
||||
}
|
||||
for (final budget in _budgets.values.toList()) {
|
||||
if (budget.categoryName != previous.name) continue;
|
||||
final changed = budget.copyWith(
|
||||
categoryName: saved.name,
|
||||
categoryIcon: saved.icon,
|
||||
categoryEmoji: saved.emoji,
|
||||
updatedAt: saved.updatedAt,
|
||||
);
|
||||
_budgets[budget.id] = changed;
|
||||
_queueChange(
|
||||
_change('budget', budget.id, changed.updatedAt, changed.payload()),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _edited();
|
||||
});
|
||||
Future<void> saveBudget(BudgetRecord record) => _exclusive(() async {
|
||||
if (record.limitMinor <= 0) {
|
||||
throw ArgumentError('Лимит должен быть больше нуля');
|
||||
}
|
||||
final localMonth = record.monthStart.toLocal();
|
||||
final month = DateTime(localMonth.year, localMonth.month);
|
||||
if (_budgets.values.any(
|
||||
(b) =>
|
||||
b.id != record.id &&
|
||||
b.categoryName == record.categoryName &&
|
||||
b.monthStart.toLocal().year == month.year &&
|
||||
b.monthStart.toLocal().month == month.month,
|
||||
)) {
|
||||
throw ArgumentError('Бюджет для этой категории уже существует');
|
||||
}
|
||||
final saved = record.copyWith(monthStart: month, updatedAt: _now());
|
||||
_budgets[saved.id] = saved;
|
||||
_queueChange(_change('budget', saved.id, saved.updatedAt, saved.payload()));
|
||||
await _edited();
|
||||
});
|
||||
Future<void> adjustBalance(int targetMinor, {String note = ''}) =>
|
||||
_exclusive(() async {
|
||||
final delta = targetMinor - balanceMinor;
|
||||
if (delta == 0) return;
|
||||
final now = _now();
|
||||
final record = TransactionRecord(
|
||||
id: newId(),
|
||||
amountMinor: delta.abs(),
|
||||
date: now,
|
||||
categoryName: 'Корректировка',
|
||||
updatedAt: now,
|
||||
note: note.isEmpty ? 'Ручная корректировка баланса' : note,
|
||||
isBalanceAdjustment: true,
|
||||
kindRawValue: delta > 0 ? 'income' : 'expense',
|
||||
);
|
||||
_transactions[record.id] = record;
|
||||
_queueChange(_change('transaction', record.id, now, record.payload()));
|
||||
await _edited();
|
||||
});
|
||||
|
||||
Future<void> setCurrency(String value) => _exclusive(() async {
|
||||
currency = value;
|
||||
await _preferences!.setString('currency', value);
|
||||
_notify();
|
||||
});
|
||||
Future<void> setTheme(String value) => _exclusive(() async {
|
||||
theme = value;
|
||||
await _preferences!.setString('theme', value);
|
||||
_notify();
|
||||
});
|
||||
Future<void> setServerUrl(String value) => _exclusive(() async {
|
||||
final normalized = value.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
final uri = Uri.tryParse(normalized);
|
||||
if (uri == null ||
|
||||
!uri.hasAuthority ||
|
||||
!['http', 'https'].contains(uri.scheme)) {
|
||||
throw ArgumentError('Укажите полный адрес сервера');
|
||||
}
|
||||
// Validate the transport before clearing an existing account or changing its scope.
|
||||
final validation = SyncApi(
|
||||
serverUrl: normalized,
|
||||
allowInsecureHttp: const [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
'10.0.2.2',
|
||||
].contains(uri.host),
|
||||
);
|
||||
validation.close();
|
||||
if (normalized == serverUrl) return;
|
||||
final unconfiguredGuest = serverUrl.isEmpty && session == null
|
||||
? _snapshot()
|
||||
: null;
|
||||
await _deleteCredentials();
|
||||
session = null;
|
||||
_apiInstance?.close();
|
||||
_apiInstance = null;
|
||||
serverUrl = normalized;
|
||||
await _preferences!.setString('serverUrl', normalized);
|
||||
// Credentials are deliberately not restored when switching servers.
|
||||
await _deleteCredentials();
|
||||
if (unconfiguredGuest != null &&
|
||||
await _storage!.read(_scopeFor(null)) == null) {
|
||||
await _storage!.write(_scopeFor(null), unconfiguredGuest);
|
||||
}
|
||||
await _loadScope();
|
||||
error = null;
|
||||
syncMessage = null;
|
||||
_notify();
|
||||
});
|
||||
|
||||
Future<void> signIn(String email, String password) =>
|
||||
_authenticate(email, password, false);
|
||||
Future<void> register(String email, String password) =>
|
||||
_authenticate(email, password, true);
|
||||
Future<void> _authenticate(
|
||||
String email,
|
||||
String password,
|
||||
bool register,
|
||||
) => _exclusive(() async {
|
||||
final response = register
|
||||
? await _api.register(email, password)
|
||||
: await _api.login(email, password);
|
||||
final nextSession = AuthSession(response);
|
||||
final nextScope = _scopeFor(nextSession.userId);
|
||||
final exists = await _storage!.read(nextScope);
|
||||
final marker = 'importedGuest.$serverUrl';
|
||||
final guest =
|
||||
session == null &&
|
||||
exists == null &&
|
||||
!(_preferences!.getBool(marker) ?? false)
|
||||
? _snapshot()
|
||||
: null;
|
||||
session = nextSession;
|
||||
await _saveCredentials(response);
|
||||
await _loadScope();
|
||||
if (guest != null) {
|
||||
await _storage!.write(_scope, guest);
|
||||
await _loadScope();
|
||||
_cursor = 0;
|
||||
// Imported rows may have been synced under another prior guest snapshot.
|
||||
for (final t in _transactions.values) {
|
||||
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
|
||||
}
|
||||
for (final c in _categories.values) {
|
||||
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
|
||||
}
|
||||
for (final b in _budgets.values) {
|
||||
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
|
||||
}
|
||||
await _persist();
|
||||
await _preferences!.setBool(marker, true);
|
||||
}
|
||||
error = null;
|
||||
_notify();
|
||||
await _syncLocked();
|
||||
});
|
||||
Future<void> signOut() => _exclusive(() async {
|
||||
final old = session;
|
||||
// Local logout succeeds even if the network is unavailable.
|
||||
session = null;
|
||||
await _deleteCredentials();
|
||||
await _loadScope();
|
||||
error = null;
|
||||
syncMessage = null;
|
||||
_notify();
|
||||
if (old != null) {
|
||||
try {
|
||||
await _api.logout(old.refreshToken);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
Future<String> _accessToken({bool force = false}) async {
|
||||
final current = session;
|
||||
if (current == null) throw StateError('Войдите в аккаунт');
|
||||
if (!force &&
|
||||
current.expiresAt.isAfter(
|
||||
DateTime.now().add(const Duration(minutes: 1)),
|
||||
)) {
|
||||
return current.accessToken;
|
||||
}
|
||||
final refreshed = await _api.refresh(current.refreshToken);
|
||||
session = AuthSession(refreshed);
|
||||
await _saveCredentials(refreshed);
|
||||
return session!.accessToken;
|
||||
}
|
||||
|
||||
Future<void> sync() => _exclusive(_syncLocked);
|
||||
Future<void> resetAndSync() => _exclusive(() async {
|
||||
_cursor = 0;
|
||||
for (final t in _transactions.values) {
|
||||
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
|
||||
}
|
||||
for (final c in _categories.values) {
|
||||
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
|
||||
}
|
||||
for (final b in _budgets.values) {
|
||||
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
|
||||
}
|
||||
for (final tombstone in _tombstones.values.toList()) {
|
||||
_queueChange(tombstone);
|
||||
}
|
||||
await _persist();
|
||||
await _syncLocked();
|
||||
});
|
||||
Future<void> _syncLocked() async {
|
||||
if (session == null || syncing) return;
|
||||
syncing = true;
|
||||
error = null;
|
||||
syncMessage = 'Синхронизация…';
|
||||
_notify();
|
||||
final sent = Map<String, Map<String, dynamic>>.from(_dirty);
|
||||
try {
|
||||
Future<int> run() => _api.synchronize(
|
||||
accessToken: _accessToken,
|
||||
cursor: _cursor,
|
||||
deviceId: _deviceId,
|
||||
changes: sent.values.toList(),
|
||||
applyPage: _applyPage,
|
||||
);
|
||||
try {
|
||||
await run();
|
||||
} on SyncApiException catch (e) {
|
||||
if (e.statusCode != 401) rethrow;
|
||||
await _accessToken(force: true);
|
||||
await run();
|
||||
}
|
||||
// Only acknowledge precisely the pushed revision, never a newer local edit.
|
||||
for (final entry in sent.entries) {
|
||||
if (_dirty[entry.key]?['updatedAt'] == entry.value['updatedAt']) {
|
||||
_dirty.remove(entry.key);
|
||||
}
|
||||
}
|
||||
lastSyncAt = DateTime.now();
|
||||
await _persist();
|
||||
syncMessage =
|
||||
sessionPersistenceWarning ??
|
||||
(_dirty.isEmpty
|
||||
? 'Синхронизировано'
|
||||
: 'Есть изменения для следующей синхронизации');
|
||||
} catch (e) {
|
||||
error = e is SyncApiException
|
||||
? e.message
|
||||
: 'Не удалось синхронизировать: $e';
|
||||
syncMessage = 'Изменения сохранены на устройстве';
|
||||
rethrow;
|
||||
} finally {
|
||||
syncing = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyPage(
|
||||
List<Map<String, dynamic>> changes,
|
||||
int cursor,
|
||||
) async {
|
||||
try {
|
||||
for (final change in changes) {
|
||||
_merge(change);
|
||||
}
|
||||
_cursor = cursor;
|
||||
await _persist();
|
||||
} catch (_) {
|
||||
// Disk failure must not advance the in-memory cursor past an uncommitted page.
|
||||
await _loadScope();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _merge(Map<String, dynamic> change) {
|
||||
final key = _key(change);
|
||||
final entity = change['entity'] as String;
|
||||
final id = change['id'] as String;
|
||||
final incoming = DateTime.parse(change['updatedAt'] as String);
|
||||
final pending = _dirty[key];
|
||||
// Preserve the user's pending deletion. A newer server edit means its LWW
|
||||
// delete needs a newer revision, which remains dirty for the next push.
|
||||
if (pending?['deleted'] == true && change['deleted'] != true) {
|
||||
if (!DateTime.parse(pending!['updatedAt'] as String).isAfter(incoming)) {
|
||||
_queueChange(
|
||||
_change(
|
||||
entity,
|
||||
id,
|
||||
incoming.add(const Duration(microseconds: 1)),
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pending != null &&
|
||||
!incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
|
||||
return;
|
||||
}
|
||||
final tombstone = _tombstones[key];
|
||||
if (tombstone != null &&
|
||||
!incoming.isAfter(DateTime.parse(tombstone['updatedAt'] as String))) {
|
||||
return;
|
||||
}
|
||||
DateTime? local;
|
||||
if (entity == 'transaction') local = _transactions[id]?.updatedAt;
|
||||
if (entity == 'category') local = _categories[id]?.updatedAt;
|
||||
if (entity == 'budget') local = _budgets[id]?.updatedAt;
|
||||
if (local != null && incoming.isBefore(local)) return;
|
||||
if (change['deleted'] == true) {
|
||||
if (entity == 'transaction') _transactions.remove(id);
|
||||
if (entity == 'category') _categories.remove(id);
|
||||
if (entity == 'budget') _budgets.remove(id);
|
||||
_tombstones[key] = Map<String, dynamic>.from(change);
|
||||
} else {
|
||||
final payload = Map<String, dynamic>.from(change['payload'] as Map);
|
||||
if (entity == 'transaction') {
|
||||
_transactions[id] = TransactionRecord.fromPayload(
|
||||
id: id,
|
||||
updatedAt: incoming,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
if (entity == 'category') {
|
||||
_categories[id] = CategoryRecord.fromPayload(
|
||||
id: id,
|
||||
updatedAt: incoming,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
if (entity == 'budget') {
|
||||
_budgets[id] = BudgetRecord.fromPayload(
|
||||
id: id,
|
||||
updatedAt: incoming,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
_tombstones.remove(key);
|
||||
}
|
||||
if (pending != null &&
|
||||
incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
|
||||
_dirty.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
void _backgroundSync() {
|
||||
if (ready && session != null) unawaited(sync().catchError((Object _) {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_backgroundSync();
|
||||
}
|
||||
if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.hidden) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_timer ??= Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
_backgroundSync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for all serialized work before a headless engine exits.
|
||||
Future<void> close() async {
|
||||
await _tail;
|
||||
_apiInstance?.close();
|
||||
_apiInstance = null;
|
||||
await _storage?.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_timer?.cancel();
|
||||
if (!background) WidgetsBinding.instance.removeObserver(this);
|
||||
unawaited(close());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
|
||||
import 'app_store.dart';
|
||||
|
||||
const _periodicSyncName = 'balance-periodic-sync';
|
||||
const _syncTask = 'balance.finance.sync';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void balanceBackgroundDispatcher() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
DartPluginRegistrant.ensureInitialized();
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
if (task != _syncTask) return true;
|
||||
final store = AppStore(background: true);
|
||||
var success = false;
|
||||
try {
|
||||
await store.initialize();
|
||||
await store.sync();
|
||||
success = true;
|
||||
} catch (_) {
|
||||
// WorkManager retries with backoff; details/tokens are never logged.
|
||||
success = false;
|
||||
} finally {
|
||||
try {
|
||||
await store.close();
|
||||
} catch (_) {
|
||||
success = false;
|
||||
}
|
||||
store.dispose();
|
||||
}
|
||||
return success;
|
||||
});
|
||||
}
|
||||
|
||||
/// Android OS scheduling survives UI engine shutdown. Other platforms retain
|
||||
/// their existing foreground startup/resume/two-minute synchronization.
|
||||
Future<void> configureBackgroundSync() async {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
|
||||
await Workmanager().initialize(balanceBackgroundDispatcher);
|
||||
await Workmanager().registerPeriodicTask(
|
||||
_periodicSyncName,
|
||||
_syncTask,
|
||||
frequency: const Duration(minutes: 15),
|
||||
constraints: Constraints(networkType: NetworkType.connected),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'runtime_lock_stub.dart'
|
||||
if (dart.library.js_interop) 'runtime_lock_web.dart'
|
||||
as browser;
|
||||
|
||||
/// Native Android lock shared by the UI engine and WorkManager headless engines.
|
||||
/// Callers must hold it over reloading preferences/session, ledger IO and sync.
|
||||
class RuntimeLock {
|
||||
static const _channel = MethodChannel('balance/runtime');
|
||||
final _browserLock = browser.BrowserRuntimeLock();
|
||||
String? _token;
|
||||
bool get _android =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (kIsWeb) return _browserLock.acquire();
|
||||
if (!_android) return;
|
||||
if (_token != null) throw StateError('Ledger lock is already held');
|
||||
final token = await _channel.invokeMethod<String>('acquireLock');
|
||||
if (token == null || token.isEmpty) {
|
||||
throw StateError('Native ledger lock returned no ownership token');
|
||||
}
|
||||
_token = token;
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
if (kIsWeb) return _browserLock.release();
|
||||
if (!_android) return;
|
||||
final token = _token;
|
||||
if (token == null) throw StateError('Ledger lock is not held');
|
||||
await _channel.invokeMethod<void>('releaseLock', {'token': token});
|
||||
_token = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// Non-web storage does not use browser-origin coordination.
|
||||
class BrowserRuntimeLock {
|
||||
Future<void> acquire() async {}
|
||||
Future<void> release() async {}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
|
||||
@JS('isSecureContext')
|
||||
external bool get _secureContext;
|
||||
|
||||
@JS('navigator.locks')
|
||||
external JSObject? get _navigatorLocks;
|
||||
|
||||
extension type _LockManager(JSObject _) implements JSObject {
|
||||
external JSPromise<JSAny?> request(JSString name, JSFunction callback);
|
||||
}
|
||||
|
||||
/// Holds the origin-wide Web Lock until release, including IndexedDB commits
|
||||
/// and rotated refresh-token persistence. Browser teardown releases the lock.
|
||||
class BrowserRuntimeLock {
|
||||
bool _waiting = false;
|
||||
Completer<JSAny?>? _release;
|
||||
Future<JSAny?>? _request;
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_waiting || _release != null) {
|
||||
throw StateError('Browser ledger lock is already held or pending');
|
||||
}
|
||||
if (!_secureContext) {
|
||||
throw StateError(
|
||||
'Для безопасного хранения данных в нескольких вкладках требуется HTTPS '
|
||||
'(или localhost). Откройте приложение по защищённому адресу.',
|
||||
);
|
||||
}
|
||||
final manager = _navigatorLocks;
|
||||
if (manager == null) {
|
||||
throw StateError(
|
||||
'Браузер не поддерживает Web Locks API. Обновите браузер: '
|
||||
'без блокировки вкладок безопасная синхронизация недоступна.',
|
||||
);
|
||||
}
|
||||
final ready = Completer<void>();
|
||||
final release = Completer<JSAny?>();
|
||||
_waiting = true;
|
||||
try {
|
||||
final request = _LockManager(manager)
|
||||
.request(
|
||||
'balance-ledger-v1'.toJS,
|
||||
((JSAny? lock) {
|
||||
_waiting = false;
|
||||
_release = release;
|
||||
ready.complete();
|
||||
return release.future.toJS;
|
||||
}).toJS,
|
||||
)
|
||||
.toDart;
|
||||
_request = request;
|
||||
// A rejected request must wake an acquiring caller, not leave it waiting
|
||||
// forever. Keep a rejection handler attached even before release awaits it.
|
||||
unawaited(
|
||||
request.then<void>(
|
||||
(_) {
|
||||
if (!ready.isCompleted) {
|
||||
ready.completeError(
|
||||
StateError('Browser ledger lock was not granted'),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stack) {
|
||||
if (!ready.isCompleted) {
|
||||
ready.completeError(
|
||||
StateError('Не удалось получить блокировку данных браузера'),
|
||||
stack,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
await ready.future;
|
||||
} catch (_) {
|
||||
_waiting = false;
|
||||
_release = null;
|
||||
_request = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
final release = _release;
|
||||
if (release == null) throw StateError('Browser ledger lock is not held');
|
||||
release.complete(null);
|
||||
try {
|
||||
// Resolves only after navigator.locks has actually released ownership.
|
||||
await _request;
|
||||
} finally {
|
||||
_release = null;
|
||||
_request = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:sembast/sembast.dart';
|
||||
|
||||
import 'storage_native.dart'
|
||||
if (dart.library.js_interop) 'storage_web.dart'
|
||||
as platform;
|
||||
|
||||
/// A single transaction replaces the complete ledger, pending changes and cursor.
|
||||
abstract class SnapshotStorage {
|
||||
Future<Map<String, dynamic>?> read(String key);
|
||||
Future<void> write(String key, Map<String, dynamic> value);
|
||||
Future<void> close();
|
||||
}
|
||||
|
||||
/// Connections are opened lazily and closed after every operation, so another
|
||||
/// Flutter engine never reads through an old Sembast in-memory cache. Android
|
||||
/// callers serialize complete operations with the shared native store lock.
|
||||
class LedgerStorage implements SnapshotStorage {
|
||||
LedgerStorage._();
|
||||
final _store = stringMapStoreFactory.store('snapshots');
|
||||
static Future<LedgerStorage> open() async => LedgerStorage._();
|
||||
@override
|
||||
Future<Map<String, dynamic>?> read(String key) async {
|
||||
final db = await platform.openLedgerDatabase();
|
||||
try {
|
||||
return await _store.record(key).get(db);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String key, Map<String, dynamic> value) async {
|
||||
final db = await platform.openLedgerDatabase();
|
||||
try {
|
||||
await db.transaction((txn) async {
|
||||
await _store.record(key).put(txn, value);
|
||||
});
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
class MemorySnapshotStorage implements SnapshotStorage {
|
||||
final Map<String, Map<String, dynamic>> values = {};
|
||||
@override
|
||||
Future<Map<String, dynamic>?> read(String key) async => values[key];
|
||||
@override
|
||||
Future<void> write(String key, Map<String, dynamic> value) async {
|
||||
values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sembast/sembast_io.dart';
|
||||
|
||||
Future<Database> openLedgerDatabase() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
await directory.create(recursive: true);
|
||||
return databaseFactoryIo.openDatabase('${directory.path}/balance.sembast');
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:sembast_web/sembast_web.dart';
|
||||
|
||||
Future<Database> openLedgerDatabase() =>
|
||||
databaseFactoryWeb.openDatabase('balance');
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// JSON wire objects, deliberately independent of local database models.
|
||||
typedef WireMap = Map<String, dynamic>;
|
||||
typedef AccessTokenProvider = Future<String> Function();
|
||||
|
||||
class SyncApiException implements Exception {
|
||||
const SyncApiException(this.code, this.message, {this.statusCode});
|
||||
|
||||
final String code;
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
// Do not include response bodies, request headers or tokens in diagnostics.
|
||||
@override
|
||||
String toString() => 'SyncApiException($code, HTTP $statusCode): $message';
|
||||
}
|
||||
|
||||
/// Stateless HTTP transport. Session persistence belongs to the caller's secure
|
||||
/// store. Never persist returned session maps in preferences or browser storage.
|
||||
class SyncApi {
|
||||
SyncApi({
|
||||
required String serverUrl,
|
||||
http.Client? client,
|
||||
this.timeout = const Duration(seconds: 30),
|
||||
bool allowInsecureHttp = false,
|
||||
}) : _client = client ?? http.Client(),
|
||||
_ownsClient = client == null,
|
||||
baseUri = _normalizeServer(serverUrl, allowInsecureHttp);
|
||||
|
||||
final Uri baseUri;
|
||||
final Duration timeout;
|
||||
final http.Client _client;
|
||||
final bool _ownsClient;
|
||||
|
||||
/// Register/login/refresh return {accessToken, refreshToken, expiresAt,
|
||||
/// user: {id, email}}. Refresh rotates both tokens; save the result atomically.
|
||||
Future<WireMap> register(String email, String password) =>
|
||||
_authenticate('register', email, password);
|
||||
|
||||
Future<WireMap> login(String email, String password) =>
|
||||
_authenticate('login', email, password);
|
||||
|
||||
Future<WireMap> _authenticate(
|
||||
String action,
|
||||
String email,
|
||||
String password,
|
||||
) async => _session(await _request('POST', 'v1/auth/$action', body: {
|
||||
'email': email.trim(),
|
||||
'password': password,
|
||||
}));
|
||||
|
||||
Future<WireMap> refresh(String refreshToken) async =>
|
||||
_session(await _request('POST', 'v1/auth/refresh', body: {
|
||||
'refreshToken': refreshToken,
|
||||
}));
|
||||
|
||||
/// Clear the external local session in a finally block, even when offline.
|
||||
/// An offline logout cannot revoke the server refresh token.
|
||||
Future<void> logout(String refreshToken) async {
|
||||
await _request('POST', 'v1/auth/logout', body: {
|
||||
'refreshToken': refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
Future<WireMap> me(String accessToken) async {
|
||||
final result = await _request('GET', 'v1/me', accessToken: accessToken);
|
||||
_string(result, 'id');
|
||||
_string(result, 'email');
|
||||
return result;
|
||||
}
|
||||
|
||||
/// One wire request. Push-only calls leave cursor unchanged. The caller must
|
||||
/// merge a pulled page durably before committing its returned cursor.
|
||||
Future<WireMap> sync({
|
||||
required String accessToken,
|
||||
required int cursor,
|
||||
required String deviceId,
|
||||
required List<WireMap> changes,
|
||||
bool pull = true,
|
||||
}) async {
|
||||
_cursor(cursor);
|
||||
if (changes.length > 5000) {
|
||||
throw ArgumentError('A sync request supports at most 5000 changes');
|
||||
}
|
||||
for (final change in changes) {
|
||||
_validateChange(change, outgoing: true);
|
||||
}
|
||||
final page = await _request('POST', 'v1/sync', accessToken: accessToken,
|
||||
body: _syncBody(cursor, deviceId, changes, pull));
|
||||
final next = _integer(page, 'cursor');
|
||||
_cursor(next);
|
||||
if (next < cursor || page['hasMore'] is! bool || page['changes'] is! List) {
|
||||
throw const FormatException('Invalid sync page');
|
||||
}
|
||||
if (page['hasMore'] == true && next <= cursor) {
|
||||
throw const FormatException('Sync cursor did not advance');
|
||||
}
|
||||
DateTime.parse(_string(page, 'serverTime'));
|
||||
final records = <WireMap>[];
|
||||
var previousSequence = cursor;
|
||||
for (final raw in page['changes'] as List) {
|
||||
final change = _object(raw);
|
||||
_validateChange(change);
|
||||
final sequence = _integer(change, 'sequence');
|
||||
if (sequence <= previousSequence || sequence > next) {
|
||||
throw const FormatException('Invalid change sequence');
|
||||
}
|
||||
previousSequence = sequence;
|
||||
records.add(change);
|
||||
}
|
||||
if (records.isNotEmpty && previousSequence != next) {
|
||||
throw const FormatException('Cursor does not match the last change');
|
||||
}
|
||||
if (records.isEmpty && next != cursor) {
|
||||
throw const FormatException('Empty page advanced the cursor');
|
||||
}
|
||||
return {...page, 'changes': records};
|
||||
}
|
||||
|
||||
/// Pushes a stable snapshot in bounded batches, then pulls all pages. The
|
||||
/// provider may refresh expired tokens through an external session manager.
|
||||
/// applyPage must atomically merge changes and persist cursor, and must throw
|
||||
/// on failure. Keep the push watermark unchanged until this method succeeds.
|
||||
Future<int> synchronize({
|
||||
required AccessTokenProvider accessToken,
|
||||
required int cursor,
|
||||
required String deviceId,
|
||||
required List<WireMap> changes,
|
||||
required Future<void> Function(List<WireMap> changes, int cursor) applyPage,
|
||||
}) async {
|
||||
// Copy the complete JSON snapshot before the first await so concurrent edits
|
||||
// cannot mutate a batch while it is being uploaded.
|
||||
final snapshot = (jsonDecode(jsonEncode(changes)) as List)
|
||||
.map(_object).toList(growable: false);
|
||||
_cursor(cursor);
|
||||
for (final change in snapshot) {
|
||||
_validateChange(change, outgoing: true);
|
||||
}
|
||||
for (var start = 0; start < snapshot.length; start += 100) {
|
||||
final end = start + 100 < snapshot.length ? start + 100 : snapshot.length;
|
||||
await _pushBatch(accessToken, cursor, deviceId, snapshot.sublist(start, end));
|
||||
}
|
||||
var next = cursor;
|
||||
while (true) {
|
||||
final page = await sync(accessToken: await accessToken(), cursor: next,
|
||||
deviceId: deviceId, changes: const []);
|
||||
next = page['cursor'] as int;
|
||||
await applyPage((page['changes'] as List).cast<WireMap>(), next);
|
||||
if (page['hasMore'] == false) return next;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pushBatch(AccessTokenProvider token, int cursor, String deviceId,
|
||||
List<WireMap> changes) async {
|
||||
final size = utf8.encode(jsonEncode(_syncBody(cursor, deviceId, changes, false))).length;
|
||||
if (size > 1500000 && changes.length > 1) {
|
||||
final middle = changes.length ~/ 2;
|
||||
await _pushBatch(token, cursor, deviceId, changes.sublist(0, middle));
|
||||
await _pushBatch(token, cursor, deviceId, changes.sublist(middle));
|
||||
return;
|
||||
}
|
||||
await sync(accessToken: await token(), cursor: cursor, deviceId: deviceId,
|
||||
changes: changes, pull: false);
|
||||
}
|
||||
|
||||
Future<WireMap> _request(String method, String path, {
|
||||
WireMap? body,
|
||||
String? accessToken,
|
||||
}) async {
|
||||
final request = http.Request(method, baseUri.resolve(path));
|
||||
request.followRedirects = false;
|
||||
request.headers['Accept'] = 'application/json';
|
||||
if (accessToken != null) {
|
||||
request.headers['Authorization'] = 'Bearer $accessToken';
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers['Content-Type'] = 'application/json; charset=utf-8';
|
||||
request.bodyBytes = utf8.encode(jsonEncode(body));
|
||||
if (request.bodyBytes.length > 2 * 1024 * 1024) {
|
||||
throw ArgumentError('Request exceeds the server 2 MiB limit');
|
||||
}
|
||||
}
|
||||
final response = await (() async {
|
||||
final stream = await _client.send(request);
|
||||
return http.Response.fromStream(stream);
|
||||
})().timeout(timeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
var code = 'http_error';
|
||||
var message = 'Server request failed';
|
||||
try {
|
||||
final detail = _object(_object(jsonDecode(response.body))['error']);
|
||||
code = _string(detail, 'code');
|
||||
message = _string(detail, 'message');
|
||||
} on FormatException {
|
||||
// Proxies may return HTML. Never surface raw bodies containing secrets.
|
||||
}
|
||||
throw SyncApiException(code, message, statusCode: response.statusCode);
|
||||
}
|
||||
if (response.statusCode == 204) return <String, dynamic>{};
|
||||
return _object(jsonDecode(utf8.decode(response.bodyBytes)));
|
||||
}
|
||||
|
||||
void close() {
|
||||
if (_ownsClient) _client.close();
|
||||
}
|
||||
|
||||
static WireMap _syncBody(int cursor, String deviceId,
|
||||
List<WireMap> changes, bool pull) => {
|
||||
'cursor': cursor, 'deviceId': deviceId, 'changes': changes, 'pull': pull,
|
||||
};
|
||||
|
||||
static WireMap _session(WireMap value) {
|
||||
_string(value, 'accessToken');
|
||||
_string(value, 'refreshToken');
|
||||
DateTime.parse(_string(value, 'expiresAt'));
|
||||
final user = _object(value['user']);
|
||||
_string(user, 'id');
|
||||
_string(user, 'email');
|
||||
return value;
|
||||
}
|
||||
|
||||
static void _validateChange(WireMap change, {bool outgoing = false}) {
|
||||
if (!const {'transaction', 'category', 'budget'}.contains(change['entity'])) {
|
||||
throw const FormatException('Unknown sync entity');
|
||||
}
|
||||
final id = _string(change, 'id');
|
||||
if (utf8.encode(id).length > 128 || change['deleted'] is! bool) {
|
||||
throw const FormatException('Invalid record ID or deletion flag');
|
||||
}
|
||||
DateTime.parse(_string(change, 'updatedAt'));
|
||||
if (change['deleted'] == false) {
|
||||
_object(change['payload']);
|
||||
}
|
||||
if (outgoing) {
|
||||
const keys = {'entity', 'id', 'deleted', 'updatedAt', 'payload', 'version', 'sequence'};
|
||||
if (change.keys.any((key) => !keys.contains(key))) {
|
||||
throw const FormatException('Unknown change property');
|
||||
}
|
||||
if (change.containsKey('payload') &&
|
||||
utf8.encode(jsonEncode(change['payload'])).length > 256 * 1024) {
|
||||
throw const FormatException('Payload exceeds 256 KiB');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static WireMap _object(dynamic value) {
|
||||
if (value is! Map<String, dynamic>) {
|
||||
throw const FormatException('Expected a JSON object');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String _string(WireMap value, String key) {
|
||||
final result = value[key];
|
||||
if (result is! String || result.isEmpty) {
|
||||
throw FormatException('Missing or invalid $key');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static int _integer(WireMap value, String key) {
|
||||
final result = value[key];
|
||||
if (result is! int) throw FormatException('Missing or invalid $key');
|
||||
return result;
|
||||
}
|
||||
|
||||
static void _cursor(int value) {
|
||||
// JS numbers cannot faithfully round-trip the full server int64 range.
|
||||
if (value < 0 || value > 9007199254740991) {
|
||||
throw ArgumentError('Cursor is outside the portable integer range');
|
||||
}
|
||||
}
|
||||
|
||||
static Uri _normalizeServer(String value, bool allowInsecureHttp) {
|
||||
final uri = Uri.parse(value.trim());
|
||||
if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty ||
|
||||
uri.hasQuery || uri.hasFragment ||
|
||||
!(uri.scheme == 'https' || (allowInsecureHttp && uri.scheme == 'http'))) {
|
||||
throw ArgumentError('Use an HTTPS server URL without credentials, query or fragment');
|
||||
}
|
||||
return uri.replace(path: '${uri.path.replaceAll(RegExp(r'/+$'), '')}/');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,314 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
String money(int minor, String currency) => NumberFormat.currency(
|
||||
locale: 'ru_RU',
|
||||
name: currency,
|
||||
symbol:
|
||||
{'RUB': '₽', 'USD': r'$', 'EUR': '€', 'SEK': 'kr'}[currency] ?? currency,
|
||||
).format(minor / 100);
|
||||
int? parseAmount(String value, {bool signed = false}) {
|
||||
final clean = value
|
||||
.replaceAll(RegExp(r'[\s\u00a0\u202f]'), '')
|
||||
.replaceAll(',', '.');
|
||||
if (!RegExp(
|
||||
signed ? r'^-?\d{1,12}(\.\d{1,2})?$' : r'^\d{1,12}(\.\d{1,2})?$',
|
||||
).hasMatch(clean)) {
|
||||
return null;
|
||||
}
|
||||
final parts = clean.replaceFirst('-', '').split('.');
|
||||
final result =
|
||||
int.parse(parts[0]) * 100 +
|
||||
(parts.length > 1 ? int.parse(parts[1].padRight(2, '0')) : 0);
|
||||
return clean.startsWith('-') ? -result : result;
|
||||
}
|
||||
|
||||
String amountInput(int minor) => (minor / 100).toStringAsFixed(2);
|
||||
|
||||
const categoryColors = <String, Color>{
|
||||
'red': Colors.red,
|
||||
'orange': Colors.orange,
|
||||
'yellow': Colors.amber,
|
||||
'green': Colors.green,
|
||||
'mint': Color(0xff39b997),
|
||||
'teal': Colors.teal,
|
||||
'cyan': Colors.cyan,
|
||||
'blue': Colors.blue,
|
||||
'indigo': Colors.indigo,
|
||||
'purple': Colors.purple,
|
||||
'pink': Colors.pink,
|
||||
'gray': Colors.blueGrey,
|
||||
'coral': Color(0xffee7063),
|
||||
'peach': Color(0xffe9a077),
|
||||
'gold': Color(0xffbf930c),
|
||||
'lime': Color(0xff91ad21),
|
||||
'forest': Color(0xff28734a),
|
||||
'turquoise': Color(0xff27a9a0),
|
||||
'sky': Color(0xff459bc8),
|
||||
'navy': Color(0xff364d94),
|
||||
'lavender': Color(0xff9b82ce),
|
||||
'magenta': Color(0xffbd439d),
|
||||
'brown': Colors.brown,
|
||||
'slate': Color(0xff627389),
|
||||
};
|
||||
const categoryIcons = <String, IconData>{
|
||||
'star.fill': Icons.star_rounded,
|
||||
'heart.fill': Icons.favorite_rounded,
|
||||
'sparkles': Icons.auto_awesome,
|
||||
'bolt.fill': Icons.bolt,
|
||||
'flame.fill': Icons.local_fire_department,
|
||||
'drop.fill': Icons.water_drop,
|
||||
'cart.fill': Icons.shopping_cart_outlined,
|
||||
'bag.fill': Icons.shopping_bag_outlined,
|
||||
'creditcard.fill': Icons.credit_card,
|
||||
'banknote.fill': Icons.payments_outlined,
|
||||
'gift.fill': Icons.card_giftcard,
|
||||
'tag.fill': Icons.sell_outlined,
|
||||
'cup.and.saucer.fill': Icons.coffee,
|
||||
'fork.knife': Icons.restaurant,
|
||||
'takeoutbag.and.cup.and.straw.fill': Icons.takeout_dining,
|
||||
'birthday.cake.fill': Icons.cake_outlined,
|
||||
'car.fill': Icons.directions_car_outlined,
|
||||
'bus.fill': Icons.directions_bus,
|
||||
'tram.fill': Icons.tram,
|
||||
'bicycle': Icons.pedal_bike,
|
||||
'airplane': Icons.flight,
|
||||
'fuelpump.fill': Icons.local_gas_station,
|
||||
'house.fill': Icons.home_outlined,
|
||||
'bed.double.fill': Icons.bed_outlined,
|
||||
'lightbulb.fill': Icons.lightbulb_outline,
|
||||
'wifi': Icons.wifi,
|
||||
'phone.fill': Icons.phone_outlined,
|
||||
'printer.fill': Icons.print_outlined,
|
||||
'pawprint.fill': Icons.pets,
|
||||
'leaf.fill': Icons.eco_outlined,
|
||||
'figure.walk': Icons.directions_walk,
|
||||
'figure.run': Icons.directions_run,
|
||||
'dumbbell.fill': Icons.fitness_center,
|
||||
'cross.case.fill': Icons.medical_services_outlined,
|
||||
'pills.fill': Icons.medication_outlined,
|
||||
'stethoscope': Icons.monitor_heart_outlined,
|
||||
'book.fill': Icons.menu_book,
|
||||
'graduationcap.fill': Icons.school_outlined,
|
||||
'laptopcomputer': Icons.laptop,
|
||||
'briefcase.fill': Icons.work_outline,
|
||||
'gamecontroller.fill': Icons.sports_esports_outlined,
|
||||
'music.note': Icons.music_note,
|
||||
'film.fill': Icons.movie_outlined,
|
||||
'camera.fill': Icons.photo_camera_outlined,
|
||||
'paintpalette.fill': Icons.palette_outlined,
|
||||
'tshirt.fill': Icons.checkroom,
|
||||
'hammer.fill': Icons.gavel,
|
||||
'wrench.and.screwdriver.fill': Icons.handyman_outlined,
|
||||
'shippingbox.fill': Icons.inventory_2_outlined,
|
||||
'globe.europe.africa.fill': Icons.public,
|
||||
'repeat': Icons.repeat,
|
||||
'chart.line.uptrend.xyaxis': Icons.trending_up,
|
||||
'ellipsis.circle.fill': Icons.more_horiz,
|
||||
'plus.circle.fill': Icons.add_circle_outline,
|
||||
'slider.horizontal.3': Icons.tune,
|
||||
'tune': Icons.tune,
|
||||
};
|
||||
|
||||
class CategoryAvatar extends StatelessWidget {
|
||||
const CategoryAvatar({
|
||||
super.key,
|
||||
required this.icon,
|
||||
this.emoji = '',
|
||||
this.color = 'indigo',
|
||||
this.size = 44,
|
||||
});
|
||||
final String icon, emoji, color;
|
||||
final double size;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = categoryColors[color] ?? Theme.of(context).colorScheme.primary;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: c.withValues(alpha: .13),
|
||||
borderRadius: BorderRadius.circular(size * .32),
|
||||
),
|
||||
child: emoji.isNotEmpty
|
||||
? Text(emoji, style: TextStyle(fontSize: size * .48))
|
||||
: Icon(
|
||||
categoryIcons[icon] ?? Icons.category_outlined,
|
||||
color: c,
|
||||
size: size * .52,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Panel extends StatelessWidget {
|
||||
const Panel({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(24),
|
||||
});
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
@override
|
||||
Widget build(BuildContext context) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
child: Padding(padding: padding, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.action,
|
||||
this.icon = Icons.receipt_long_outlined,
|
||||
});
|
||||
final String title, subtitle;
|
||||
final Widget? action;
|
||||
final IconData icon;
|
||||
@override
|
||||
Widget build(BuildContext context) => Panel(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(subtitle, textAlign: TextAlign.center),
|
||||
if (action != null) ...[const SizedBox(height: 20), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class Metric extends StatelessWidget {
|
||||
const Metric({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
this.color,
|
||||
});
|
||||
final String label, value;
|
||||
final IconData icon;
|
||||
final Color? color;
|
||||
@override
|
||||
Widget build(BuildContext context) => Panel(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: color ?? Theme.of(context).colorScheme.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SpendingRing extends StatelessWidget {
|
||||
const SpendingRing({super.key, required this.segments, required this.total});
|
||||
final List<(int, Color)> segments;
|
||||
final String total;
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
label: 'Всего расходов: $total. Структура по категориям приведена ниже.',
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 190,
|
||||
child: CustomPaint(
|
||||
painter: _RingPainter(segments),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Расходы',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
FittedBox(
|
||||
child: Text(
|
||||
total,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _RingPainter extends CustomPainter {
|
||||
_RingPainter(this.segments);
|
||||
final List<(int, Color)> segments;
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final total = segments.fold<int>(0, (sum, s) => sum + s.$1);
|
||||
if (total <= 0) return;
|
||||
final rect = Offset.zero & size;
|
||||
var angle = -1.5707963267948966;
|
||||
for (final segment in segments) {
|
||||
final sweep = segment.$1 / total * 6.283185307179586;
|
||||
canvas.drawArc(
|
||||
rect.deflate(13),
|
||||
angle,
|
||||
sweep,
|
||||
false,
|
||||
Paint()
|
||||
..color = segment.$2
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 20,
|
||||
);
|
||||
angle += sweep;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RingPainter oldDelegate) =>
|
||||
oldDelegate.segments != segments;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../domain/finance.dart';
|
||||
import 'design.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
Future<T?> editDialog<T>(BuildContext context, Widget child) => showDialog<T>(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
class TransactionEditor extends StatefulWidget {
|
||||
const TransactionEditor({super.key, this.existing, required this.categories});
|
||||
final TransactionRecord? existing;
|
||||
final List<CategoryRecord> categories;
|
||||
@override
|
||||
State<TransactionEditor> createState() => _TransactionEditorState();
|
||||
}
|
||||
|
||||
class _TransactionEditorState extends State<TransactionEditor> {
|
||||
final form = GlobalKey<FormState>();
|
||||
late final TextEditingController amount, note;
|
||||
late DateTime date;
|
||||
late String kind;
|
||||
late CategoryRecord selected;
|
||||
List<CategoryRecord> get choices {
|
||||
final list = [
|
||||
...BuiltInCategories.forKind(kind),
|
||||
...widget.categories.where((c) => c.kindRawValue == kind),
|
||||
];
|
||||
if (!list.any((c) => c.name == selected.name)) list.add(selected);
|
||||
return {for (final c in list) c.name: c}.values.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final t = widget.existing;
|
||||
amount = TextEditingController(
|
||||
text: t == null ? '' : amountInput(t.amountMinor),
|
||||
);
|
||||
note = TextEditingController(text: t?.note ?? '');
|
||||
date = t?.date.toLocal() ?? DateTime.now();
|
||||
kind = t?.kindRawValue ?? 'expense';
|
||||
selected = t == null
|
||||
? BuiltInCategories.expense.first
|
||||
: CategoryRecord(
|
||||
id: 'archived',
|
||||
name: t.categoryName,
|
||||
icon: t.categoryIcon,
|
||||
emoji: t.categoryEmoji,
|
||||
colorName: t.categoryColorName,
|
||||
kindRawValue: kind,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
amount.dispose();
|
||||
note.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: form,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.existing == null ? 'Новая операция' : 'Изменить операцию',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: 'expense',
|
||||
label: Text('Расход'),
|
||||
icon: Icon(Icons.north_east),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: 'income',
|
||||
label: Text('Доход'),
|
||||
icon: Icon(Icons.south_west),
|
||||
),
|
||||
],
|
||||
selected: {kind},
|
||||
onSelectionChanged: widget.existing?.isBalanceAdjustment == true
|
||||
? null
|
||||
: (s) => setState(() {
|
||||
kind = s.first;
|
||||
selected = BuiltInCategories.forKind(kind).first;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: amount,
|
||||
autofocus: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Сумма',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) {
|
||||
final m = parseAmount(v ?? '');
|
||||
return m == null || m <= 0
|
||||
? 'Введите положительную сумму, до 2 знаков после запятой'
|
||||
: null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selected.name,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Категория',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: choices
|
||||
.map(
|
||||
(c) => DropdownMenuItem(
|
||||
value: c.name,
|
||||
child: Row(
|
||||
children: [
|
||||
CategoryAvatar(
|
||||
icon: c.icon,
|
||||
emoji: c.emoji,
|
||||
color: c.colorName,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Text(c.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: widget.existing?.isBalanceAdjustment == true
|
||||
? null
|
||||
: (v) => setState(
|
||||
() => selected = choices.firstWhere((c) => c.name == v),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.calendar_today_outlined),
|
||||
title: Text(DateFormat('d MMMM y, HH:mm', 'ru').format(date)),
|
||||
trailing: const Icon(Icons.edit_outlined),
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: date,
|
||||
firstDate: DateTime(1900),
|
||||
lastDate: DateTime(2200),
|
||||
);
|
||||
if (d == null || !context.mounted) return;
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(date),
|
||||
);
|
||||
if (time != null && mounted) {
|
||||
setState(
|
||||
() => date = DateTime(
|
||||
d.year,
|
||||
d.month,
|
||||
d.day,
|
||||
time.hour,
|
||||
time.minute,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: note,
|
||||
maxLines: 3,
|
||||
maxLength: 4000,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Заметка',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (!form.currentState!.validate()) return;
|
||||
final now = DateTime.now();
|
||||
Navigator.pop(
|
||||
context,
|
||||
TransactionRecord(
|
||||
id: widget.existing?.id ?? _uuid.v4(),
|
||||
amountMinor: parseAmount(amount.text)!,
|
||||
date: date,
|
||||
note: note.text.trim(),
|
||||
categoryName: selected.name,
|
||||
categoryIcon: selected.icon,
|
||||
categoryEmoji: selected.emoji,
|
||||
categoryColorName: selected.colorName,
|
||||
kindRawValue: kind,
|
||||
isBalanceAdjustment:
|
||||
widget.existing?.isBalanceAdjustment ?? false,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class CategoryEditor extends StatefulWidget {
|
||||
const CategoryEditor({super.key, this.existing, required this.categories});
|
||||
final CategoryRecord? existing;
|
||||
final List<CategoryRecord> categories;
|
||||
@override
|
||||
State<CategoryEditor> createState() => _CategoryEditorState();
|
||||
}
|
||||
|
||||
class _CategoryEditorState extends State<CategoryEditor> {
|
||||
final form = GlobalKey<FormState>();
|
||||
late final TextEditingController name, emoji;
|
||||
late String kind, icon, color;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final c = widget.existing;
|
||||
name = TextEditingController(text: c?.name ?? '');
|
||||
emoji = TextEditingController(text: c?.emoji ?? '');
|
||||
kind = c?.kindRawValue ?? 'expense';
|
||||
icon = c?.icon ?? 'star.fill';
|
||||
color = c?.colorName ?? 'indigo';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
name.dispose();
|
||||
emoji.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: form,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.existing == null ? 'Новая категория' : 'Изменить категорию',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
controller: name,
|
||||
maxLength: 80,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (s) {
|
||||
final n = (s ?? '').trim().toLowerCase();
|
||||
if (n.isEmpty) return 'Введите название';
|
||||
if ([
|
||||
...BuiltInCategories.expense,
|
||||
...BuiltInCategories.income,
|
||||
...widget.categories,
|
||||
].any(
|
||||
(c) => c.id != widget.existing?.id && c.name.toLowerCase() == n,
|
||||
)) {
|
||||
return 'Категория с таким названием уже есть';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'expense', label: Text('Расход')),
|
||||
ButtonSegment(value: 'income', label: Text('Доход')),
|
||||
],
|
||||
selected: {kind},
|
||||
onSelectionChanged: widget.existing != null
|
||||
? null
|
||||
: (s) => setState(() => kind = s.first),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
CategoryAvatar(
|
||||
icon: icon,
|
||||
emoji: emoji.text,
|
||||
color: color,
|
||||
size: 60,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: emoji,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Свой эмодзи',
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
tooltip: 'Убрать эмодзи',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => setState(() => emoji.clear()),
|
||||
),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
maxLength: 8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: CategoryDesignOptions.emojis
|
||||
.map(
|
||||
(e) => IconButton(
|
||||
onPressed: () => setState(() => emoji.text = e),
|
||||
tooltip: e,
|
||||
icon: Text(e, style: const TextStyle(fontSize: 23)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Значок', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: CategoryDesignOptions.icons
|
||||
.map(
|
||||
(i) => IconButton.filledTonal(
|
||||
isSelected: i == icon && emoji.text.isEmpty,
|
||||
onPressed: () => setState(() {
|
||||
icon = i;
|
||||
emoji.clear();
|
||||
}),
|
||||
tooltip: i,
|
||||
icon: Icon(categoryIcons[i] ?? Icons.category_outlined),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('Цвет', style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: CategoryDesignOptions.colors
|
||||
.map(
|
||||
(c) => IconButton(
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: categoryColors[c],
|
||||
),
|
||||
tooltip: c,
|
||||
onPressed: () => setState(() => color = c),
|
||||
icon: Icon(
|
||||
c == color ? Icons.check : Icons.circle,
|
||||
color: c == color ? Colors.white : Colors.transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (!form.currentState!.validate()) return;
|
||||
final now = DateTime.now();
|
||||
Navigator.pop(
|
||||
context,
|
||||
CategoryRecord(
|
||||
id: widget.existing?.id ?? _uuid.v4(),
|
||||
name: name.text.trim(),
|
||||
icon: icon,
|
||||
emoji: emoji.text.characters.take(1).toString(),
|
||||
colorName: color,
|
||||
kindRawValue: kind,
|
||||
createdAt: widget.existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class BudgetEditor extends StatefulWidget {
|
||||
const BudgetEditor({
|
||||
super.key,
|
||||
this.existing,
|
||||
required this.categories,
|
||||
required this.month,
|
||||
});
|
||||
final BudgetRecord? existing;
|
||||
final List<CategoryRecord> categories;
|
||||
final DateTime month;
|
||||
@override
|
||||
State<BudgetEditor> createState() => _BudgetEditorState();
|
||||
}
|
||||
|
||||
class _BudgetEditorState extends State<BudgetEditor> {
|
||||
final form = GlobalKey<FormState>();
|
||||
late final TextEditingController amount;
|
||||
late CategoryRecord selected;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
amount = TextEditingController(
|
||||
text: widget.existing == null
|
||||
? ''
|
||||
: amountInput(widget.existing!.limitMinor),
|
||||
);
|
||||
selected =
|
||||
widget.categories
|
||||
.where((c) => c.name == widget.existing?.categoryName)
|
||||
.firstOrNull ??
|
||||
widget.categories.first;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
amount.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: form,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Лимит на ${DateFormat('LLLL y', 'ru').format(widget.month)}',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: selected.name,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Категория',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: widget.categories
|
||||
.map(
|
||||
(c) => DropdownMenuItem(value: c.name, child: Text(c.name)),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (n) => setState(
|
||||
() => selected = widget.categories.firstWhere((c) => c.name == n),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: amount,
|
||||
autofocus: true,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Месячный лимит',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) =>
|
||||
parseAmount(v ?? '') == null || parseAmount(v ?? '')! <= 0
|
||||
? 'Введите положительный лимит'
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.end,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (!form.currentState!.validate()) return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
BudgetRecord(
|
||||
id: widget.existing?.id ?? _uuid.v4(),
|
||||
categoryName: selected.name,
|
||||
categoryIcon: selected.icon,
|
||||
categoryEmoji: selected.emoji,
|
||||
limitMinor: parseAmount(amount.text)!,
|
||||
monthStart: widget.month,
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> confirmDelete(
|
||||
BuildContext context,
|
||||
String title, {
|
||||
String message =
|
||||
'Это действие будет синхронизировано с другими устройствами.',
|
||||
}) async =>
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Удалить'),
|
||||
),
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
Reference in new issue
Block a user