Adds category type functionality

Implements category type selection (income/expense) during category creation/editing.

This enhancement introduces a switch to define categories as either income or expense, providing more granular control over financial tracking.
It also includes localization support for new strings ("Icon", "Category type", "Add category").
A random color and icon are assigned to new categories for better user experience.
Finally, uses AnimatedList for categories in the list page.
This commit is contained in:
2025-06-30 17:48:54 +03:00
parent 6da0da45b7
commit 368c62f3fb
11 changed files with 344 additions and 89 deletions
+28
View File
@@ -0,0 +1,28 @@
# Project: Budget App
## General Instructions:
- Это проект на Flutter используй только его
- Комментируй в коде каждое изменение, которое ты делаешь, что бы мне было понятно и я учился на этом.
- Комментарии и твои ответы должны быть на русском языке
- When generating new Flutter code, please follow the existing coding style.
- Цветовая палитра черно-белая
- Все настройки цветов выноси в тему
- Все элементы должны разбиваться на мелкие и иметь логичную структуру по попкам
- Весь текст должен иметь локализацию чрезе flutter_localizations. Смотри папку l10n
- Разработка ведется под windows, ты можешь исопльзовать его консольные команды
## Coding Style:
- Interface names should be prefixed with `I` (e.g., `IUserService`).
- Private class members should be prefixed with an underscore (`_`).
- Учитывай, что в проекте используется bloc cubit архитектура
## Role
- You are a Flutter assistant that helps users write more efficient and optimizable Flutter code.
- You specialize in identifying patterns that enable Flutter Compiler to automatically apply optimizations, reducing unnecessary re-renders and improving application performance.
## Follow these guidelines in all code you produce and suggest
- Prefer composition and small components: Break down UI into small, reusable components rather than writing large monolithic components. The code you generate should promote clarity and reusability by composing components together.
- Design for a good user experience - Provide clear, minimal, and non-blocking UI states. When data is loading, show lightweight placeholders (e.g., skeleton screens) rather than intrusive spinners everywhere. Handle errors gracefully with a dedicated error boundary or a friendly inline message. Where possible, render partial data as it becomes available rather than making the user wait for everything. Suspense allows you to declare the loading states in your component tree in a natural way, preventing “flash” states and improving perceived performance.
-
+4 -1
View File
@@ -36,11 +36,14 @@
"cancel": "Cancel",
"save": "Save",
"tag": "Tag",
"icon": "Icon",
"smsPageTitle": "SMS Messages",
"smsPermissionDenied": "SMS permission is required",
"editCategories": "Edit Categories",
"editCategoriesDescription": "Add, edit, or delete categories",
"color": "Color",
"chooseIcon": "Pick icon",
"chooseIconHint": "Search"
"chooseIconHint": "Search",
"categoryType": "Category type",
"addCategory": "Add category"
}
+18
View File
@@ -314,6 +314,12 @@ abstract class AppLocalizations {
/// **'Tag'**
String get tag;
/// No description provided for @icon.
///
/// In en, this message translates to:
/// **'Icon'**
String get icon;
/// No description provided for @smsPageTitle.
///
/// In en, this message translates to:
@@ -355,6 +361,18 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Search'**
String get chooseIconHint;
/// No description provided for @categoryType.
///
/// In en, this message translates to:
/// **'Category type'**
String get categoryType;
/// No description provided for @addCategory.
///
/// In en, this message translates to:
/// **'Add category'**
String get addCategory;
}
class _AppLocalizationsDelegate
+9
View File
@@ -118,6 +118,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tag => 'Tag';
@override
String get icon => 'Icon';
@override
String get smsPageTitle => 'SMS Messages';
@@ -138,4 +141,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chooseIconHint => 'Search';
@override
String get categoryType => 'Category type';
@override
String get addCategory => 'Add category';
}
+9
View File
@@ -119,6 +119,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get tag => 'Тег';
@override
String get icon => 'Иконка';
@override
String get smsPageTitle => 'SMS Сообщения';
@@ -140,4 +143,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get chooseIconHint => 'Поиск по анлгийскому наименованию';
@override
String get categoryType => 'Тип категории';
@override
String get addCategory => 'Добавить категорию';
}
+4 -1
View File
@@ -36,11 +36,14 @@
"cancel": "Отмена",
"save": "Сохранить",
"tag": "Тег",
"icon": "Иконка",
"smsPageTitle": "SMS Сообщения",
"smsPermissionDenied": "Необходимо разрешение на чтение SMS",
"editCategories": "Редактировать категории",
"editCategoriesDescription": "Добавляйте, редактируйте или удаляйте категории",
"color": "Цвет",
"chooseIcon": "Выберите иконку",
"chooseIconHint": "Поиск по анлгийскому наименованию"
"chooseIconHint": "Поиск по анлгийскому наименованию",
"categoryType": "Тип категории",
"addCategory": "Добавить категорию"
}
+41 -6
View File
@@ -1,5 +1,6 @@
import 'package:budget_app/l10n/app_localizations.dart';
import 'package:budget_app/models/category.dart';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import 'package:flutter_iconpicker/Models/configuration.dart';
@@ -7,7 +8,7 @@ import 'package:flutter_iconpicker/flutter_iconpicker.dart';
class CategoryEditPage extends StatefulWidget {
final Category? category;
final Function(String, Color, IconData) onSave;
final Function(String, Color, IconData, bool) onSave;
const CategoryEditPage({super.key, this.category, required this.onSave});
@@ -20,13 +21,28 @@ class _CategoryEditPageState extends State<CategoryEditPage> {
late String _name;
late Color _color;
late IconData _icon;
late bool _isIncome; // Добавляем состояние для типа категории (доход/расход)
@override
void initState() {
super.initState();
_name = widget.category?.name ?? '';
_color = widget.category?.color ?? Colors.blue;
_icon = widget.category?.icon ?? Icons.ac_unit;
// Генерация случайного цвета из всей палитры для новой категории
_color = widget.category?.color ?? Color((Random().nextDouble() * 0xFFFFFF).toInt()).withOpacity(1.0);
// Генерация случайной иконки из предопределённого набора для новой категории
_icon = widget.category?.icon ?? [
Icons.attach_money,
Icons.shopping_cart,
Icons.food_bank,
Icons.home,
Icons.directions_car,
Icons.medical_services,
Icons.school,
Icons.work,
Icons.credit_card,
Icons.savings,
][Random().nextInt(10)];
_isIncome = widget.category?.isIncome ?? false; // Инициализируем значение типа категории
}
@override
@@ -36,7 +52,7 @@ class _CategoryEditPageState extends State<CategoryEditPage> {
appBar: AppBar(
title: Text(
widget.category == null
? localizations.addTransactionButton
? localizations.addCategory
: localizations.editCategories,
),
),
@@ -100,7 +116,7 @@ class _CategoryEditPageState extends State<CategoryEditPage> {
const SizedBox(height: 20),
Row(
children: [
Text(localizations.tag),
Text(localizations.icon),
const SizedBox(width: 10),
GestureDetector(
onTap: () async {
@@ -148,11 +164,30 @@ class _CategoryEditPageState extends State<CategoryEditPage> {
],
),
const SizedBox(height: 20),
// Добавляем выбор типа категории (доход/расход)
Row(
children: [
Text(localizations.categoryType),
const SizedBox(width: 10),
Switch(
value: _isIncome,
onChanged: (value) {
setState(() {
_isIncome = value;
});
},
),
const SizedBox(width: 10),
Text(_isIncome ? localizations.income : localizations.expense),
],
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
widget.onSave(_name, _color, _icon);
// Добавляем параметр _isIncome в вызов onSave
widget.onSave(_name, _color, _icon, _isIncome);
Navigator.pop(context);
}
},
+97 -81
View File
@@ -2,98 +2,114 @@ import 'package:budget_app/l10n/app_localizations.dart';
import 'package:budget_app/logic/category/category_cubit.dart';
import 'package:budget_app/models/category.dart';
import 'package:budget_app/pages/category/category_edit_page.dart';
import 'package:budget_app/pages/category/widgets/category_list_item.dart';
import 'package:budget_app/pages/category/widgets/add_category_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart'; // Импортируем GetIt
import 'package:get_it/get_it.dart';
class CategoryListPage extends StatelessWidget {
class CategoryListPage extends StatefulWidget {
const CategoryListPage({super.key});
@override
State<CategoryListPage> createState() => _CategoryListPageState();
}
class _CategoryListPageState extends State<CategoryListPage> {
final GlobalKey<AnimatedListState> _listKey = GlobalKey();
@override
Widget build(BuildContext context) {
final localizations = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(localizations.editCategories)),
body: BlocProvider(
create: (context) =>
GetIt.instance<CategoryCubit>()
..loadCategories(), // Получаем CategoryCubit из GetIt
child: BlocBuilder<CategoryCubit, List<Category>>(
builder: (context, categories) {
return ListView.builder(
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: category.color,
child: Icon(category.icon, color: Colors.white),
),
title: Text(category.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryEditPage(
category: category,
onSave: (name, color, icon) {
final updatedCategory = category.copyWith(
name: name,
color: color,
icon: icon,
);
context.read<CategoryCubit>().updateCategory(
updatedCategory,
); // Обновляем категорию по ее id
},
),
),
);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
context.read<CategoryCubit>().deleteCategory(
category.id,
); // Удаляем категорию по ее id
},
),
],
),
);
},
// Оборачиваем Scaffold в BlocProvider, чтобы все дочерние виджеты,
// включая floatingActionButton, имели доступ к CategoryCubit.
return BlocProvider(
create: (context) => GetIt.instance<CategoryCubit>()..loadCategories(),
// Используем Builder для получения нового контекста, который "видит" BlocProvider.
child: Builder(builder: (context) {
final localizations = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(localizations.editCategories)),
body: BlocBuilder<CategoryCubit, List<Category>>(
builder: (context, categories) {
return AnimatedList(
key: _listKey,
initialItemCount: categories.length,
itemBuilder: (context, index, animation) {
final category = categories[index];
return SizeTransition(
sizeFactor: animation,
child: CategoryListItem(
category: category,
onEdit: () => _editCategory(context, category),
onDelete: () =>
_deleteCategory(context, category, index),
),
);
},
);
},
),
floatingActionButton: AddCategoryButton(
// Теперь этот context имеет доступ к CategoryCubit
onPressed: () => _addCategory(context),
),
);
}),
);
}
void _addCategory(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryEditPage(
onSave: (name, color, icon, isIncome) {
final newCategory = Category(
name: name,
color: color,
icon: icon,
isIncome: isIncome,
userId: 'default_user',
);
context.read<CategoryCubit>().addCategory(newCategory);
_listKey.currentState?.insertItem(0);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryEditPage(
onSave: (name, color, icon) {
final newCategory = Category(
name: name,
color: color,
icon: icon,
isIncome: false, // по умолчанию false
userId: 'default_user',
// userId будет установлен в CategoryCubit
);
context.read<CategoryCubit>().addCategory(newCategory);
},
),
),
);
},
child: const Icon(Icons.add),
);
}
void _editCategory(BuildContext context, Category category) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CategoryEditPage(
category: category,
onSave: (name, color, icon, isIncome) {
final updatedCategory = category.copyWith(
name: name,
color: color,
icon: icon,
isIncome: isIncome,
);
context.read<CategoryCubit>().updateCategory(updatedCategory);
},
),
),
);
}
void _deleteCategory(BuildContext context, Category category, int index) {
context.read<CategoryCubit>().deleteCategory(category.id);
_listKey.currentState?.removeItem(
index,
(context, animation) => SizeTransition(
sizeFactor: animation,
child: CategoryListItem(
category: category,
onEdit: () {},
onDelete: () {},
),
),
);
}
@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:budget_app/theme/app_theme.dart';
/// Плавающая кнопка добавления категории с анимацией
class AddCategoryButton extends StatelessWidget {
final VoidCallback onPressed;
const AddCategoryButton({
super.key,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: onPressed,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
elevation: 4,
child: const Icon(Icons.add),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:budget_app/theme/app_theme.dart';
import 'package:budget_app/theme/custom_colors.dart';
/// Виджет кнопок действий для категории (редактировать/удалить)
class CategoryActions extends StatelessWidget {
final VoidCallback onEdit;
final VoidCallback onDelete;
const CategoryActions({
super.key,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).extension<CustomColors>();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.edit, color: colors?.accent),
onPressed: onEdit,
splashRadius: 20,
),
IconButton(
icon: Icon(Icons.delete, color: colors?.accent),
onPressed: onDelete,
splashRadius: 20,
),
],
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:budget_app/models/category.dart';
import 'package:budget_app/theme/app_theme.dart';
import 'package:budget_app/theme/custom_colors.dart';
import './category_actions.dart';
/// Виджет элемента списка категорий с улучшенным дизайном
class CategoryListItem extends StatelessWidget {
final Category category;
final VoidCallback onEdit;
final VoidCallback onDelete;
const CategoryListItem({
super.key,
required this.category,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.extension<CustomColors>();
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
// Аватар категории
CircleAvatar(
backgroundColor: category.color.withOpacity(0.2),
radius: 24,
child: Icon(
category.icon,
color: colors?.accent,
size: 20,
),
),
const SizedBox(width: 12),
// Информация о категории
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
category.name,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
category.isIncome ? 'Доход' : 'Расход',
style: theme.textTheme.bodySmall?.copyWith(
color: colors?.unselectedIcon,
),
),
],
),
),
// Кнопки действий
CategoryActions(
onEdit: onEdit,
onDelete: onDelete,
),
],
),
),
);
}
}