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