feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
@@ -0,0 +1,256 @@
import 'package:flutter/cupertino.dart';
import 'package:please_pay_me/core/format/formatters.dart';
import 'package:please_pay_me/data/models/budget.dart';
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:please_pay_me/ui/ui.dart';
/// Create / edit form for an envelope.
class BudgetFormSheet extends StatefulWidget {
const BudgetFormSheet({super.key, required this.onSubmit, this.initial});
final BudgetStatus? initial;
final Future<String?> Function({
required String name,
required double totalAmount,
required DateTime startDate,
required DateTime endDate,
required bool resetExpenses,
}) onSubmit;
@override
State<BudgetFormSheet> createState() => _BudgetFormSheetState();
}
class _BudgetFormSheetState extends State<BudgetFormSheet> {
late final _nameController = TextEditingController(
text: widget.initial?.budget.name ?? '',
);
late final _amountController = TextEditingController(
text: widget.initial == null
? ''
: widget.initial!.budget.totalAmount.toStringAsFixed(0),
);
late DateTime _startDate = widget.initial?.budget.startDate ?? DateTime.now();
late DateTime _endDate =
widget.initial?.budget.endDate ?? DateTime.now().add(const Duration(days: 14));
bool _resetExpenses = false;
bool _saving = false;
String? _error;
bool get _isEditing => widget.initial != null;
@override
void dispose() {
_nameController.dispose();
_amountController.dispose();
super.dispose();
}
Future<void> _submit() async {
final name = _nameController.text.trim();
final amount = double.tryParse(
_amountController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
);
if (name.isEmpty) {
setState(() => _error = 'Введите название бюджета');
return;
}
if (amount == null || amount <= 0) {
setState(() => _error = 'Введите сумму больше нуля');
return;
}
if (!_endDate.isAfter(_startDate)) {
setState(() => _error = 'Дата окончания должна быть позже начала');
return;
}
setState(() {
_saving = true;
_error = null;
});
final error = await widget.onSubmit(
name: name,
totalAmount: amount,
startDate: _startDate,
endDate: _endDate,
resetExpenses: _resetExpenses,
);
if (!mounted) return;
if (error != null) {
setState(() {
_saving = false;
_error = error;
});
return;
}
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return CupertinoPageScaffold(
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
navigationBar: AppNavBar(
title: _isEditing ? 'Бюджет' : 'Новый бюджет',
leading: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: _saving ? null : () => Navigator.of(context).pop(false),
child: const AppText.body('Отмена', color: AppColors.accent),
),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.only(top: AppSpacing.s4, bottom: AppSpacing.s6),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppTextField(
label: 'Название',
placeholder: 'До аванса',
controller: _nameController,
enabled: !_saving,
),
const SizedBox(height: AppSpacing.s4),
AppTextField(
label: 'Сумма на период',
placeholder: '0',
controller: _amountController,
enabled: !_saving,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
],
),
),
const SizedBox(height: AppSpacing.s5),
AppListSection(
header: 'Период',
footer: 'Дневной лимит = остаток ÷ количество оставшихся дней.',
children: [
AppListTile(
title: 'Начало',
value: formatShortDate(_startDate),
onTap: _saving ? null : () => _pickDate(isStart: true),
),
AppListTile(
title: 'Окончание',
value: formatShortDate(_endDate),
onTap: _saving ? null : () => _pickDate(isStart: false),
),
],
),
if (_isEditing) ...[
const SizedBox(height: AppSpacing.s5),
AppListSection(
footer: 'Сбросить траты — обнулить потраченное по этому бюджету.',
children: [
AppSwitchRow(
title: 'Сбросить траты',
value: _resetExpenses,
onChanged: _saving ? null : (v) => setState(() => _resetExpenses = v),
),
],
),
],
if (_error != null)
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.gutter,
AppSpacing.s3,
AppSpacing.gutter,
0,
),
child: AppText.footnote(_error!, color: AppColors.systemRed),
),
const SizedBox(height: AppSpacing.s5),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
child: AppButton(
label: _isEditing ? 'Сохранить' : 'Создать бюджет',
loading: _saving,
onPressed: _submit,
),
),
],
),
),
);
}
Future<void> _pickDate({required bool isStart}) async {
final picked = await showAppDatePicker(
context: context,
initialDate: isStart ? _startDate : _endDate,
minimumDate: isStart ? null : _startDate,
);
if (picked == null || !mounted) return;
setState(() {
if (isStart) {
_startDate = picked;
if (!_endDate.isAfter(_startDate)) {
_endDate = _startDate.add(const Duration(days: 14));
}
} else {
_endDate = picked;
}
});
}
}
/// Opens the sheet wired to [BudgetsController].
Future<void> showBudgetFormSheet({
required BuildContext context,
required BudgetsController controller,
BudgetStatus? initial,
}) async {
final saved = await showAppFormSheet<bool>(
context: context,
builder: (_) => BudgetFormSheet(
initial: initial,
onSubmit: ({
required name,
required totalAmount,
required startDate,
required endDate,
required resetExpenses,
}) {
if (initial == null) {
return controller.create(
name: name,
totalAmount: totalAmount,
endDate: endDate,
startDate: startDate,
);
}
return controller.update(
budgetId: initial.budget.id,
name: name,
totalAmount: totalAmount,
endDate: endDate,
startDate: startDate,
resetExpenses: resetExpenses,
);
},
),
);
if (saved == true && context.mounted) {
await showAppToast(
context,
message: initial == null ? 'Бюджет создан' : 'Бюджет обновлён',
);
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/foundation.dart';
import 'package:please_pay_me/core/state/async_value.dart';
import 'package:please_pay_me/data/api/api_client.dart';
import 'package:please_pay_me/data/models/budget.dart';
import 'package:please_pay_me/data/repositories/repositories.dart';
/// Source of truth for budgets: the overview, the budget list and the expense
/// form all read the selected envelope from here.
class BudgetsController extends ChangeNotifier {
BudgetsController({
required BudgetRepository budgets,
required ExpenseRepository expenses,
}) : _budgets = budgets,
_expenses = expenses;
final BudgetRepository _budgets;
final ExpenseRepository _expenses;
AsyncValue<List<BudgetStatus>> _state = const AsyncValue.loading();
bool _mutating = false;
AsyncValue<List<BudgetStatus>> get state => _state;
/// True while a write is in flight — used to disable buttons.
bool get isMutating => _mutating;
List<BudgetStatus> get items => _state.valueOrNull ?? const [];
/// Currently selected envelope; `null` when the user has no budgets at all.
BudgetStatus? get selected {
final all = items;
if (all.isEmpty) return null;
for (final status in all) {
if (status.selected) return status;
}
return all.first;
}
bool get hasBudgets => items.isNotEmpty;
Future<void> load({bool silent = false}) async {
if (!silent) {
_state = const AsyncValue.loading();
notifyListeners();
}
try {
_state = AsyncValue.data(await _budgets.list());
} on ApiException catch (error) {
_state = AsyncValue.error(error.message);
}
notifyListeners();
}
Future<String?> select(int budgetId) {
return _mutate(() => _budgets.select(budgetId));
}
Future<String?> setActive(int budgetId, {required bool isActive}) {
return _mutate(() => _budgets.setActive(budgetId, isActive: isActive));
}
Future<String?> create({
required String name,
required double totalAmount,
required DateTime endDate,
DateTime? startDate,
}) {
return _mutate(
() => _budgets.create(
name: name,
totalAmount: totalAmount,
endDate: endDate,
startDate: startDate,
),
);
}
Future<String?> update({
required int budgetId,
String? name,
double? totalAmount,
DateTime? endDate,
DateTime? startDate,
bool resetExpenses = false,
}) {
return _mutate(
() => _budgets.update(
budgetId: budgetId,
name: name,
totalAmount: totalAmount,
endDate: endDate,
startDate: startDate,
resetExpenses: resetExpenses,
),
);
}
Future<String?> delete(int budgetId) => _mutate(() => _budgets.delete(budgetId));
Future<String?> addExpense({
required double amount,
String? note,
DateTime? spentAt,
int? budgetId,
}) {
return _mutate(
() => _expenses.create(
amount: amount,
note: note,
spentAt: spentAt,
budgetId: budgetId ?? selected?.budget.id,
),
);
}
Future<String?> undoLastExpense() {
return _mutate(() => _expenses.undoLast(budgetId: selected?.budget.id));
}
/// Runs a write, reloads the list and returns an error message or `null`.
Future<String?> _mutate(Future<void> Function() action) async {
_mutating = true;
notifyListeners();
try {
await action();
await load(silent: true);
return null;
} on ApiException catch (error) {
return error.message;
} finally {
_mutating = false;
notifyListeners();
}
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/cupertino.dart';
import 'package:please_pay_me/core/format/formatters.dart';
import 'package:please_pay_me/data/models/budget.dart';
import 'package:please_pay_me/features/budgets/budget_form_sheet.dart';
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
import 'package:please_pay_me/features/journal/journal_controller.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:please_pay_me/ui/ui.dart';
import 'package:provider/provider.dart';
/// All envelopes: pick the active one, edit, archive or delete.
class BudgetsScreen extends StatelessWidget {
const BudgetsScreen({super.key});
@override
Widget build(BuildContext context) {
final controller = context.watch<BudgetsController>();
return CupertinoPageScaffold(
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
child: CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
AppLargeNavBar(
title: 'Бюджеты',
trailing: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => showBudgetFormSheet(context: context, controller: controller),
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
),
),
CupertinoSliverRefreshControl(onRefresh: () => controller.load(silent: true)),
SliverToBoxAdapter(
child: controller.state.map(
loading: () => AppListSection(
children: List.generate(3, (_) => const AppSkeletonRow()),
),
error: (message) => AppErrorView(message: message, onRetry: controller.load),
data: (items) => items.isEmpty
? AppEmptyState(
icon: CupertinoIcons.money_rubl_circle,
title: 'Бюджетов нет',
message: 'Создайте первый конверт до следующей зарплаты.',
actionLabel: 'Создать бюджет',
onAction: () =>
showBudgetFormSheet(context: context, controller: controller),
)
: _BudgetsList(items: items, controller: controller),
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
],
),
);
}
}
class _BudgetsList extends StatelessWidget {
const _BudgetsList({required this.items, required this.controller});
final List<BudgetStatus> items;
final BudgetsController controller;
@override
Widget build(BuildContext context) {
final active = items.where((status) => !status.isExpired).toList();
final archived = items.where((status) => status.isExpired).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (active.isNotEmpty)
AppListSection(
header: 'Активные',
footer: 'Нажмите, чтобы сделать бюджет текущим.',
separatorIndent: 60,
children: [
for (final status in active)
_BudgetRow(status: status, controller: controller),
],
),
if (archived.isNotEmpty) ...[
const SizedBox(height: AppSpacing.s5),
AppListSection(
header: 'Завершённые',
separatorIndent: 60,
children: [
for (final status in archived)
_BudgetRow(status: status, controller: controller),
],
),
],
],
);
}
}
class _BudgetRow extends StatelessWidget {
const _BudgetRow({required this.status, required this.controller});
final BudgetStatus status;
final BudgetsController controller;
@override
Widget build(BuildContext context) {
final currency = status.budget.currency;
final subtitle = status.isExpired
? 'Завершён ${formatShortDate(status.budget.endDate)}'
: '${formatDaysLeft(status.daysLeft)} · лимит ${formatMoney(status.dailyLimit, currency: currency)}';
return AppListTile(
leading: AppIconBadge(
icon: status.selected ? CupertinoIcons.checkmark_alt : CupertinoIcons.tray_full,
color: status.selected
? AppColors.accent
: status.isExpired
? AppColors.systemGray
: AppColors.systemOrange,
),
title: status.budget.name,
subtitle: subtitle,
value: formatMoney(status.remaining, currency: currency),
onTap: () => _openActions(context),
);
}
Future<void> _openActions(BuildContext context) async {
final journal = context.read<JournalController>();
final index = await showAppActionSheet(
context: context,
title: status.budget.name,
message: 'Остаток ${formatMoney(status.remaining, currency: status.budget.currency)}',
actions: [
if (!status.selected) const AppActionSheetAction(label: 'Сделать текущим', isDefault: true),
const AppActionSheetAction(label: 'Редактировать'),
AppActionSheetAction(label: status.budget.isActive ? 'В архив' : 'Вернуть из архива'),
const AppActionSheetAction(label: 'Удалить', destructive: true),
],
);
if (index == null || !context.mounted) return;
final actions = <String>[
if (!status.selected) 'select',
'edit',
'archive',
'delete',
];
switch (actions[index]) {
case 'select':
final error = await controller.select(status.budget.id);
journal.bindBudget(controller.selected?.budget.id);
await journal.load(silent: true);
if (context.mounted) {
await showAppToast(context, message: error ?? 'Бюджет выбран');
}
case 'edit':
await showBudgetFormSheet(
context: context,
controller: controller,
initial: status,
);
case 'archive':
final error = await controller.setActive(
status.budget.id,
isActive: !status.budget.isActive,
);
if (context.mounted) {
await showAppToast(
context,
message: error ?? (status.budget.isActive ? 'Бюджет в архиве' : 'Бюджет активен'),
);
}
case 'delete':
await _confirmDelete(context, journal);
}
}
Future<void> _confirmDelete(BuildContext context, JournalController journal) async {
final confirmed = await showAppAlert(
context: context,
title: 'Удалить «${status.budget.name}»?',
message: 'Вместе с бюджетом удалятся все его операции.',
confirmLabel: 'Удалить',
cancelLabel: 'Отмена',
destructive: true,
);
if (confirmed != true) return;
final error = await controller.delete(status.budget.id);
journal.bindBudget(controller.selected?.budget.id);
await journal.load(silent: true);
if (context.mounted) {
await showAppToast(context, message: error ?? 'Бюджет удалён');
}
}
}