Improves transaction input and display

Adds a tag selection to transaction dialog and refines the display of transactions.

The changes introduce the ability to tag transactions, improving categorization and filtering.
It also modifies the transaction input dialog to include time, as well as refines the appearance of transaction items on the home page by using a custom widget wrapped in a card.

The UI of the summary widget is also improved by using theme colors and increasing the overall visibility.
This commit is contained in:
2025-06-30 10:26:27 +03:00
parent 4ceb710782
commit 5ff11c5449
11 changed files with 359 additions and 142 deletions
+3 -2
View File
@@ -34,5 +34,6 @@
"requiredField": "Required field",
"invalidNumber": "Invalid number",
"cancel": "Cancel",
"save": "Save"
}
"save": "Save",
"tag": "Tag"
}
+6
View File
@@ -307,6 +307,12 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @tag.
///
/// In en, this message translates to:
/// **'Tag'**
String get tag;
}
class _AppLocalizationsDelegate
+3
View File
@@ -114,4 +114,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get save => 'Save';
@override
String get tag => 'Tag';
}
+3
View File
@@ -115,4 +115,7 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get save => 'Сохранить';
@override
String get tag => 'Тег';
}
+3 -2
View File
@@ -34,5 +34,6 @@
"requiredField": "Обязательное поле",
"invalidNumber": "Неверный формат числа",
"cancel": "Отмена",
"save": "Сохранить"
}
"save": "Сохранить",
"tag": "Тег"
}
+19 -24
View File
@@ -9,7 +9,8 @@ import '../../services/user_service.dart';
import '../home/widgets/summary_widget.dart'; // Импортируем новый виджет сводки
import '../reports_page.dart'; // Импортируем новую страницу отчетов
import '../settings_page.dart';
import 'widgets/add_transaction_dialog.dart'; // Добавлен импорт для UserService
import 'widgets/add_transaction_dialog.dart';
import 'widgets/transaction_item.dart'; // Импортируем новый виджет для элемента транзакции
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -160,29 +161,23 @@ class TransactionsPage extends StatelessWidget {
child: Text(localizations.noTransactionsText),
) // Локализованный текст
else
ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.transactions.length,
itemBuilder: (context, index) {
final transaction = state.transactions[index];
return ListTile(
leading: Icon(
transaction.category.icon,
color: transaction.category.color,
),
title: Text(transaction.vendor),
subtitle: Text(transaction.category.name),
trailing: Text(
'${transaction.amount.toStringAsFixed(2)} ${transaction.currency}',
style: TextStyle(
color: transaction.isIncome
? Colors.green
: Colors.red,
),
),
);
},
// Оборачиваем список транзакций в Card для создания единого блока
Card(
margin: const EdgeInsets.all(16.0),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
child: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: state.transactions.length,
itemBuilder: (context, index) {
final transaction = state.transactions[index];
// Возвращаем новый кастомный виджет для транзакции
return TransactionItem(transaction: transaction);
},
),
),
],
);
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:intl/intl.dart';
@@ -6,7 +7,9 @@ import 'package:intl/intl.dart';
import '../../../l10n/app_localizations.dart';
import '../../../logic/auth/auth_bloc.dart';
import '../../../logic/transaction/transaction_bloc.dart';
import '../../../data/repositories/interfaces/itag_repository.dart'; // Импортируем интерфейс репозитория тегов
import '../../../models/category.dart';
import '../../../models/tag.dart'; // Импортируем модель тега
import '../../../models/transaction_record.dart';
import '../../../services/user_service.dart';
import '../../../utils/category_utils.dart';
@@ -26,12 +29,16 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
bool _isIncome = false;
Category? _selectedCategory;
DateTime _selectedDate = DateTime.now();
// Комментарий: Добавляем состояние для выбранного тега.
Tag? _selectedTag;
// Комментарий: Заменяем _selectedDate на _selectedDateTime для хранения даты и времени.
DateTime _selectedDateTime = DateTime.now();
@override
void initState() {
super.initState();
_dateController.text = DateFormat.yMd().format(_selectedDate);
// Комментарий: Устанавливаем начальное значение с датой и временем.
_dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime);
}
@override
@@ -42,19 +49,37 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
super.dispose();
}
Future<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
// Комментарий: Этот метод теперь обрабатывает выбор и даты, и времени.
Future<void> _selectDateTime(BuildContext context) async {
final DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDate,
initialDate: _selectedDateTime,
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
_dateController.text = DateFormat.yMd().format(_selectedDate);
});
}
// Комментарий: Если пользователь не выбрал дату, выходим из функции.
if (pickedDate == null) return;
// ignore: use_build_context_synchronously
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_selectedDateTime),
);
// Комментарий: Если пользователь не выбрал время, выходим из функции.
if (pickedTime == null) return;
// Комментарий: Обновляем состояние с новой датой и временем.
setState(() {
_selectedDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
// Комментарий: Обновляем текстовое поле с отформатированной датой и временем.
_dateController.text = DateFormat.yMd().add_Hm().format(_selectedDateTime);
});
}
void _submitForm() {
@@ -93,7 +118,10 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
amount: amount,
vendor: _vendorController.text,
category: _selectedCategory!,
dateTime: _selectedDate,
// Комментарий: Используем _selectedDateTime для сохранения точного времени транзакции.
dateTime: _selectedDateTime,
// Комментарий: Добавляем выбранный тег в транзакцию.
tag: _selectedTag,
currency:
GetIt.instance<UserService>().currentUser?.defaultCurrency ?? 'USD',
userId: authState.user.id,
@@ -135,7 +163,13 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
TextFormField(
controller: _amountController,
decoration: InputDecoration(labelText: localizations.amount),
keyboardType: TextInputType.number,
// Комментарий: Устанавливаем числовую клавиатуру с поддержкой десятичных чисел.
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
// Комментарий: Добавляем фильтр для ввода только чисел и одной точки.
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return localizations.requiredField;
@@ -173,13 +207,46 @@ class _AddTransactionDialogState extends State<AddTransactionDialog> {
validator: (value) =>
value == null ? localizations.requiredField : null,
),
// Комментарий: Добавляем выпадающий список для выбора тега.
// Он будет загружать теги асинхронно для текущего пользователя.
FutureBuilder<List<Tag>>(
future: GetIt.instance<ITagRepository>().getAllByUser(
(context.read<AuthBloc>().state as AuthAuthenticated).user.id,
),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final tags = snapshot.data ?? [];
return DropdownButtonFormField<Tag>(
value: _selectedTag,
decoration: InputDecoration(labelText: localizations.tag),
items: tags.map((Tag tag) {
return DropdownMenuItem<Tag>(
value: tag,
child: Text(tag.name),
);
}).toList(),
onChanged: (Tag? newValue) {
setState(() {
_selectedTag = newValue;
});
},
// Комментарий: Тег не является обязательным полем.
);
},
),
TextFormField(
controller: _dateController,
decoration: InputDecoration(
labelText: localizations.date,
suffixIcon: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () => _selectDate(context),
// Комментарий: Вызываем новый метод для выбора даты и времени.
onPressed: () => _selectDateTime(context),
),
),
readOnly: true,
+97 -92
View File
@@ -27,82 +27,81 @@ class SummaryWidget extends StatelessWidget {
final customColors = Theme.of(context).extension<CustomColors>()!;
final theme = Theme.of(context);
// Определяем цвет для градиента в зависимости от темы.
final isDarkMode = theme.brightness == Brightness.dark;
final gradientColor = isDarkMode ? Colors.grey[800]! : Colors.grey[200]!;
// Используем Card для придания виджету тени и скругленных углов.
// Используем Card для создания тени и скругленных углов.
// Увеличиваем радиус скругления и elevation для более выраженного эффекта.
return Card(
elevation: 4.0,
elevation: 8.0,
margin: const EdgeInsets.all(16.0),
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
borderRadius: BorderRadius.circular(16.0),
),
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
// Добавляем градиентный фон для более современного вида.
gradient: LinearGradient(
colors: [
theme.colorScheme.surface,
gradientColor,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0, horizontal: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Центральный блок с балансом.
Center(
child: Column(
// Используем акцентный цвет для заголовка.
Text(
localizations.balance,
style: theme.textTheme.titleLarge?.copyWith(
color: customColors.accent,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8.0),
// Анимированное отображение общей суммы.
// Увеличиваем шрифт для большей наглядности.
AnimatedDigitWidget(
value: balance,
fractionDigits: 2,
textStyle: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
suffix: ' $currencySymbol',
),
const SizedBox(height: 24.0),
// Добавляем разделитель с цветом из темы.
Divider(
height: 1,
thickness: 1,
color: customColors.divider,
),
const SizedBox(height: 24.0),
// Разделение на доходы и расходы.
// Используем IntrinsicHeight для выравнивания высоты дочерних элементов.
IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// Заголовок секции.
Text(
localizations.balance,
style: theme.textTheme.titleLarge,
// Виджет для отображения доходов.
_buildIncomeExpense(
context,
localizations.income,
income,
customColors.income!, // Используем цвет дохода из темы
Icons.arrow_circle_up_outlined,
),
const SizedBox(height: 8.0),
// Анимированное отображение общей суммы.
AnimatedDigitWidget(
value: balance,
fractionDigits: 2,
textStyle: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
suffix: ' $currencySymbol',
// Вертикальный разделитель.
VerticalDivider(
width: 1,
thickness: 1,
color: customColors.divider,
),
// Виджет для отображения расходов.
_buildIncomeExpense(
context,
localizations.expense,
expense,
customColors.expense!, // Используем цвет расхода из темы
Icons.arrow_circle_down_outlined,
),
],
),
),
const SizedBox(height: 16.0),
// Добавляем разделитель для лучшей структуры.
const Divider(height: 24.0),
// Разделение на доходы и расходы.
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// Виджет для отображения доходов.
_buildIncomeExpense(
context,
localizations.income,
income,
customColors.income!, // Используем цвет дохода из темы
Icons.arrow_upward,
),
// Виджет для отображения расходов.
_buildIncomeExpense(
context,
localizations.expense,
expense,
customColors.expense!, // Используем цвет расхода из темы
Icons.arrow_downward,
),
],
),
],
),
),
@@ -110,37 +109,43 @@ class SummaryWidget extends StatelessWidget {
}
// Вспомогательный метод для создания виджета дохода/расхода.
// Изменен для более компактного и чистого вида.
Widget _buildIncomeExpense(
BuildContext context, String title, double amount, Color color, IconData icon) {
return Row(
children: [
// Иконка для наглядности.
Icon(
icon,
color: color,
size: 28.0,
),
const SizedBox(width: 8.0),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
BuildContext context,
String title,
double amount,
Color color,
IconData icon,
) {
final theme = Theme.of(context);
return Expanded(
child: Column(
children: [
// Иконка для наглядности.
Icon(
icon,
color: color,
size: 32.0,
),
const SizedBox(height: 8.0),
// Заголовок.
Text(
title,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 4.0),
// Анимированное отображение суммы.
// Используем основной цвет текста для суммы.
AnimatedDigitWidget(
value: amount,
fractionDigits: 2,
textStyle: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
const SizedBox(height: 4.0),
// Анимированное отображение суммы.
AnimatedDigitWidget(
value: amount,
fractionDigits: 2,
textStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
],
),
);
}
}
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/transaction_record.dart';
/// Виджет для отображения одной транзакции в списке.
///
/// Этот виджет представляет собой карточку с подробной информацией о транзакции,
/// включая поставщика, сумму, категорию, тег и дату.
class TransactionItem extends StatelessWidget {
final TransactionRecord transaction;
const TransactionItem({super.key, required this.transaction});
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
final theme = Theme.of(context);
// Форматируем дату в соответствии с локалью
final formattedDate = DateFormat.yMMMd(localizations.localeName).format(transaction.dateTime);
// Убираем Card, так как обертка будет в родительском виджете.
// Добавляем разделитель и уменьшаем отступы для компактности.
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Верхняя строка: Название поставщика и сумма
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Название поставщика
Expanded(
child: Text(
transaction.vendor,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
// Сумма транзакции
// Используем .abs() чтобы избежать двойного минуса для расходов
Text(
'${transaction.isIncome ? '+' : '-'}${transaction.amount.abs().toStringAsFixed(2)} ${transaction.currency}',
style: theme.textTheme.titleSmall?.copyWith(
color: transaction.isIncome
? theme.colorScheme.primary
: theme.colorScheme.error,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8.0),
// Средняя строка: Категория и тег
Row(
children: [
// Иконка категории
Icon(
transaction.category.icon,
color: transaction.category.color,
size: 20.0, // Уменьшаем размер иконки
),
const SizedBox(width: 8.0),
// Название категории и тега
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
transaction.category.name,
style: theme.textTheme.bodySmall,
),
if (transaction.tag != null)
Text(
'#${transaction.tag!.name}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
),
// Дата в правом углу
Text(
formattedDate,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
],
),
),
// Разделитель между транзакциями
const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16),
],
);
}
}
+24 -7
View File
@@ -37,10 +37,19 @@ class AppTheme {
),
),
),
extensions: const <ThemeExtension<dynamic>>[
// Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону.
dropdownMenuTheme: DropdownMenuThemeData(
menuStyle: MenuStyle(
backgroundColor: MaterialStateProperty.all(Colors.white),
),
),
// Изменяем цвета в соответствии с черно-белой палитрой
extensions: <ThemeExtension<dynamic>>[
CustomColors(
income: Colors.green,
expense: Colors.red,
income: Colors.grey[800]!, // Темно-серый для доходов
expense: Colors.grey[600]!, // Серый для расходов
divider: Colors.grey[300]!, // Светло-серый для разделителей
accent: Colors.black, // Черный для акцентов
),
],
);
@@ -81,13 +90,21 @@ class AppTheme {
),
),
),
extensions: const <ThemeExtension<dynamic>>[
// Комментарий: Добавляем тему для выпадающих меню, чтобы цвет соответствовал фону.
dropdownMenuTheme: DropdownMenuThemeData(
menuStyle: MenuStyle(
backgroundColor: MaterialStateProperty.all(Colors.grey[800]),
),
),
// Изменяем цвета в соответствии с черно-белой палитрой
extensions: <ThemeExtension<dynamic>>[
CustomColors(
income: Colors.greenAccent,
expense: Colors.redAccent,
income: Colors.grey[300]!, // Светло-серый для доходов
expense: Colors.grey[500]!, // Серый для расходов
divider: Colors.grey[700]!, // Темно-серый для разделителей
accent: Colors.white, // Белый для акцентов
),
],
);
}
}
+14 -1
View File
@@ -7,16 +7,27 @@ class CustomColors extends ThemeExtension<CustomColors> {
const CustomColors({
required this.income,
required this.expense,
this.divider, // Добавлен цвет для разделителей
this.accent, // Добавлен цвет для акцентов
});
final Color? income;
final Color? expense;
final Color? divider;
final Color? accent;
@override
CustomColors copyWith({Color? income, Color? expense}) {
CustomColors copyWith({
Color? income,
Color? expense,
Color? divider,
Color? accent,
}) {
return CustomColors(
income: income ?? this.income,
expense: expense ?? this.expense,
divider: divider ?? this.divider,
accent: accent ?? this.accent,
);
}
@@ -28,6 +39,8 @@ class CustomColors extends ThemeExtension<CustomColors> {
return CustomColors(
income: Color.lerp(income, other.income, t),
expense: Color.lerp(expense, other.expense, t),
divider: Color.lerp(divider, other.divider, t),
accent: Color.lerp(accent, other.accent, t),
);
}
}