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,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 ?? 'Бюджет удалён');
}
}
}