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,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 ? 'Работа добавлена' : 'Работа обновлена',
);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/foundation.dart';
import 'package:please_pay_me/core/state/async_value.dart';
import 'package:please_pay_me/data/api/api_client.dart';
import 'package:please_pay_me/data/models/job.dart';
import 'package:please_pay_me/data/repositories/repositories.dart';
class JobsController extends ChangeNotifier {
JobsController({required JobRepository jobs}) : _jobs = jobs;
final JobRepository _jobs;
AsyncValue<List<Job>> _state = const AsyncValue.loading();
bool _mutating = false;
AsyncValue<List<Job>> get state => _state;
bool get isMutating => _mutating;
List<Job> get items => _state.valueOrNull ?? const [];
/// Nearest payday across all jobs — shown on the overview screen.
UpcomingPay? get nextPay {
final pays = items.expand((job) => job.nextPays).toList()
..sort((a, b) => a.date.compareTo(b.date));
return pays.isEmpty ? null : pays.first;
}
Future<void> load({bool silent = false}) async {
if (!silent) {
_state = const AsyncValue.loading();
notifyListeners();
}
try {
_state = AsyncValue.data(await _jobs.list());
} on ApiException catch (error) {
_state = AsyncValue.error(error.message);
}
notifyListeners();
}
Future<String?> create({
required String name,
required double salaryAmount,
required List<int> payDays,
required double firstPayPercent,
required WeekendPolicy weekendPolicy,
}) {
return _mutate(
() => _jobs.create(
name: name,
salaryAmount: salaryAmount,
payDays: payDays,
firstPayPercent: firstPayPercent,
weekendPolicy: weekendPolicy,
),
);
}
Future<String?> update({
required int jobId,
required String name,
required double salaryAmount,
required List<int> payDays,
required double firstPayPercent,
required WeekendPolicy weekendPolicy,
}) {
return _mutate(
() => _jobs.update(
jobId: jobId,
name: name,
salaryAmount: salaryAmount,
payDays: payDays,
firstPayPercent: firstPayPercent,
weekendPolicy: weekendPolicy,
),
);
}
Future<String?> delete(int jobId) => _mutate(() => _jobs.delete(jobId));
Future<String?> _mutate(Future<void> Function() action) async {
_mutating = true;
notifyListeners();
try {
await action();
await load(silent: true);
return null;
} on ApiException catch (error) {
return error.message;
} finally {
_mutating = false;
notifyListeners();
}
}
}
+166
View File
@@ -0,0 +1,166 @@
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/job_form_sheet.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';
/// Jobs and their payday schedule.
class WorkScreen extends StatelessWidget {
const WorkScreen({super.key});
@override
Widget build(BuildContext context) {
final controller = context.watch<JobsController>();
return CupertinoPageScaffold(
backgroundColor: AppColors.of(context, AppColors.groupedBackground),
child: CustomScrollView(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
slivers: [
AppLargeNavBar(
title: 'Работа',
trailing: CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => showJobFormSheet(context: context, controller: controller),
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
),
),
CupertinoSliverRefreshControl(onRefresh: () => controller.load(silent: true)),
SliverToBoxAdapter(
child: controller.state.map(
loading: () => AppListSection(
children: List.generate(2, (_) => const AppSkeletonRow()),
),
error: (message) => AppErrorView(message: message, onRetry: controller.load),
data: (jobs) => jobs.isEmpty
? AppEmptyState(
icon: CupertinoIcons.briefcase,
title: 'Работа не добавлена',
message: 'Укажите оклад и дни выплат — приложение подскажет даты зарплаты.',
actionLabel: 'Добавить работу',
onAction: () =>
showJobFormSheet(context: context, controller: controller),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final job in jobs) ...[
_JobCard(job: job, controller: controller),
const SizedBox(height: AppSpacing.s5),
],
],
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
],
),
);
}
}
class _JobCard extends StatelessWidget {
const _JobCard({required this.job, required this.controller});
final Job job;
final JobsController controller;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppListSection(
header: job.name,
separatorIndent: 60,
children: [
AppListTile(
leading: const AppIconBadge(
icon: CupertinoIcons.briefcase_fill,
color: AppColors.accent,
),
title: 'Оклад',
value: formatMoney(job.salaryAmount, currency: job.currency),
showChevron: false,
),
AppListTile(
leading: const AppIconBadge(
icon: CupertinoIcons.calendar,
color: AppColors.systemOrange,
),
title: 'Дни выплат',
value: job.payDays.map((day) => '$day').join(' и '),
showChevron: false,
),
AppListTile(
leading: const AppIconBadge(
icon: CupertinoIcons.arrow_left_right,
color: AppColors.systemGray,
),
title: 'Выходные',
value: job.weekendPolicy.label,
showChevron: false,
),
AppListTile(
title: 'Настроить',
onTap: () => _openActions(context),
),
],
),
if (job.nextPays.isNotEmpty) ...[
const SizedBox(height: AppSpacing.s4),
AppListSection(
header: 'Ближайшие выплаты',
children: [
for (final pay in job.nextPays)
AppListTile(
title: formatDay(pay.date),
subtitle: '${pay.percent.round()}% оклада · ${pay.scheduledDay} числа',
value: formatMoney(pay.amount, currency: job.currency),
showChevron: false,
),
],
),
],
],
);
}
Future<void> _openActions(BuildContext context) async {
final index = await showAppActionSheet(
context: context,
title: job.name,
actions: const [
AppActionSheetAction(label: 'Редактировать', isDefault: true),
AppActionSheetAction(label: 'Удалить', destructive: true),
],
);
if (index == null || !context.mounted) return;
if (index == 0) {
await showJobFormSheet(context: context, controller: controller, initial: job);
return;
}
final confirmed = await showAppAlert(
context: context,
title: 'Удалить «${job.name}»?',
message: 'График выплат тоже будет удалён.',
confirmLabel: 'Удалить',
cancelLabel: 'Отмена',
destructive: true,
);
if (confirmed != true) return;
final error = await controller.delete(job.id);
if (context.mounted) {
await showAppToast(context, message: error ?? 'Работа удалена');
}
}
}