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,289 @@
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/data/models/job.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/expenses/expense_actions.dart';
import 'package:please_pay_me/features/journal/journal_controller.dart';
import 'package:please_pay_me/features/work/jobs_controller.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:please_pay_me/ui/ui.dart';
import 'package:provider/provider.dart';
/// Home tab: current envelope, today's allowance and quick actions.
class OverviewScreen extends StatelessWidget {
const OverviewScreen({super.key});
@override
Widget build(BuildContext context) {
final budgets = context.watch<BudgetsController>();
return CupertinoPageScaffold(
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
child: CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
const AppLargeNavBar(title: 'Обзор'),
CupertinoSliverRefreshControl(
onRefresh: () async {
await Future.wait([
budgets.load(silent: true),
context.read<JobsController>().load(silent: true),
context.read<JournalController>().load(silent: true),
]);
},
),
SliverToBoxAdapter(
child: budgets.state.map(
loading: () => const AppLoadingView(),
error: (message) => AppErrorView(message: message, onRetry: budgets.load),
data: (_) {
final selected = budgets.selected;
if (selected == null) return _NoBudgets(controller: budgets);
return _OverviewBody(status: selected);
},
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
],
),
);
}
}
class _NoBudgets extends StatelessWidget {
const _NoBudgets({required this.controller});
final BudgetsController controller;
@override
Widget build(BuildContext context) {
return AppEmptyState(
icon: CupertinoIcons.money_rubl_circle,
title: 'Бюджета пока нет',
message: 'Создайте конверт до следующей зарплаты — приложение посчитает дневной лимит.',
actionLabel: 'Создать бюджет',
onAction: () => showBudgetFormSheet(context: context, controller: controller),
);
}
}
class _OverviewBody extends StatelessWidget {
const _OverviewBody({required this.status});
final BudgetStatus status;
@override
Widget build(BuildContext context) {
final budgets = context.watch<BudgetsController>();
final currency = status.budget.currency;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: AppText.headline(status.budget.name)),
AppChip(
label: status.isExpired
? 'Завершён'
: formatDaysLeft(status.daysLeft).replaceFirst('осталось ', ''),
selected: !status.isExpired,
),
],
),
const SizedBox(height: AppSpacing.s3),
const AppText.footnote('Остаток бюджета'),
AppText.largeTitle(
formatMoney(status.remaining, currency: currency),
color: status.isOverBudget ? AppColors.systemRed : AppColors.label,
),
const SizedBox(height: AppSpacing.s4),
AppProgressBar(
value: status.spentProgress,
color: status.isOverBudget ? AppColors.systemRed : AppColors.accent,
),
const SizedBox(height: AppSpacing.s2),
AppText.footnote(
'Потрачено ${formatMoney(status.totalSpent, currency: currency)} '
'из ${formatMoney(status.budget.totalAmount, currency: currency)}',
),
],
),
),
const SizedBox(height: AppSpacing.s4),
_TodayCard(status: status),
const SizedBox(height: AppSpacing.s4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
child: Row(
children: [
Expanded(
child: AppButton(
label: 'Добавить трату',
icon: CupertinoIcons.plus,
onPressed: budgets.isMutating
? null
: () => showExpenseFormSheet(context: context, controller: budgets),
),
),
const SizedBox(width: AppSpacing.s3),
AppButton(
label: 'Отменить',
style: AppButtonStyle.gray,
expanded: false,
onPressed: budgets.isMutating
? null
: () => undoLastExpense(context: context, controller: budgets),
),
],
),
),
const SizedBox(height: AppSpacing.s5),
const _NextPaySection(),
const _RecentOperations(),
],
);
}
}
class _TodayCard extends StatelessWidget {
const _TodayCard({required this.status});
final BudgetStatus status;
@override
Widget build(BuildContext context) {
final currency = status.budget.currency;
final overspent = status.isOverDaily;
return AppCard(
title: 'Сегодня',
subtitle: formatDay(status.today),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _Metric(
label: 'Дневной лимит',
value: formatMoney(status.dailyLimit, currency: currency),
),
),
Expanded(
child: _Metric(
label: 'Потрачено',
value: formatMoney(status.spentToday, currency: currency),
color: overspent ? AppColors.systemRed : AppColors.label,
),
),
],
),
const SizedBox(height: AppSpacing.s4),
AppProgressBar(
value: status.dailyProgress,
color: overspent ? AppColors.systemRed : AppColors.systemGreen,
),
const SizedBox(height: AppSpacing.s2),
AppText.footnote(
overspent
? 'Лимит превышен на ${formatMoney(status.spentToday - status.dailyLimit, currency: currency)}'
: 'Можно потратить ещё ${formatMoney(status.remainingToday, currency: currency)}',
color: overspent ? AppColors.systemRed : AppColors.secondaryLabel,
),
],
),
);
}
}
class _Metric extends StatelessWidget {
const _Metric({required this.label, required this.value, this.color = AppColors.label});
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppText.footnote(label),
const SizedBox(height: 2),
AppText.title(value, color: color),
],
);
}
}
class _NextPaySection extends StatelessWidget {
const _NextPaySection();
@override
Widget build(BuildContext context) {
final jobs = context.watch<JobsController>();
final pay = jobs.nextPay;
if (pay == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.s5),
child: AppListSection(
header: 'Ближайшая выплата',
separatorIndent: 60,
children: [
AppListTile(
leading: const AppIconBadge(
icon: CupertinoIcons.money_rubl_circle_fill,
color: AppColors.systemGreen,
),
title: formatMoney(pay.amount),
subtitle: '${formatDay(pay.date)} · ${pay.percent.round()}% оклада',
value: _daysUntil(pay),
showChevron: false,
),
],
),
);
}
String _daysUntil(UpcomingPay pay) {
final now = DateTime.now();
final days = pay.date.difference(DateTime(now.year, now.month, now.day)).inDays;
return switch (days) {
<= 0 => 'сегодня',
1 => 'завтра',
_ => 'через ${plural(days, 'день', 'дня', 'дней')}',
};
}
}
class _RecentOperations extends StatelessWidget {
const _RecentOperations();
@override
Widget build(BuildContext context) {
final journal = context.watch<JournalController>();
final recent = journal.items.take(3).toList();
if (recent.isEmpty) return const SizedBox.shrink();
return AppListSection(
header: 'Последние операции',
children: [
for (final expense in recent)
AppListTile(
title: expense.note ?? 'Без комментария',
subtitle: formatRelativeDay(expense.spentAt),
value: formatSignedMoney(expense.amount),
showChevron: false,
),
],
);
}
}