feat(proj): init
This commit is contained in:
@@ -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 ?? 'Бюджет удалён');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user