feat: Add settings menu screen and rename category settings

This commit is contained in:
2025-05-08 23:27:05 +03:00
parent d9c70c1bb6
commit 0a6267ec3a
3 changed files with 567 additions and 8 deletions
+511 -3
View File
@@ -18,7 +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 'settings_screen.dart'; // Import settings screen
import 'settings_menu_screen.dart'; // Import the new settings menu screen
import '../widgets/add_category_dialog.dart'; // Import the new dialog
// Enum for transaction type selection in the form
@@ -183,11 +183,11 @@ class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStat
);
break;
case 2:
// Navigate to Settings Screen
// Navigate to Settings Menu Screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SettingsScreen(database: widget.database),
builder: (context) => SettingsMenuScreen(database: widget.database), // Navigate to the new menu screen
),
);
break;
@@ -1141,3 +1141,511 @@ class _AddTransactionFormState extends State<_AddTransactionForm> {
);
}
}
// Helper widget to manage multiple streams for the main body
class MultiStreamBuilder extends StatelessWidget {
final List<Stream<dynamic>> streams;
final Widget Function(BuildContext, List<AsyncSnapshot<dynamic>>) builder;
const MultiStreamBuilder({
Key? key,
required this.streams,
required this.builder,
}) super(key: key); // Use super constructor
@override
Widget build(BuildContext context) {
// 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 && !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 combining streams: ${combinedSnapshot.error}'));
}
// 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);
},
);
}
// 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,
required this.categoriesStream, // Require stream
}) : super(key: key);
@override
State<_AddTransactionForm> createState() => _AddTransactionFormState();
}
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? _selectedCategoryName; // Store the NAME of the selected category
DateTime _selectedDate = DateTime.now(); // Default to today, includes time
TransactionType _selectedType = TransactionType.expense; // Default to expense
// 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
// 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
void dispose() {
_amountController.dispose();
_merchantController.dispose();
super.dispose();
}
// Function to show the date and time pickers
Future<void> _selectDateTime(BuildContext context) async {
// 1. Pick Date
final DateTime? pickedDate = await showDatePicker(
context: context,
initialDate: _selectedDate,
firstDate: DateTime(2000), // Allow dates from year 2000
lastDate: DateTime.now().add(const Duration(days: 365)), // Allow up to one year in future
);
if (pickedDate != null) {
// If date was picked, proceed to pick time
// 2. Pick Time
final TimeOfDay? pickedTime = await showTimePicker(
context: context,
initialTime: TimeOfDay.fromDateTime(_selectedDate), // Use current time from state
);
if (pickedTime != null) {
// If time was also picked, combine date and time and update state
setState(() {
_selectedDate = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
pickedTime.hour,
pickedTime.minute,
);
});
} else {
// If only date was picked, update state with the picked date and existing time
setState(() {
_selectedDate = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
_selectedDate.hour, // Keep existing hour
_selectedDate.minute, // Keep existing minute
);
});
}
}
// If date picker was cancelled (pickedDate == null), do nothing.
}
// Function to handle form submission
void _submitTransaction() async {
// Validate the form
if (_formKey.currentState!.validate()) {
// Parse amount
final amount = double.tryParse(_amountController.text);
if (amount == null || amount <= 0) {
// Show error if amount is invalid
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter a valid positive amount.'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
return;
}
// Determine category and type string
String categoryToSave;
String typeString = _selectedType == TransactionType.income ? 'income' : 'expense';
if (_selectedType == TransactionType.income) {
categoryToSave = 'Income'; // Use a fixed category for income
} else {
// Ensure a category is selected for expenses
if (_selectedCategoryName == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a category for the expense.'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
return;
}
categoryToSave = _selectedCategoryName!;
}
// Create the transaction companion including the type
final merchantValue = _merchantController.text.isNotEmpty
? _merchantController.text
: (_selectedType == TransactionType.income ? 'Unknown Source' : 'Unknown Merchant');
final newTransaction = db.TransactionsCompanion(
categoryName: Value(categoryToSave),
amount: Value(amount),
date: Value(_selectedDate),
merchant: Value(merchantValue),
type: Value(typeString),
);
try {
// Add transaction to the database
await widget.database.addTransaction(newTransaction);
// Close the bottom sheet
if (mounted) Navigator.pop(context);
// Show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${_selectedType == TransactionType.income ? "Income" : "Expense"} added: ${typeString == 'income' ? '' : '$categoryToSave - '}\$${amount.toStringAsFixed(2)}'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
print('Error adding transaction: $e');
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error adding transaction: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
}
}
// --- 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);
final isDark = theme.brightness == Brightness.dark;
final bool isIncome = _selectedType == TransactionType.income;
return Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min, // Take minimum space needed
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// --- Header ---
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Add New Transaction',
style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context), // Close button
tooltip: 'Close',
)
],
),
const SizedBox(height: 16),
// --- Transaction Type Selector ---
Center(
child: ToggleButtons(
isSelected: [!isIncome, isIncome], // [Expense selected, Income selected]
onPressed: (int index) {
setState(() {
_selectedType = index == 0 ? TransactionType.expense : TransactionType.income;
// Reset category selection if switching to income
if (_selectedType == TransactionType.income) {
_selectedCategoryName = null;
} else {
// Don't reset to default here, let StreamBuilder handle initial state
// _selectedCategoryName = _categories[0]; // Remove this
}
});
},
borderRadius: BorderRadius.circular(12),
constraints: BoxConstraints(minWidth: (MediaQuery.of(context).size.width - 60) / 2, minHeight: 40), // Adjust width based on screen
selectedColor: Colors.white,
fillColor: isIncome ? Colors.green.shade400 : Colors.red.shade400,
color: isDark ? Colors.white70 : Colors.black54,
selectedBorderColor: isIncome ? Colors.green.shade600 : Colors.red.shade600,
borderColor: isDark ? Colors.grey.shade600 : Colors.grey.shade400,
children: const <Widget>[
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ Icon(Icons.arrow_upward_rounded, size: 18), SizedBox(width: 8), Text('Expense'), ],
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [ Icon(Icons.arrow_downward_rounded, size: 18), SizedBox(width: 8), Text('Income'), ],
),
),
],
),
),
const SizedBox(height: 20),
// --- Amount Field ---
TextFormField(
controller: _amountController,
decoration: InputDecoration(
labelText: 'Amount',
prefixIcon: Icon(Icons.attach_money, color: isIncome ? Colors.green : theme.colorScheme.primary),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null || double.parse(value) <= 0) {
return 'Please enter a valid positive number';
}
return null;
},
),
const SizedBox(height: 16),
// --- Category Dropdown and Add Button (Only for Expenses, uses StreamBuilder) ---
if (!isIncome)
StreamBuilder<List<db.CategoryDb>>(
stream: widget.categoriesStream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting && !snapshot.hasData) {
return const Center(child: CircularProgressIndicator(strokeWidth: 2));
}
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) {
// Get icon data using the new utility function
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 row is shown
// --- Date and Time Picker ---
InkWell(
onTap: () => _selectDateTime(context), // Use the combined picker function
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Date & Time', // Updated label
prefixIcon: const Icon(Icons.calendar_today_outlined),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
),
child: Text(
// Format date and time nicely
DateFormat.yMMMd().add_jm().format(_selectedDate),
style: theme.textTheme.bodyLarge,
),
),
),
const SizedBox(height: 16),
// --- Merchant / Source Field ---
TextFormField(
controller: _merchantController,
decoration: InputDecoration(
labelText: isIncome ? 'Source' : 'Merchant / Store', // Dynamic label
prefixIcon: Icon(isIncome ? Icons.source_outlined : Icons.storefront_outlined), // Dynamic icon
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
fillColor: isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade100,
),
textCapitalization: TextCapitalization.words,
// No validator needed, can be empty
),
const SizedBox(height: 24),
// --- Save Button ---
SizedBox(
width: double.infinity, // Make button full width
child: ElevatedButton.icon(
onPressed: _submitTransaction,
icon: const Icon(Icons.save_alt_rounded),
label: Text(isIncome ? 'Save Income' : 'Save Expense'), // Dynamic label
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: isIncome ? Colors.green : theme.colorScheme.primary, // Dynamic color
foregroundColor: theme.colorScheme.onPrimary, // Text color on primary
),
),
),
const SizedBox(height: 10), // Padding at the bottom
],
),
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import '../database/database.dart' as db;
import 'category_settings_screen.dart'; // Импортируем переименованный экран
class SettingsMenuScreen extends StatelessWidget {
final db.AppDatabase database;
const SettingsMenuScreen({Key? key, required this.database}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Настройки'),
centerTitle: true,
backgroundColor: theme.appBarTheme.backgroundColor,
elevation: theme.appBarTheme.elevation,
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8.0),
children: [
// Опция "Редактирование категорий"
ListTile(
leading: CircleAvatar(
radius: 22,
backgroundColor: theme.colorScheme.primary.withOpacity(0.15),
child: Icon(Icons.category_outlined, color: theme.colorScheme.primary, size: 24),
),
title: Text('Редактирование категорий', style: theme.textTheme.titleMedium),
trailing: Icon(Icons.arrow_forward_ios, size: 18, color: theme.colorScheme.onSurface.withOpacity(0.6)),
onTap: () {
// Переход на экран редактирования категорий
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => CategorySettingsScreen(database: database),
),
);
},
),
// Добавьте другие опции настроек здесь в будущем
// ListTile(...),
],
),
);
}
}
+7 -5
View File
@@ -4,16 +4,18 @@ import '../database/database.dart' as db;
import '../utils/category_utils.dart';
import '../widgets/edit_category_dialog.dart'; // Импортируем диалог
class SettingsScreen extends StatefulWidget {
// Переименован класс SettingsScreen в CategorySettingsScreen
class CategorySettingsScreen extends StatefulWidget {
final db.AppDatabase database;
const SettingsScreen({Key? key, required this.database}) : super(key: key);
const CategorySettingsScreen({Key? key, required this.database}) : super(key: key);
@override
State<SettingsScreen> createState() => _SettingsScreenState();
State<CategorySettingsScreen> createState() => _CategorySettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
// Переименован класс _SettingsScreenState в _CategorySettingsScreenState
class _CategorySettingsScreenState extends State<CategorySettingsScreen> {
late Stream<List<db.CategoryDb>> _categoriesStream;
@override
@@ -123,7 +125,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Настройки категорий'),
title: const Text('Редактирование категорий'), // Обновленный заголовок
centerTitle: true,
backgroundColor: theme.appBarTheme.backgroundColor, // Ensure AppBar color matches theme
elevation: theme.appBarTheme.elevation, // Ensure elevation matches theme