feat: add category creation dialog and integration
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// ignore_for_file: unused_import // Часто генерируется при условном импорте
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart' as c;
|
||||
import 'package:flutter/material.dart' as c; // Use alias 'c' for flutter material
|
||||
// Условный импорт бэкенда базы данных
|
||||
// Выбирает реализацию connect() в зависимости от платформы
|
||||
import 'database_connection/connection.dart' // Базовый импорт
|
||||
@@ -24,8 +24,8 @@ enum TransactionType { income, expense }
|
||||
class Categories extends Table { // Changed class name to plural 'Categories'
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get name => text().unique()();
|
||||
TextColumn get icon => text()();
|
||||
IntColumn get color => integer()();
|
||||
TextColumn get icon => text()(); // Store icon name (e.g., 'shopping_cart_outlined')
|
||||
IntColumn get color => integer()(); // Store color value (e.g., Colors.green.value)
|
||||
}
|
||||
|
||||
// Определение таблицы Transactions
|
||||
@@ -67,12 +67,26 @@ class AppDatabase extends _$AppDatabase {
|
||||
// Drift автоматически обработает добавление новых таблиц (Categories)
|
||||
// при обновлении до версии 4.
|
||||
// Нам нужно только обработать специфичные изменения, как добавление колонки type.
|
||||
if (from < 4) { // Check if upgrading from a version before 4
|
||||
// Check if the 'type' column exists before trying to add it
|
||||
// This requires a more complex check, usually involving inspecting the schema.
|
||||
// For simplicity, we assume if version is < 4, the column might be missing.
|
||||
// A safer approach involves querying PRAGMA table_info(transactions);
|
||||
// However, Drift's default migration handles adding columns well.
|
||||
// Let's ensure the Categories table is created if upgrading from very old versions.
|
||||
await m.createTable(categories); // Ensure categories table exists
|
||||
// Add the 'type' column if it doesn't exist (Drift might handle this, but explicit is safer)
|
||||
// We'll rely on Drift's default behavior for adding the column here.
|
||||
// If specific default values or constraints were needed during upgrade,
|
||||
// more complex logic would be required.
|
||||
}
|
||||
if (from == 1) {
|
||||
// Миграция с версии 1 на 2: добавляем колонку type
|
||||
await m.addColumn(transactions, transactions.type);
|
||||
// Example: If migrating specifically from 1, maybe add the type column
|
||||
// await m.addColumn(transactions, transactions.type);
|
||||
// But the check `from < 4` above is more general if relying on Drift's auto-migration.
|
||||
}
|
||||
// Add more migration steps for future versions here
|
||||
// if (from < 3) { ... }
|
||||
// if (from < 5) { ... }
|
||||
|
||||
// ИЗМЕНЕНО: Удален вызов insertInitialDataIfNeeded отсюда
|
||||
// Не нужно вставлять начальные данные при обновлении существующей БД.
|
||||
@@ -128,7 +142,31 @@ class AppDatabase extends _$AppDatabase {
|
||||
return into(transactions).insert(entry);
|
||||
}
|
||||
|
||||
// --- Методы для работы с категориями и суммами ---
|
||||
// --- Методы для работы с категориями ---
|
||||
|
||||
// Получить все категории в виде потока, упорядоченные по имени
|
||||
// Возвращает Stream<List<CategoryDb>> (CategoryDb - сгенерированный Drift класс)
|
||||
Stream<List<CategoryDb>> watchAllCategoriesDb() {
|
||||
return (select(categories)..orderBy([(c) => OrderingTerm(expression: c.name)])).watch();
|
||||
}
|
||||
|
||||
// Добавить новую категорию
|
||||
// Принимает CategoriesCompanion (сгенерированный Drift)
|
||||
// Возвращает ID вставленной категории
|
||||
Future<int> addCategory(CategoriesCompanion entry) {
|
||||
// Проверяем, что имя, иконка и цвет предоставлены
|
||||
assert(entry.name.present && entry.name.value.isNotEmpty);
|
||||
assert(entry.icon.present); // Icon can be empty string if needed
|
||||
assert(entry.color.present);
|
||||
return into(categories).insert(entry);
|
||||
}
|
||||
|
||||
// Получить категорию по ID (если нужно)
|
||||
Future<CategoryDb?> getCategoryById(int id) {
|
||||
return (select(categories)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
// --- Методы для агрегации и отчетов ---
|
||||
|
||||
// Вычислить и наблюдать за общими суммами по категориям РАСХОДОВ
|
||||
// Возвращает Stream<List<Category>> где Category - это класс модели из '../models/category.dart'
|
||||
@@ -151,6 +189,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
// 3. Преобразуем сгруппированные данные в список объектов Category (модель)
|
||||
return categoryTotals.entries.map((entry) {
|
||||
// Используем утилиту для получения деталей (иконка, цвет) по имени
|
||||
// TODO: В будущем лучше получать детали напрямую из CategoryDb, если они там хранятся
|
||||
final categoryDetails = CategoryUtils.getCategoryDetails(entry.key);
|
||||
return Category( // Это Category из models/category.dart
|
||||
entry.key, // name
|
||||
@@ -201,15 +240,16 @@ class AppDatabase extends _$AppDatabase {
|
||||
await batch((batch) {
|
||||
batch.insertAll(categories, [
|
||||
// Используем CategoriesCompanion (сгенерированный для таблицы Categories)
|
||||
// Используем реальные имена иконок Material Icons и значения цветов
|
||||
CategoriesCompanion.insert(name: 'Groceries', icon: 'shopping_cart_outlined', color: c.Colors.green.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Subscriptions', icon: 'subscriptions_outlined', color: c.Colors.orange.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Restaurant', icon: 'restaurant_menu_outlined', color: c.Colors.red.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Shopping', icon: 'shopping_bag_outlined', color: c.Colors.blue.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Transport', icon: 'directions_bus_filled_outlined', color: c.Colors.purple.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Travel', icon: 'flight_takeoff_outlined', color: c.Colors.cyan.shade400.value),
|
||||
CategoriesCompanion.insert(name: 'Utilities', icon: 'home_outlined', color: c.Colors.teal.shade400.value), // Example: Added Utilities
|
||||
// 'Income' category might not be needed if handled separately, but can be added for consistency
|
||||
CategoriesCompanion.insert(name: 'Income', icon: 'attach_money', color: c.Colors.lightGreenAccent.shade400.value), // Example: Added Income category
|
||||
CategoriesCompanion.insert(name: 'Utilities', icon: 'home_outlined', color: c.Colors.teal.shade400.value),
|
||||
// 'Income' category - use a specific icon and color
|
||||
CategoriesCompanion.insert(name: 'Income', icon: 'attach_money', color: c.Colors.lightGreenAccent.shade700.value), // Changed color slightly
|
||||
]);
|
||||
});
|
||||
print("Initial categories inserted.");
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../widgets/transaction_list_item.dart';
|
||||
import '../widgets/filter_chip_widget.dart';
|
||||
import '../utils/category_utils.dart'; // Import category utils
|
||||
import 'profile_screen.dart'; // Import profile screen
|
||||
import '../widgets/add_category_dialog.dart'; // Import the new dialog
|
||||
|
||||
// Enum for transaction type selection in the form
|
||||
enum TransactionType { expense, income }
|
||||
@@ -53,6 +54,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
late Stream<List<db.Transaction>> _transactionsStream; // Stream for transactions
|
||||
late Stream<double> _totalIncomeStream; // Stream for total income
|
||||
late Stream<double> _totalExpensesStream; // Stream for total expenses
|
||||
late Stream<List<db.CategoryDb>> _categoriesStream; // Stream for categories from DB
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -83,6 +85,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
_transactionsStream = widget.database.watchFilteredTransactions(_selectedFilter);
|
||||
_totalIncomeStream = widget.database.watchTotalIncome();
|
||||
_totalExpensesStream = widget.database.watchTotalExpenses(); // Use direct expense stream
|
||||
_categoriesStream = widget.database.watchAllCategoriesDb(); // Watch categories from DB
|
||||
|
||||
// Start the pie chart appearance animation
|
||||
_pieChartAnimationController.forward();
|
||||
@@ -144,11 +147,14 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) {
|
||||
// Pass the database instance to the form widget
|
||||
// Pass the database instance and the categories stream to the form widget
|
||||
return Padding(
|
||||
// Add padding to account for the keyboard
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: _AddTransactionForm(database: widget.database),
|
||||
child: _AddTransactionForm(
|
||||
database: widget.database,
|
||||
categoriesStream: _categoriesStream, // Pass the stream here
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -225,15 +231,28 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
_watchCategoryTotals(), // Stream<List<Category>> for pie chart (expenses only)
|
||||
_totalIncomeStream, // Stream<double> for total income
|
||||
_totalExpensesStream, // Stream<double> for total expenses
|
||||
_categoriesStream, // Stream<List<db.CategoryDb>> for filter chips
|
||||
],
|
||||
builder: (context, snapshots) {
|
||||
if (snapshots.length < 3) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
// Check if all streams have data (or handle loading/error states individually)
|
||||
if (snapshots.any((s) => s.connectionState == ConnectionState.waiting && !s.hasData)) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshots.any((s) => s.hasError)) {
|
||||
// Find the first error and display it
|
||||
final errorSnapshot = snapshots.firstWhere((s) => s.hasError);
|
||||
return Center(child: Text('Error loading data: ${errorSnapshot.error}'));
|
||||
}
|
||||
|
||||
final expenseCategories = snapshots[0].data as List<Category>? ?? [];
|
||||
// Safely extract data with defaults
|
||||
final expenseCategoriesForPie = snapshots[0].data as List<Category>? ?? [];
|
||||
final totalIncome = snapshots[1].data as double? ?? 0.0;
|
||||
final totalExpenses = snapshots[2].data as double? ?? 0.0;
|
||||
final allDbCategories = snapshots[3].data as List<db.CategoryDb>? ?? [];
|
||||
|
||||
// Filter out the 'Income' category for display in expense filters/pie chart
|
||||
final expenseDbCategories = allDbCategories.where((c) => c.name != 'Income').toList();
|
||||
|
||||
|
||||
// Main column layout
|
||||
return Column(
|
||||
@@ -334,7 +353,8 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
onTap: _togglePieChartVisibility,
|
||||
heightFactor: _pieChartHeightFactor, // Animation controller
|
||||
child: SpendingPieChart(
|
||||
categories: expenseCategories, // Pass EXPENSE categories from stream snapshot
|
||||
// Use the categories calculated specifically for the pie chart
|
||||
categories: expenseCategoriesForPie,
|
||||
totalExpenses: totalExpenses, // Pass calculated total expenses
|
||||
animation: _pieChartAnimation, // Appearance animation
|
||||
),
|
||||
@@ -394,7 +414,7 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
),
|
||||
|
||||
// --- Filter Chips Row (Animated Visibility) ---
|
||||
// Shows 'All' and EXPENSE categories
|
||||
// Shows 'All' and EXPENSE categories from the database stream
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300), // Animation duration
|
||||
curve: Curves.easeInOut, // Animation curve
|
||||
@@ -414,12 +434,12 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
isSelected: _selectedFilter == 'All',
|
||||
onTap: () => _applyFilter('All')
|
||||
),
|
||||
// Dynamically generate filter chips from EXPENSE categories
|
||||
...CategoryUtils.getAllCategoryNames().map((name) =>
|
||||
// Dynamically generate filter chips from EXPENSE categories (from DB)
|
||||
...expenseDbCategories.map((category) =>
|
||||
FilterChipWidget(
|
||||
label: name,
|
||||
isSelected: _selectedFilter == name,
|
||||
onTap: () => _applyFilter(name) // Applies expense category filter
|
||||
label: category.name, // Use name from CategoryDb
|
||||
isSelected: _selectedFilter == category.name,
|
||||
onTap: () => _applyFilter(category.name) // Applies expense category filter
|
||||
)
|
||||
).toList(),
|
||||
],
|
||||
@@ -488,28 +508,39 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
|
||||
itemBuilder: (context, index) {
|
||||
// Get the db.Transaction object from the stream
|
||||
final dbTransaction = transactions[index];
|
||||
// Get the corresponding category details (icon, color) - use defaults for income
|
||||
// FIX: Define icon/color directly for income, no CategoryDetails class
|
||||
final ({IconData iconCode, Color colorCode}) categoryDetails = dbTransaction.type == 'expense'
|
||||
? CategoryUtils.getCategoryDetails(dbTransaction.categoryName)
|
||||
: ( // Default/Placeholder for Income type
|
||||
iconCode: Icons.attach_money, // Or a more specific income icon
|
||||
colorCode: Colors.green,
|
||||
);
|
||||
|
||||
// Find the corresponding CategoryDb object for details (icon, color)
|
||||
// This assumes category names are unique. Handle potential null if category deleted.
|
||||
final categoryDb = allDbCategories.firstWhere(
|
||||
(c) => c.name == dbTransaction.categoryName,
|
||||
orElse: () => db.CategoryDb( // Provide a default if not found
|
||||
id: -1,
|
||||
name: dbTransaction.categoryName,
|
||||
icon: 'help_outline', // Default icon
|
||||
color: Colors.grey.value // Default color
|
||||
),
|
||||
);
|
||||
|
||||
// Get icon data from string name (using CategoryUtils as a helper for now)
|
||||
// TODO: Improve icon handling (store IconData directly or use a better mapping)
|
||||
final iconData = CategoryUtils.getIconFromString(categoryDb.icon);
|
||||
final colorData = Color(categoryDb.color);
|
||||
|
||||
|
||||
// Create the model.TransactionRecord needed by TransactionListItem
|
||||
final transactionRecord = model.TransactionRecord(
|
||||
id: dbTransaction.id,
|
||||
type: dbTransaction.type,
|
||||
amount: dbTransaction.amount,
|
||||
category: dbTransaction.type == 'expense'
|
||||
// Create the UI Category model only for expenses
|
||||
category: dbTransaction.type == 'expense'
|
||||
? Category(
|
||||
dbTransaction.categoryName,
|
||||
dbTransaction.amount,
|
||||
categoryDetails.colorCode,
|
||||
categoryDetails.iconCode,
|
||||
dbTransaction.amount, // Amount here might be redundant?
|
||||
colorData,
|
||||
iconData,
|
||||
)
|
||||
: null,
|
||||
: null, // No UI Category for income type
|
||||
date: dbTransaction.date,
|
||||
merchant: dbTransaction.merchant,
|
||||
);
|
||||
@@ -591,35 +622,60 @@ class MultiStreamBuilder extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<List<AsyncSnapshot<dynamic>>>(
|
||||
stream: _combineStreams(),
|
||||
// Combine streams ensuring all emit at least one value (or handle initial nulls)
|
||||
// Using StreamZip might wait until all streams emit. Behavior depends on stream types.
|
||||
// Consider using combineLatest or similar if waiting isn't desired.
|
||||
return StreamBuilder<List<dynamic>>( // Use List<dynamic> and check types later
|
||||
stream: StreamZip(streams), // StreamZip waits for all streams to emit at least once
|
||||
builder: (context, combinedSnapshot) {
|
||||
if (combinedSnapshot.connectionState == ConnectionState.waiting) {
|
||||
if (combinedSnapshot.connectionState == ConnectionState.waiting && !combinedSnapshot.hasData) {
|
||||
// Show loading only if waiting AND no data has arrived yet
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (combinedSnapshot.hasError) {
|
||||
return Center(child: Text('Error: ${combinedSnapshot.error}'));
|
||||
return Center(child: Text('Error combining streams: ${combinedSnapshot.error}'));
|
||||
}
|
||||
|
||||
return builder(context, combinedSnapshot.data ?? []);
|
||||
// Create AsyncSnapshot objects manually for the builder
|
||||
// This allows handling individual stream states if needed, though StreamZip simplifies it
|
||||
final snapshots = List<AsyncSnapshot<dynamic>>.generate(
|
||||
streams.length,
|
||||
(index) {
|
||||
if (combinedSnapshot.hasData) {
|
||||
// If combined stream has data, assume individual streams are done (or active with data)
|
||||
return AsyncSnapshot.withData(ConnectionState.active, combinedSnapshot.data![index]);
|
||||
} else if (combinedSnapshot.hasError) {
|
||||
// Propagate error to individual snapshots (might need refinement)
|
||||
return AsyncSnapshot.withError(ConnectionState.active, combinedSnapshot.error!);
|
||||
} else {
|
||||
// Default to waiting state if combined stream is waiting
|
||||
return const AsyncSnapshot.waiting();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Call the original builder function with the list of snapshots
|
||||
return builder(context, snapshots);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Stream<List<AsyncSnapshot<dynamic>>> _combineStreams() {
|
||||
return StreamZip(streams.map((stream) {
|
||||
return stream.map((data) => AsyncSnapshot.withData(ConnectionState.done, data));
|
||||
}));
|
||||
}
|
||||
// This helper function is no longer needed as StreamZip handles the combination
|
||||
// Stream<List<AsyncSnapshot<dynamic>>> _combineStreams() { ... }
|
||||
}
|
||||
|
||||
|
||||
// --- Widget for the Add Transaction Form ---
|
||||
class _AddTransactionForm extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
final Stream<List<db.CategoryDb>> categoriesStream; // Receive stream
|
||||
|
||||
const _AddTransactionForm({Key? key, required this.database}) : super(key: key);
|
||||
const _AddTransactionForm({
|
||||
Key? key,
|
||||
required this.database,
|
||||
required this.categoriesStream, // Require stream
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<_AddTransactionForm> createState() => _AddTransactionFormState();
|
||||
@@ -629,20 +685,21 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
final _formKey = GlobalKey<FormState>(); // Key for form validation
|
||||
final _amountController = TextEditingController();
|
||||
final _merchantController = TextEditingController(); // Label changes based on type
|
||||
String? _selectedCategory; // Nullable initially, only for expenses
|
||||
String? _selectedCategoryName; // Store the NAME of the selected category
|
||||
DateTime _selectedDate = DateTime.now(); // Default to today, includes time
|
||||
TransactionType _selectedType = TransactionType.expense; // Default to expense
|
||||
|
||||
// List of available categories fetched from utils (for expenses)
|
||||
final List<String> _categories = CategoryUtils.getAllCategoryNames();
|
||||
// No longer need static list: final List<String> _categories = CategoryUtils.getAllCategoryNames();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Set the initial category if the list is not empty and type is expense
|
||||
if (_selectedType == TransactionType.expense && _categories.isNotEmpty) {
|
||||
_selectedCategory = _categories[0];
|
||||
}
|
||||
// We need to listen to the stream for the initial value
|
||||
// Setting initial value here is tricky with streams, better handle in StreamBuilder
|
||||
// if (_selectedType == TransactionType.expense && _categories.isNotEmpty) {
|
||||
// _selectedCategoryName = _categories[0];
|
||||
// }
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -724,7 +781,7 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
categoryToSave = 'Income'; // Use a fixed category for income
|
||||
} else {
|
||||
// Ensure a category is selected for expenses
|
||||
if (_selectedCategory == null) {
|
||||
if (_selectedCategoryName == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select a category for the expense.'),
|
||||
@@ -734,7 +791,7 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
categoryToSave = _selectedCategory!;
|
||||
categoryToSave = _selectedCategoryName!;
|
||||
}
|
||||
|
||||
// Create the transaction companion including the type
|
||||
@@ -783,6 +840,33 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Function to handle Add Category button press ---
|
||||
void _showAddCategoryDialog() async {
|
||||
// Show the dialog and wait for the result
|
||||
final newCategory = await showDialog<db.CategoryDb?>( // Expecting CategoryDb or null
|
||||
context: context,
|
||||
builder: (context) => AddCategoryDialog(database: widget.database),
|
||||
);
|
||||
|
||||
// If a new category was created and returned
|
||||
if (newCategory != null) {
|
||||
// Set the newly created category as selected in the dropdown
|
||||
setState(() {
|
||||
_selectedCategoryName = newCategory.name;
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Категория "${newCategory.name}" создана и выбрана.'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- End of Add Category Function ---
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -821,13 +905,12 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
onPressed: (int index) {
|
||||
setState(() {
|
||||
_selectedType = index == 0 ? TransactionType.expense : TransactionType.income;
|
||||
// Reset category selection if switching to income or if expense has no default
|
||||
// Reset category selection if switching to income
|
||||
if (_selectedType == TransactionType.income) {
|
||||
_selectedCategory = null;
|
||||
} else if (_categories.isNotEmpty) {
|
||||
_selectedCategory = _categories[0]; // Reset to default for expense
|
||||
_selectedCategoryName = null;
|
||||
} else {
|
||||
_selectedCategory = null;
|
||||
// Don't reset to default here, let StreamBuilder handle initial state
|
||||
// _selectedCategoryName = _categories[0]; // Remove this
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -882,44 +965,97 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// --- Category Dropdown (Only for Expenses) ---
|
||||
// --- Category Dropdown and Add Button (Only for Expenses, uses StreamBuilder) ---
|
||||
if (!isIncome)
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedCategory,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Category',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
|
||||
),
|
||||
items: _categories.map((String category) {
|
||||
final details = CategoryUtils.getCategoryDetails(category);
|
||||
return DropdownMenuItem<String>(
|
||||
value: category,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(details.iconCode, color: details.colorCode, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(category),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedCategory = newValue;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
// Only validate if it's an expense
|
||||
if (_selectedType == TransactionType.expense && value == null) {
|
||||
return 'Please select a category';
|
||||
StreamBuilder<List<db.CategoryDb>>(
|
||||
stream: widget.categoriesStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
|
||||
}
|
||||
return null; // No validation needed for income
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Text('Error loading categories: ${snapshot.error}');
|
||||
}
|
||||
|
||||
final categoriesFromDb = snapshot.data ?? [];
|
||||
// Filter out 'Income' category for the dropdown
|
||||
final expenseCategories = categoriesFromDb.where((c) => c.name != 'Income').toList();
|
||||
|
||||
// Ensure _selectedCategoryName is valid or reset it
|
||||
if (_selectedCategoryName != null && !expenseCategories.any((c) => c.name == _selectedCategoryName)) {
|
||||
_selectedCategoryName = null; // Reset if selected category is no longer valid
|
||||
}
|
||||
// Set default selection if nothing is selected and list is not empty
|
||||
if (_selectedCategoryName == null && expenseCategories.isNotEmpty) {
|
||||
// Use WidgetsBinding to schedule state update after build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) { // Check if widget is still mounted
|
||||
setState(() {
|
||||
_selectedCategoryName = expenseCategories[0].name;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start, // Align items to the top
|
||||
children: [
|
||||
// Dropdown takes most space
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedCategoryName, // Use the name state variable
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Category',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
filled: true,
|
||||
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), // Adjust padding if needed
|
||||
),
|
||||
// Map CategoryDb objects to DropdownMenuItem<String>
|
||||
items: expenseCategories.map((db.CategoryDb category) {
|
||||
// TODO: Improve icon/color handling later
|
||||
final iconData = CategoryUtils.getIconFromString(category.icon);
|
||||
final colorData = Color(category.color);
|
||||
return DropdownMenuItem<String>(
|
||||
value: category.name, // Value is the category name (String)
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconData, color: colorData, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Text(category.name),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
setState(() {
|
||||
_selectedCategoryName = newValue; // Update the selected name
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
// Only validate if it's an expense
|
||||
if (_selectedType == TransactionType.expense && value == null) {
|
||||
return 'Please select a category';
|
||||
}
|
||||
return null; // No validation needed for income
|
||||
},
|
||||
),
|
||||
),
|
||||
// Add Category Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0, top: 8.0), // Add padding to space it out and align vertically
|
||||
child: IconButton(
|
||||
icon: Icon(Icons.add_circle_outline, color: theme.colorScheme.primary),
|
||||
tooltip: 'Создать категорию', // Tooltip in Russian as requested
|
||||
onPressed: _showAddCategoryDialog, // Call the function to show the dialog
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isIncome) const SizedBox(height: 16), // Spacer only if category is shown
|
||||
if (!isIncome) const SizedBox(height: 16), // Spacer only if category row is shown
|
||||
|
||||
// --- Date and Time Picker ---
|
||||
InkWell(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
|
||||
class AddCategoryDialog extends StatefulWidget {
|
||||
final db.AppDatabase database;
|
||||
|
||||
const AddCategoryDialog({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddCategoryDialog> createState() => _AddCategoryDialogState();
|
||||
}
|
||||
|
||||
class _AddCategoryDialogState extends State<AddCategoryDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
// TODO: Add controllers/state for icon and color pickers later
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _saveCategory() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final name = _nameController.text;
|
||||
// TODO: Get selected icon and color
|
||||
const icon = 'label_outline'; // Placeholder icon
|
||||
final color = Colors.grey.shade400.value; // Placeholder color
|
||||
|
||||
final newCategoryCompanion = db.CategoriesCompanion(
|
||||
name: Value(name),
|
||||
icon: const Value(icon), // Use placeholder
|
||||
color: Value(color), // Use placeholder
|
||||
);
|
||||
|
||||
try {
|
||||
final newCategoryId = await widget.database.addCategory(newCategoryCompanion);
|
||||
final newCategory = await widget.database.getCategoryById(newCategoryId);
|
||||
if (mounted) {
|
||||
Navigator.pop(context, newCategory); // Return the newly created CategoryDb object
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding category: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error adding category: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Создать категорию'),
|
||||
content: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Название категории',
|
||||
icon: Icon(Icons.label_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Пожалуйста, введите название';
|
||||
}
|
||||
// TODO: Add validation to check if category name already exists
|
||||
return null;
|
||||
},
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
),
|
||||
// TODO: Add Icon Picker here
|
||||
// TODO: Add Color Picker here
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null), // Return null if cancelled
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _saveCategory,
|
||||
child: const Text('Сохранить'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user