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,73 @@
import 'package:flutter/cupertino.dart';
import 'package:please_pay_me/core/format/formatters.dart';
import 'package:please_pay_me/features/budgets/budgets_controller.dart';
import 'package:please_pay_me/features/expenses/expense_form_sheet.dart';
import 'package:please_pay_me/features/journal/journal_controller.dart';
import 'package:please_pay_me/ui/ui.dart';
import 'package:provider/provider.dart';
/// Opens the expense form and keeps the journal in sync on success.
Future<void> showExpenseFormSheet({
required BuildContext context,
required BudgetsController controller,
}) async {
final selected = controller.selected;
final journal = context.read<JournalController>();
final saved = await showAppFormSheet<bool>(
context: context,
builder: (_) => ExpenseFormSheet(
budgetName: selected?.budget.name,
remainingToday: selected?.remainingToday,
onSubmit: ({required amount, note, required spentAt}) => controller.addExpense(
amount: amount,
note: note,
spentAt: spentAt,
),
),
);
if (saved != true) return;
await journal.load(silent: true);
if (context.mounted) {
await showAppToast(context, message: 'Трата записана');
}
}
Future<void> undoLastExpense({
required BuildContext context,
required BudgetsController controller,
}) async {
final journal = context.read<JournalController>();
final confirmed = await showAppAlert(
context: context,
title: 'Отменить последнюю трату?',
message: 'Операция будет удалена из текущего бюджета.',
confirmLabel: 'Отменить трату',
cancelLabel: 'Закрыть',
destructive: true,
);
if (confirmed != true) return;
final error = await controller.undoLastExpense();
await journal.load(silent: true);
if (!context.mounted) return;
await showAppToast(
context,
message: error ?? 'Последняя трата удалена',
icon: error == null
? CupertinoIcons.arrow_uturn_left_circle_fill
: CupertinoIcons.exclamationmark_circle_fill,
);
}
/// Shared row renderer so the journal and the overview look identical.
String expenseTitle(String? note) => note?.trim().isNotEmpty == true ? note!.trim() : 'Без комментария';
String expenseAmount(double amount, {String currency = 'RUB'}) {
return formatSignedMoney(amount, currency: currency);
}
@@ -0,0 +1,208 @@
import 'package:flutter/cupertino.dart';
import 'package:please_pay_me/core/format/formatters.dart';
import 'package:please_pay_me/theme/theme.dart';
import 'package:please_pay_me/ui/ui.dart';
typedef ExpenseSubmit = Future<String?> Function({
required double amount,
String? note,
required DateTime spentAt,
});
/// Modal form for a new expense. Submits through the caller so the sheet has
/// no knowledge of repositories.
class ExpenseFormSheet extends StatefulWidget {
const ExpenseFormSheet({
super.key,
required this.onSubmit,
this.budgetName,
this.remainingToday,
});
final ExpenseSubmit onSubmit;
final String? budgetName;
final double? remainingToday;
@override
State<ExpenseFormSheet> createState() => _ExpenseFormSheetState();
}
class _ExpenseFormSheetState extends State<ExpenseFormSheet> {
final _amountController = TextEditingController();
final _noteController = TextEditingController();
DateTime _date = DateTime.now();
bool _saving = false;
String? _error;
static const _quickAmounts = [100.0, 250.0, 500.0, 1000.0];
@override
void dispose() {
_amountController.dispose();
_noteController.dispose();
super.dispose();
}
double? get _amount {
final raw = _amountController.text.trim().replaceAll(',', '.').replaceAll(' ', '');
final value = double.tryParse(raw);
return value != null && value > 0 ? value : null;
}
Future<void> _submit() async {
final amount = _amount;
if (amount == null) {
setState(() => _error = 'Введите сумму больше нуля');
return;
}
setState(() {
_saving = true;
_error = null;
});
final note = _noteController.text.trim();
final error = await widget.onSubmit(
amount: amount,
note: note.isEmpty ? null : note,
spentAt: _date,
);
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: 'Новая трата',
subtitle: widget.budgetName,
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: [
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AppText.footnote('Сумма'),
const SizedBox(height: AppSpacing.s1),
CupertinoTextField.borderless(
controller: _amountController,
autofocus: true,
placeholder: '0',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: AppTypography.largeTitle.copyWith(
color: AppColors.of(context, AppColors.label),
),
placeholderStyle: AppTypography.largeTitle.copyWith(
color: AppColors.of(context, AppColors.tertiaryLabel),
),
padding: EdgeInsets.zero,
suffix: const AppText.title('', color: AppColors.secondaryLabel),
onChanged: (_) => setState(() => _error = null),
onSubmitted: (_) => _submit(),
),
if (widget.remainingToday != null) ...[
const SizedBox(height: AppSpacing.s2),
AppText.footnote(
'На сегодня осталось ${formatMoney(widget.remainingToday!)}',
color: widget.remainingToday! < 0
? AppColors.systemRed
: AppColors.secondaryLabel,
),
],
],
),
),
const SizedBox(height: AppSpacing.s3),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
child: Row(
children: [
for (final amount in _quickAmounts) ...[
AppChip(
label: formatMoney(amount, compact: true),
onPressed: () => setState(() {
_amountController.text = amount.toStringAsFixed(0);
_error = null;
}),
),
const SizedBox(width: AppSpacing.s2),
],
],
),
),
const SizedBox(height: AppSpacing.s5),
AppListSection(
children: [
AppListTile(
title: 'Дата',
value: formatRelativeDay(_date),
onTap: _saving ? null : _pickDate,
),
],
),
const SizedBox(height: AppSpacing.s4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
child: AppTextField(
controller: _noteController,
placeholder: 'Комментарий',
prefixIcon: CupertinoIcons.text_alignleft,
enabled: !_saving,
),
),
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: 'Записать трату',
loading: _saving,
onPressed: _submit,
),
),
],
),
),
);
}
Future<void> _pickDate() async {
final picked = await showAppDatePicker(
context: context,
initialDate: _date,
maximumDate: DateTime.now(),
);
if (picked != null && mounted) setState(() => _date = picked);
}
}