feat(proj): init
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:please_pay_me/core/format/formatters.dart';
|
||||
import 'package:please_pay_me/data/models/job.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';
|
||||
|
||||
/// Create / edit form for a job and its payday schedule.
|
||||
class JobFormSheet extends StatefulWidget {
|
||||
const JobFormSheet({super.key, required this.onSubmit, this.initial});
|
||||
|
||||
final Job? initial;
|
||||
|
||||
final Future<String?> Function({
|
||||
required String name,
|
||||
required double salaryAmount,
|
||||
required List<int> payDays,
|
||||
required double firstPayPercent,
|
||||
required WeekendPolicy weekendPolicy,
|
||||
}) onSubmit;
|
||||
|
||||
@override
|
||||
State<JobFormSheet> createState() => _JobFormSheetState();
|
||||
}
|
||||
|
||||
class _JobFormSheetState extends State<JobFormSheet> {
|
||||
late final _nameController = TextEditingController(text: widget.initial?.name ?? '');
|
||||
late final _salaryController = TextEditingController(
|
||||
text: widget.initial == null ? '' : widget.initial!.salaryAmount.toStringAsFixed(0),
|
||||
);
|
||||
|
||||
late int _firstDay = widget.initial?.payDays.firstOrNull ?? 5;
|
||||
late int? _secondDay =
|
||||
(widget.initial?.payDays.length ?? 0) > 1 ? widget.initial!.payDays[1] : 20;
|
||||
late double _firstPercent = widget.initial?.firstPayPercent ?? 40;
|
||||
late WeekendPolicy _policy = widget.initial?.weekendPolicy ?? WeekendPolicy.beforeWeekend;
|
||||
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
bool get _isEditing => widget.initial != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_salaryController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final name = _nameController.text.trim();
|
||||
final salary = double.tryParse(
|
||||
_salaryController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
|
||||
);
|
||||
|
||||
if (name.isEmpty) {
|
||||
setState(() => _error = 'Введите название работы');
|
||||
return;
|
||||
}
|
||||
if (salary == null || salary <= 0) {
|
||||
setState(() => _error = 'Введите оклад больше нуля');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final error = await widget.onSubmit(
|
||||
name: name,
|
||||
salaryAmount: salary,
|
||||
payDays: [_firstDay, if (_secondDay != null) _secondDay!],
|
||||
firstPayPercent: _secondDay == null ? 100 : _firstPercent,
|
||||
weekendPolicy: _policy,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error != null) {
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = error;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final salary = double.tryParse(
|
||||
_salaryController.text.trim().replaceAll(',', '.').replaceAll(' ', ''),
|
||||
) ??
|
||||
0;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
|
||||
navigationBar: AppNavBar(
|
||||
title: _isEditing ? 'Работа' : 'Новая работа',
|
||||
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: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppTextField(
|
||||
label: 'Название',
|
||||
placeholder: 'Основная работа',
|
||||
controller: _nameController,
|
||||
enabled: !_saving,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s4),
|
||||
AppTextField(
|
||||
label: 'Оклад в месяц',
|
||||
placeholder: '0',
|
||||
controller: _salaryController,
|
||||
enabled: !_saving,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppListSection(
|
||||
header: 'Дни выплат',
|
||||
footer: 'Если день выпадает на выходные, выплата сдвигается по правилу ниже.',
|
||||
children: [
|
||||
AppListTile(
|
||||
title: 'Первая выплата',
|
||||
value: '$_firstDay числа',
|
||||
onTap: _saving ? null : () => _pickDay(isFirst: true),
|
||||
),
|
||||
AppSwitchRow(
|
||||
title: 'Вторая выплата',
|
||||
value: _secondDay != null,
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (value) => setState(() => _secondDay = value ? 20 : null),
|
||||
),
|
||||
if (_secondDay != null)
|
||||
AppListTile(
|
||||
title: 'Вторая выплата',
|
||||
value: '$_secondDay числа',
|
||||
onTap: _saving ? null : () => _pickDay(isFirst: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_secondDay != null) ...[
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
AppCard(
|
||||
title: 'Доля первой выплаты',
|
||||
subtitle: '${_firstPercent.round()}% — '
|
||||
'${formatMoney(salary * _firstPercent / 100)} из ${formatMoney(salary)}',
|
||||
child: CupertinoSlider(
|
||||
value: _firstPercent,
|
||||
min: 5,
|
||||
max: 95,
|
||||
divisions: 18,
|
||||
activeColor: AppColors.of(context, AppColors.accent),
|
||||
onChanged: _saving ? null : (v) => setState(() => _firstPercent = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
const AppSectionHeader('Если выплата на выходных'),
|
||||
AppSegmentedControl(
|
||||
labels: WeekendPolicy.values.map((policy) => policy.label).toList(),
|
||||
index: WeekendPolicy.values.indexOf(_policy),
|
||||
onChanged: (index) => setState(() => _policy = WeekendPolicy.values[index]),
|
||||
),
|
||||
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: _isEditing ? 'Сохранить' : 'Добавить работу',
|
||||
loading: _saving,
|
||||
onPressed: _submit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDay({required bool isFirst}) async {
|
||||
final initial = isFirst ? _firstDay : (_secondDay ?? 20);
|
||||
var picked = initial;
|
||||
|
||||
final result = await showCupertinoModalPopup<int>(
|
||||
context: context,
|
||||
builder: (ctx) => Container(
|
||||
height: 280,
|
||||
color: AppColors.of(ctx, AppColors.groupedSurface),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CupertinoPicker(
|
||||
itemExtent: 36,
|
||||
scrollController: FixedExtentScrollController(initialItem: initial - 1),
|
||||
onSelectedItemChanged: (index) => picked = index + 1,
|
||||
children: [
|
||||
for (var day = 1; day <= 31; day++) Center(child: AppText.body('$day числа')),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s2,
|
||||
AppSpacing.gutter,
|
||||
AppSpacing.s3,
|
||||
),
|
||||
child: AppButton(
|
||||
label: 'Готово',
|
||||
onPressed: () => Navigator.of(ctx).pop(picked),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == null || !mounted) return;
|
||||
setState(() {
|
||||
if (isFirst) {
|
||||
_firstDay = result;
|
||||
} else {
|
||||
_secondDay = result;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showJobFormSheet({
|
||||
required BuildContext context,
|
||||
required JobsController controller,
|
||||
Job? initial,
|
||||
}) async {
|
||||
final saved = await showAppFormSheet<bool>(
|
||||
context: context,
|
||||
builder: (_) => JobFormSheet(
|
||||
initial: initial,
|
||||
onSubmit: ({
|
||||
required name,
|
||||
required salaryAmount,
|
||||
required payDays,
|
||||
required firstPayPercent,
|
||||
required weekendPolicy,
|
||||
}) {
|
||||
if (initial == null) {
|
||||
return controller.create(
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
);
|
||||
}
|
||||
return controller.update(
|
||||
jobId: initial.id,
|
||||
name: name,
|
||||
salaryAmount: salaryAmount,
|
||||
payDays: payDays,
|
||||
firstPayPercent: firstPayPercent,
|
||||
weekendPolicy: weekendPolicy,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (saved == true && context.mounted) {
|
||||
await showAppToast(
|
||||
context,
|
||||
message: initial == null ? 'Работа добавлена' : 'Работа обновлена',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user