290 lines
9.6 KiB
Dart
290 lines
9.6 KiB
Dart
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,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|