feat(proj): init
This commit is contained in:
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user