feat(proj): init
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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/expense.dart';
|
||||
import 'package:please_pay_me/data/repositories/repositories.dart';
|
||||
|
||||
enum JournalScope {
|
||||
current('Текущий'),
|
||||
all('Все бюджеты');
|
||||
|
||||
const JournalScope(this.label);
|
||||
|
||||
final String label;
|
||||
}
|
||||
|
||||
class ExpenseGroup {
|
||||
const ExpenseGroup({required this.day, required this.items});
|
||||
|
||||
final DateTime day;
|
||||
final List<Expense> items;
|
||||
|
||||
double get total => items.fold<double>(0, (sum, expense) => sum + expense.amount);
|
||||
}
|
||||
|
||||
/// Paginated journal of operations with day grouping.
|
||||
class JournalController extends ChangeNotifier {
|
||||
JournalController({required ExpenseRepository expenses, this.pageSize = 20})
|
||||
: _expenses = expenses;
|
||||
|
||||
final ExpenseRepository _expenses;
|
||||
final int pageSize;
|
||||
|
||||
AsyncValue<ExpensesPage> _state = const AsyncValue.loading();
|
||||
JournalScope _scope = JournalScope.current;
|
||||
int? _budgetId;
|
||||
bool _loadingMore = false;
|
||||
|
||||
AsyncValue<ExpensesPage> get state => _state;
|
||||
JournalScope get scope => _scope;
|
||||
bool get isLoadingMore => _loadingMore;
|
||||
|
||||
List<Expense> get items => _state.valueOrNull?.items ?? const [];
|
||||
|
||||
bool get hasMore => _state.valueOrNull?.hasMore ?? false;
|
||||
|
||||
double get totalSum => _state.valueOrNull?.totalSum ?? 0;
|
||||
|
||||
/// Operations bucketed by day, newest first — the journal renders one
|
||||
/// inset-grouped section per bucket.
|
||||
List<ExpenseGroup> get groups {
|
||||
final buckets = <DateTime, List<Expense>>{};
|
||||
for (final expense in items) {
|
||||
final day = DateTime(expense.spentAt.year, expense.spentAt.month, expense.spentAt.day);
|
||||
buckets.putIfAbsent(day, () => []).add(expense);
|
||||
}
|
||||
|
||||
final days = buckets.keys.toList()..sort((a, b) => b.compareTo(a));
|
||||
return [for (final day in days) ExpenseGroup(day: day, items: buckets[day]!)];
|
||||
}
|
||||
|
||||
void bindBudget(int? budgetId) {
|
||||
if (_budgetId == budgetId) return;
|
||||
_budgetId = budgetId;
|
||||
load(silent: true);
|
||||
}
|
||||
|
||||
Future<void> setScope(JournalScope scope) async {
|
||||
if (_scope == scope) return;
|
||||
_scope = scope;
|
||||
notifyListeners();
|
||||
await load();
|
||||
}
|
||||
|
||||
Future<void> load({bool silent = false}) async {
|
||||
if (!silent) {
|
||||
_state = const AsyncValue.loading();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
try {
|
||||
_state = AsyncValue.data(await _fetch(1));
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
final current = _state.valueOrNull;
|
||||
if (current == null || !current.hasMore || _loadingMore) return;
|
||||
|
||||
_loadingMore = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final next = await _fetch(current.page + 1);
|
||||
_state = AsyncValue.data(
|
||||
next.copyWithItems([...current.items, ...next.items]),
|
||||
);
|
||||
} on ApiException catch (error) {
|
||||
_state = AsyncValue.error(error.message);
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ExpensesPage> _fetch(int page) {
|
||||
return _expenses.page(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
budgetId: _scope == JournalScope.all ? null : _budgetId,
|
||||
all: _scope == JournalScope.all,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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_actions.dart';
|
||||
import 'package:please_pay_me/features/journal/journal_controller.dart';
|
||||
import 'package:please_pay_me/theme/theme.dart';
|
||||
import 'package:please_pay_me/ui/ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Operations grouped by day, with current-budget / all-budgets scope.
|
||||
class JournalScreen extends StatelessWidget {
|
||||
const JournalScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final journal = context.watch<JournalController>();
|
||||
final budgets = context.watch<BudgetsController>();
|
||||
|
||||
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: budgets.hasBudgets
|
||||
? () => showExpenseFormSheet(context: context, controller: budgets)
|
||||
: null,
|
||||
child: const AppIcon(CupertinoIcons.add_circled, color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
CupertinoSliverRefreshControl(onRefresh: () => journal.load(silent: true)),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: AppSpacing.s2, bottom: AppSpacing.s4),
|
||||
child: AppSegmentedControl(
|
||||
labels: JournalScope.values.map((scope) => scope.label).toList(),
|
||||
index: JournalScope.values.indexOf(journal.scope),
|
||||
onChanged: (index) => journal.setScope(JournalScope.values[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: journal.state.map(
|
||||
loading: () => AppListSection(
|
||||
children: List.generate(4, (_) => const AppSkeletonRow(hasLeading: false)),
|
||||
),
|
||||
error: (message) => AppErrorView(message: message, onRetry: journal.load),
|
||||
data: (_) => _JournalBody(journal: journal),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.s7)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JournalBody extends StatelessWidget {
|
||||
const _JournalBody({required this.journal});
|
||||
|
||||
final JournalController journal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = journal.groups;
|
||||
|
||||
if (groups.isEmpty) {
|
||||
final budgets = context.read<BudgetsController>();
|
||||
return AppEmptyState(
|
||||
icon: CupertinoIcons.doc_text,
|
||||
title: 'Операций пока нет',
|
||||
message: journal.scope == JournalScope.all
|
||||
? 'Как только появится первая трата, она появится здесь.'
|
||||
: 'В текущем бюджете ещё ничего не потрачено.',
|
||||
actionLabel: budgets.hasBudgets ? 'Добавить трату' : null,
|
||||
onAction: budgets.hasBudgets
|
||||
? () => showExpenseFormSheet(context: context, controller: budgets)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppCard(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppText.footnote('Всего операций'),
|
||||
AppText.title('${journal.state.valueOrNull?.totalCount ?? 0}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppText.footnote('Сумма'),
|
||||
AppText.title(formatMoney(journal.totalSum)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
for (final group in groups) ...[
|
||||
AppListSection(
|
||||
header: formatRelativeDay(group.day),
|
||||
footer: 'Итого за день: ${formatMoney(group.total)}',
|
||||
children: [
|
||||
for (final expense in group.items)
|
||||
AppListTile(
|
||||
title: expenseTitle(expense.note),
|
||||
subtitle: formatWeekday(expense.spentAt),
|
||||
value: expenseAmount(expense.amount),
|
||||
showChevron: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.s5),
|
||||
],
|
||||
if (journal.hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.gutter),
|
||||
child: AppButton(
|
||||
label: 'Показать ещё',
|
||||
style: AppButtonStyle.gray,
|
||||
loading: journal.isLoadingMore,
|
||||
onPressed: journal.loadMore,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user