feat: add more icons and color selection for categories

This commit is contained in:
2025-05-04 22:55:21 +03:00
parent 8ce8add138
commit 65e72dbe9c
2 changed files with 205 additions and 45 deletions
+135 -30
View File
@@ -5,6 +5,8 @@ import '../models/category.dart'; // Import the Category model for return types
class CategoryUtils {
// Private map storing details for each predefined category name.
// Using a record `({IconData icon, Color color})` for concise structure.
// This map is mainly for initial data and potentially fallback display logic.
// The actual icon/color for user-created categories will come from the database.
static final Map<String, ({IconData iconCode, Color colorCode})> _categoryDetails = {
'Groceries': (iconCode: Icons.shopping_cart_outlined, colorCode: Colors.green.shade400),
'Subscriptions': (iconCode: Icons.subscriptions_outlined, colorCode: Colors.orange.shade400),
@@ -12,65 +14,55 @@ class CategoryUtils {
'Shopping': (iconCode: Icons.shopping_bag_outlined, colorCode: Colors.blue.shade400),
'Transport': (iconCode: Icons.directions_bus_filled_outlined, colorCode: Colors.purple.shade400),
'Travel': (iconCode: Icons.flight_takeoff_outlined, colorCode: Colors.cyan.shade400),
'Utilities': (iconCode: Icons.lightbulb_outline, colorCode: Colors.yellow.shade700), // Changed from home_outlined
'Utilities': (iconCode: Icons.lightbulb_outline, colorCode: Colors.yellow.shade700),
'Health': (iconCode: Icons.local_hospital_outlined, colorCode: Colors.pink.shade300),
'Entertainment': (iconCode: Icons.movie_filter_outlined, colorCode: Colors.teal.shade400),
'Income': (iconCode: Icons.attach_money, colorCode: Colors.lightGreenAccent.shade700), // Added Income details
'Other': (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500), // Default/fallback
// Add placeholder icons used elsewhere
'Income': (iconCode: Icons.attach_money, colorCode: Colors.lightGreenAccent.shade700),
'Other': (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500),
// Placeholders should ideally not be needed here if DB provides data
'label_outline': (iconCode: Icons.label_outline, colorCode: Colors.grey.shade400),
'help_outline': (iconCode: Icons.help_outline, colorCode: Colors.grey.shade500),
};
// Default details to return if a category name is not found in the map.
// Default details to return if a category name is not found (e.g., deleted category).
static final _defaultDetails = (iconCode: Icons.category_outlined, colorCode: Colors.grey.shade500);
/// Returns the IconData and Color associated with a given category name.
///
/// If the `categoryName` is found in the predefined map, its details are returned.
/// Otherwise, default icon and color are returned.
/// Returns the IconData and Color associated with a given category name (primarily for fallback).
/// It's better to rely on data fetched directly from the database (CategoryDb).
static ({Color colorCode, IconData iconCode}) getCategoryDetails(String categoryName) {
// Use the null-aware operator `??` to provide default values if the key doesn't exist.
return _categoryDetails[categoryName] ?? _defaultDetails;
}
/// Returns a `Category` object based on its name and amount.
///
/// This is useful when you have the name and amount (e.g., from database aggregation)
/// and need to construct a full `Category` object including the icon and color.
/// Returns a `Category` object based on its name and amount (primarily for UI models like Pie Chart).
/// Fetches icon/color details using `getCategoryDetails` as a fallback mechanism.
static Category getCategoryByName(String name, double amount) {
// Retrieve the icon and color using the getCategoryDetails method.
final details = getCategoryDetails(name);
// Construct and return the Category object.
return Category(name, amount, details.colorCode, details.iconCode);
}
/// Returns a list of all predefined category names (excluding placeholders).
///
/// Useful for generating UI elements like filter chips or dropdowns.
/// Might be less useful now that categories are fully dynamic.
static List<String> getAllCategoryNames() {
// Return the keys from the details map as a list, excluding known placeholders
return _categoryDetails.keys.where((key) => key != 'label_outline' && key != 'help_outline').toList();
}
/// Converts a string icon name (like 'shopping_cart_outlined') to IconData.
///
/// This map should contain mappings for all icon names stored in the database.
/// This map is crucial for displaying icons based on the string stored in the DB.
static final Map<String, IconData> _stringToIconData = {
// Existing Icons
'shopping_cart_outlined': Icons.shopping_cart_outlined,
'subscriptions_outlined': Icons.subscriptions_outlined,
'restaurant_menu_outlined': Icons.restaurant_menu_outlined,
'shopping_bag_outlined': Icons.shopping_bag_outlined,
'directions_bus_filled_outlined': Icons.directions_bus_filled_outlined,
'flight_takeoff_outlined': Icons.flight_takeoff_outlined,
'lightbulb_outline': Icons.lightbulb_outline, // Updated from home_outlined
'lightbulb_outline': Icons.lightbulb_outline,
'local_hospital_outlined': Icons.local_hospital_outlined,
'movie_filter_outlined': Icons.movie_filter_outlined,
'attach_money': Icons.attach_money, // For Income
'category_outlined': Icons.category_outlined, // For Other/Default
'label_outline': Icons.label_outline, // Placeholder used in AddCategoryDialog
'help_outline': Icons.help_outline, // Placeholder for missing categories in list
// Add more icons suitable for categories
'attach_money': Icons.attach_money, // Income
'category_outlined': Icons.category_outlined, // Other/Default
'label_outline': Icons.label_outline, // Placeholder
'help_outline': Icons.help_outline, // Placeholder
'home_outlined': Icons.home_outlined,
'pets_outlined': Icons.pets_outlined,
'school_outlined': Icons.school_outlined,
@@ -93,10 +85,108 @@ class CategoryUtils {
'park_outlined': Icons.park_outlined, // Parks/Outdoors
'science_outlined': Icons.science_outlined, // Science/Tech
'palette_outlined': Icons.palette_outlined, // Art/Design
// Added More Icons
'account_balance_outlined': Icons.account_balance_outlined, // Bank/Finance
'analytics_outlined': Icons.analytics_outlined, // Analysis/Reports
'apartment_outlined': Icons.apartment_outlined, // Rent/Mortgage
'beach_access_outlined': Icons.beach_access_outlined, // Vacation
'brush_outlined': Icons.brush_outlined, // Personal Care/Cosmetics
'business_center_outlined': Icons.business_center_outlined, // Business Expenses
'call_outlined': Icons.call_outlined, // Phone Bill
'camera_alt_outlined': Icons.camera_alt_outlined, // Photography/Equipment
'car_rental_outlined': Icons.car_rental_outlined, // Car Rental
'car_repair_outlined': Icons.car_repair_outlined, // Car Repair
'celebration_outlined': Icons.celebration_outlined, // Party/Events
'computer_outlined': Icons.computer_outlined, // Computer/Software
'construction_outlined': Icons.construction_outlined, // Home Improvement
'cottage_outlined': Icons.cottage_outlined, // Vacation Home
'delivery_dining_outlined': Icons.delivery_dining_outlined, // Food Delivery
'diamond_outlined': Icons.diamond_outlined, // Jewelry/Luxury
'dry_cleaning_outlined': Icons.dry_cleaning_outlined, // Laundry/Dry Cleaning
'electrical_services_outlined': Icons.electrical_services_outlined, // Electrician
'emoji_events_outlined': Icons.emoji_events_outlined, // Awards/Competitions
'fastfood_outlined': Icons.fastfood_outlined, // Fast Food
'fax_outlined': Icons.fax_outlined, // Office Supplies
'festival_outlined': Icons.festival_outlined, // Festivals/Events
'fireplace_outlined': Icons.fireplace_outlined, // Heating/Fuel
'flatware_outlined': Icons.flatware_outlined, // Kitchenware
'gas_meter_outlined': Icons.gas_meter_outlined, // Gas Bill
'gavel_outlined': Icons.gavel_outlined, // Legal Fees
'grass_outlined': Icons.grass_outlined, // Gardening/Lawn Care
'hardware_outlined': Icons.hardware_outlined, // Hardware Store
'hearing_outlined': Icons.hearing_outlined, // Audio/Headphones
'hiking_outlined': Icons.hiking_outlined, // Hiking/Outdoor Gear
'icecream_outlined': Icons.icecream_outlined, // Ice Cream/Desserts
'interests_outlined': Icons.interests_outlined, // Hobbies General
'key_outlined': Icons.key_outlined, // Keys/Locks
'liquor_outlined': Icons.liquor_outlined, // Alcohol
'local_activity_outlined': Icons.local_activity_outlined, // Tickets/Events
'local_atm_outlined': Icons.local_atm_outlined, // ATM Withdrawal
'local_convenience_store_outlined': Icons.local_convenience_store_outlined, // Convenience Store
'local_florist_outlined': Icons.local_florist_outlined, // Flowers
'local_gas_station_outlined': Icons.local_gas_station_outlined, // Gas/Fuel
'local_laundry_service_outlined': Icons.local_laundry_service_outlined, // Laundry Service
'local_mall_outlined': Icons.local_mall_outlined, // Mall Shopping
'local_offer_outlined': Icons.local_offer_outlined, // Discounts/Sales
'local_parking_outlined': Icons.local_parking_outlined, // Parking Fees
'local_pharmacy_outlined': Icons.local_pharmacy_outlined, // Pharmacy
'local_pizza_outlined': Icons.local_pizza_outlined, // Pizza
'local_shipping_outlined': Icons.local_shipping_outlined, // Shipping Costs
'local_taxi_outlined': Icons.local_taxi_outlined, // Taxi/Rideshare
'lunch_dining_outlined': Icons.lunch_dining_outlined, // Lunch
'medication_outlined': Icons.medication_outlined, // Medication
'museum_outlined': Icons.museum_outlined, // Museum/Exhibits
'newspaper_outlined': Icons.newspaper_outlined, // Newspapers/Magazines
'paid_outlined': Icons.paid_outlined, // Payments/Transfers
'pedal_bike_outlined': Icons.pedal_bike_outlined, // Cycling
'plumbing_outlined': Icons.plumbing_outlined, // Plumber
'ramen_dining_outlined': Icons.ramen_dining_outlined, // Noodles/Asian Food
'recycling_outlined': Icons.recycling_outlined, // Recycling Fees
'redeem_outlined': Icons.redeem_outlined, // Gifts Received/Redeemed
'request_quote_outlined': Icons.request_quote_outlined, // Invoices/Quotes
'roller_skating_outlined': Icons.roller_skating_outlined, // Skating
'roofing_outlined': Icons.roofing_outlined, // Roofing Repair
'room_service_outlined': Icons.room_service_outlined, // Hotel Service
'settop_component_outlined': Icons.settop_component_outlined, // Cable/Streaming Box
'shield_outlined': Icons.shield_outlined, // Insurance
'skateboarding_outlined': Icons.skateboarding_outlined, // Skateboarding
'smoking_rooms_outlined': Icons.smoking_rooms_outlined, // Tobacco
'spa_outlined': Icons.spa_outlined, // Spa/Wellness
'sports_bar_outlined': Icons.sports_bar_outlined, // Sports Bar
'sports_basketball_outlined': Icons.sports_basketball_outlined, // Basketball
'sports_football_outlined': Icons.sports_football_outlined, // Football
'sports_golf_outlined': Icons.sports_golf_outlined, // Golf
'sports_gymnastics_outlined': Icons.sports_gymnastics_outlined, // Gymnastics
'sports_handball_outlined': Icons.sports_handball_outlined, // Handball
'sports_hockey_outlined': Icons.sports_hockey_outlined, // Hockey
'sports_kabaddi_outlined': Icons.sports_kabaddi_outlined, // Kabaddi
'sports_mma_outlined': Icons.sports_mma_outlined, // MMA
'sports_motorsports_outlined': Icons.sports_motorsports_outlined, // Motorsports
'sports_soccer_outlined': Icons.sports_soccer_outlined, // Soccer
'sports_tennis_outlined': Icons.sports_tennis_outlined, // Tennis
'sports_volleyball_outlined': Icons.sports_volleyball_outlined, // Volleyball
'stadium_outlined': Icons.stadium_outlined, // Stadium/Events
'store_mall_directory_outlined': Icons.store_mall_directory_outlined, // Department Store
'stroller_outlined': Icons.stroller_outlined, // Baby Supplies
'subway_outlined': Icons.subway_outlined, // Subway/Metro
'surfing_outlined': Icons.surfing_outlined, // Surfing
'sync_alt_outlined': Icons.sync_alt_outlined, // Transfers
'theater_comedy_outlined': Icons.theater_comedy_outlined, // Comedy Club
'theaters_outlined': Icons.theaters_outlined, // Cinema/Theater
'toys_outlined': Icons.toys_outlined, // Toys
'train_outlined': Icons.train_outlined, // Train Travel
'tram_outlined': Icons.tram_outlined, // Tram
'two_wheeler_outlined': Icons.two_wheeler_outlined, // Motorcycle/Scooter
'vape_free_outlined': Icons.vape_free_outlined, // Vaping (quit)
'vaping_rooms_outlined': Icons.vaping_rooms_outlined, // Vaping
'videogame_asset_outlined': Icons.videogame_asset_outlined, // Video Games
'water_drop_outlined': Icons.water_drop_outlined, // Water Bill
'wifi_outlined': Icons.wifi_outlined, // Internet Bill
'wine_bar_outlined': Icons.wine_bar_outlined, // Wine Bar
};
/// Returns the IconData corresponding to the given icon name string.
///
/// If the `iconName` is not found, returns a default fallback icon (`Icons.help_outline`).
static IconData getIconFromString(String iconName) {
return _stringToIconData[iconName] ?? Icons.help_outline; // Return default if not found
@@ -105,11 +195,26 @@ class CategoryUtils {
/// Returns a map of available icons for selection.
/// Excludes placeholder/internal icons.
static Map<String, IconData> getAvailableIcons() {
// Create a new map excluding specific keys
final availableIcons = Map<String, IconData>.from(_stringToIconData);
availableIcons.remove('help_outline'); // Remove fallback icon
availableIcons.remove('label_outline'); // Remove placeholder icon
// Add more exclusions if needed
availableIcons.remove('attach_money'); // Remove income icon (usually not selectable for expense)
availableIcons.remove('category_outlined'); // Remove generic 'Other' icon
// Add more exclusions if needed (e.g., icons only relevant for income)
return availableIcons;
}
/// List of available colors for category selection.
static const List<Color> availableColors = [
Colors.red, Colors.pink, Colors.purple, Colors.deepPurple,
Colors.indigo, Colors.blue, Colors.lightBlue, Colors.cyan,
Colors.teal, Colors.green, Colors.lightGreen, Colors.lime,
Colors.yellow, Colors.amber, Colors.orange, Colors.deepOrange,
Colors.brown, Colors.grey, Colors.blueGrey,
// Adding some accent colors
Colors.redAccent, Colors.pinkAccent, Colors.purpleAccent,
Colors.indigoAccent, Colors.blueAccent, Colors.cyanAccent,
Colors.tealAccent, Colors.greenAccent, Colors.limeAccent,
Colors.yellowAccent, Colors.amberAccent, Colors.orangeAccent,
];
}
@@ -18,7 +18,8 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
// State for selected icon
String _selectedIconName = 'label_outline'; // Default icon name (string)
IconData _selectedIconData = Icons.label_outline; // Default icon data
// TODO: Add state for selected color later
// State for selected color
Color _selectedColor = CategoryUtils.availableColors[9]; // Default to green
@override
void initState() {
@@ -56,19 +57,22 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
mainAxisSpacing: 10.0,
),
itemBuilder: (context, index) {
final bool isSelected = _selectedIconName == iconNames[index];
return InkWell(
onTap: () {
Navigator.pop(context, iconNames[index]); // Return the selected icon name (String)
},
borderRadius: BorderRadius.circular(8.0), // Ripple effect matches border
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: _selectedIconName == iconNames[index]
color: isSelected
? Theme.of(context).colorScheme.primary // Highlight selected
: Colors.transparent,
width: 2.0,
: Colors.grey.withOpacity(0.3), // Subtle border for all
width: isSelected ? 2.0 : 1.0, // Thicker border if selected
),
borderRadius: BorderRadius.circular(8.0),
color: isSelected ? Theme.of(context).colorScheme.primary.withOpacity(0.1) : null,
),
child: Icon(
iconDatas[index],
@@ -108,17 +112,17 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
final name = _nameController.text;
// Use the selected icon name
final icon = _selectedIconName;
// TODO: Get selected color
final color = Colors.grey.shade400.value; // Placeholder color
// Use the selected color value
final color = _selectedColor.value;
final newCategoryCompanion = db.CategoriesCompanion(
name: Value(name),
icon: Value(icon), // Use selected icon name
color: Value(color), // Use placeholder color
color: Value(color), // Use selected color value
);
try {
// TODO: Add check for duplicate category name before inserting
// TODO: Add check for duplicate category name before inserting (more robustly)
final newCategoryId = await widget.database.addCategory(newCategoryCompanion);
final newCategory = await widget.database.getCategoryById(newCategoryId);
if (mounted) {
@@ -127,7 +131,7 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
} catch (e) {
print('Error adding category: $e');
// Handle potential duplicate name error from database (depends on DB constraints)
String errorMessage = 'Error adding category: $e';
String errorMessage = 'Произошла ошибка при добавлении категории.';
if (e.toString().toLowerCase().contains('unique constraint failed')) {
errorMessage = 'Категория с таким именем уже существует.';
}
@@ -146,13 +150,20 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Determine icon color based on the selected background color's brightness
final iconColor = ThemeData.estimateBrightnessForColor(_selectedColor) == Brightness.dark
? Colors.white70
: Colors.black87;
return AlertDialog(
title: const Text('Создать категорию'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, // Align children to the start
children: [
// --- Icon and Name Row ---
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -161,12 +172,13 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
padding: const EdgeInsets.only(right: 16.0, bottom: 10.0), // Add padding
child: InkWell(
onTap: _showIconPicker, // Show picker on tap
customBorder: const CircleBorder(), // Make ripple circular
child: CircleAvatar(
radius: 24,
backgroundColor: theme.colorScheme.primaryContainer.withOpacity(0.5),
backgroundColor: _selectedColor, // Use selected color for background
child: Icon(
_selectedIconData, // Display selected icon
color: theme.colorScheme.onPrimaryContainer,
color: iconColor, // Adjust icon color based on background
size: 26,
),
),
@@ -178,13 +190,15 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Название категории',
// Removed icon: Icon(Icons.label_outline), // Icon is now separate
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Пожалуйста, введите название';
}
// TODO: Add more robust validation (e.g., check against existing names via stream/future)
// Basic length check
if (value.length > 50) {
return 'Название слишком длинное';
}
return null;
},
textCapitalization: TextCapitalization.sentences,
@@ -192,8 +206,48 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
),
],
),
// TODO: Add Color Picker here (e.g., below the name field)
const SizedBox(height: 16), // Add some space
const SizedBox(height: 20), // Space before color picker
// --- Color Picker ---
const Text('Выберите цвет:', style: TextStyle(fontSize: 16)),
const SizedBox(height: 8),
Wrap( // Use Wrap for horizontal layout with wrapping
spacing: 8.0, // Horizontal space between circles
runSpacing: 8.0, // Vertical space between rows
children: CategoryUtils.availableColors.map((color) {
final bool isSelected = _selectedColor == color;
return InkWell(
onTap: () {
setState(() {
_selectedColor = color; // Update selected color on tap
});
},
customBorder: const CircleBorder(),
child: Container(
width: 32, // Size of the color circle
height: 32,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? theme.colorScheme.onSurface // Highlight selected
: theme.dividerColor, // Border for others
width: isSelected ? 3.0 : 1.0,
),
boxShadow: isSelected ? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 3,
offset: const Offset(0, 1),
)
] : null,
),
),
);
}).toList(),
),
const SizedBox(height: 16), // Add some space at the bottom
],
),
),
@@ -210,3 +264,4 @@ class _AddCategoryDialogState extends State<AddCategoryDialog> {
);
}
}