diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 5b1a582f5..0d7db3b2d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'providers/auth_provider.dart'; import 'providers/accounts_provider.dart'; +import 'providers/categories_provider.dart'; import 'providers/transactions_provider.dart'; import 'providers/chat_provider.dart'; import 'providers/theme_provider.dart'; @@ -36,6 +37,7 @@ class SureApp extends StatelessWidget { ChangeNotifierProvider(create: (_) => ConnectivityService()), ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => ChatProvider()), + ChangeNotifierProvider(create: (_) => CategoriesProvider()), ChangeNotifierProvider(create: (_) => ThemeProvider()), ChangeNotifierProxyProvider( create: (_) => AccountsProvider(), diff --git a/mobile/lib/models/category.dart b/mobile/lib/models/category.dart new file mode 100644 index 000000000..a492a0e08 --- /dev/null +++ b/mobile/lib/models/category.dart @@ -0,0 +1,44 @@ +class Category { + final String id; + final String name; + final String? color; + final String? icon; + final Category? parent; + final int subcategoriesCount; + + Category({ + required this.id, + required this.name, + this.color, + this.icon, + this.parent, + this.subcategoriesCount = 0, + }); + + factory Category.fromJson(Map json) { + Category? parent; + if (json['parent'] != null && json['parent'] is Map) { + parent = Category.fromJson(Map.from(json['parent'])); + } + + return Category( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + color: json['color']?.toString(), + icon: json['icon']?.toString(), + parent: parent, + subcategoriesCount: json['subcategories_count'] as int? ?? 0, + ); + } + + /// Display name including full ancestor path for subcategories + String get displayName { + final parts = []; + Category? current = this; + while (current != null) { + parts.add(current.name); + current = current.parent; + } + return parts.reversed.join(' > '); + } +} diff --git a/mobile/lib/models/offline_transaction.dart b/mobile/lib/models/offline_transaction.dart index 4259f40a3..0f8a877f4 100644 --- a/mobile/lib/models/offline_transaction.dart +++ b/mobile/lib/models/offline_transaction.dart @@ -23,6 +23,8 @@ class OfflineTransaction extends Transaction { required super.currency, required super.nature, super.notes, + super.categoryId, + super.categoryName, this.syncStatus = SyncStatus.pending, DateTime? createdAt, DateTime? updatedAt, @@ -44,6 +46,8 @@ class OfflineTransaction extends Transaction { currency: transaction.currency, nature: transaction.nature, notes: transaction.notes, + categoryId: transaction.categoryId, + categoryName: transaction.categoryName, syncStatus: syncStatus, ); } @@ -59,6 +63,8 @@ class OfflineTransaction extends Transaction { currency: map['currency'] as String, nature: map['nature'] as String, notes: map['notes'] as String?, + categoryId: map['category_id'] as String?, + categoryName: map['category_name'] as String?, syncStatus: _parseSyncStatus(map['sync_status'] as String), createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), @@ -76,6 +82,8 @@ class OfflineTransaction extends Transaction { 'currency': currency, 'nature': nature, 'notes': notes, + 'category_id': categoryId, + 'category_name': categoryName, 'sync_status': _syncStatusToString(syncStatus), 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), @@ -92,6 +100,8 @@ class OfflineTransaction extends Transaction { currency: currency, nature: nature, notes: notes, + categoryId: categoryId, + categoryName: categoryName, ); } @@ -105,6 +115,8 @@ class OfflineTransaction extends Transaction { String? currency, String? nature, String? notes, + String? categoryId, + String? categoryName, SyncStatus? syncStatus, DateTime? createdAt, DateTime? updatedAt, @@ -119,6 +131,8 @@ class OfflineTransaction extends Transaction { currency: currency ?? this.currency, nature: nature ?? this.nature, notes: notes ?? this.notes, + categoryId: categoryId ?? this.categoryId, + categoryName: categoryName ?? this.categoryName, syncStatus: syncStatus ?? this.syncStatus, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, diff --git a/mobile/lib/models/transaction.dart b/mobile/lib/models/transaction.dart index 291f571c0..e20e536b3 100644 --- a/mobile/lib/models/transaction.dart +++ b/mobile/lib/models/transaction.dart @@ -7,6 +7,8 @@ class Transaction { final String currency; final String nature; // "expense" or "income" final String? notes; + final String? categoryId; + final String? categoryName; Transaction({ this.id, @@ -17,6 +19,8 @@ class Transaction { required this.currency, required this.nature, this.notes, + this.categoryId, + this.categoryName, }); factory Transaction.fromJson(Map json) { @@ -39,6 +43,17 @@ class Transaction { nature = json['nature']?.toString() ?? 'expense'; } + // Parse category from API response + String? categoryId; + String? categoryName; + if (json['category'] != null && json['category'] is Map) { + categoryId = json['category']['id']?.toString(); + categoryName = json['category']['name']?.toString(); + } else if (json['category_id'] != null) { + categoryId = json['category_id']?.toString(); + categoryName = json['category_name']?.toString(); + } + return Transaction( id: json['id']?.toString(), accountId: accountId, @@ -48,6 +63,8 @@ class Transaction { currency: json['currency']?.toString() ?? '', nature: nature, notes: json['notes']?.toString(), + categoryId: categoryId, + categoryName: categoryName, ); } @@ -61,6 +78,8 @@ class Transaction { 'currency': currency, 'nature': nature, if (notes != null) 'notes': notes, + if (categoryId != null) 'category_id': categoryId, + if (categoryName != null) 'category_name': categoryName, }; } diff --git a/mobile/lib/providers/categories_provider.dart b/mobile/lib/providers/categories_provider.dart new file mode 100644 index 000000000..a0d916522 --- /dev/null +++ b/mobile/lib/providers/categories_provider.dart @@ -0,0 +1,56 @@ +import 'package:flutter/foundation.dart'; +import '../models/category.dart' as models; +import '../services/categories_service.dart'; +import '../services/log_service.dart'; + +class CategoriesProvider with ChangeNotifier { + final CategoriesService _categoriesService = CategoriesService(); + final LogService _log = LogService.instance; + + List _categories = []; + bool _isLoading = false; + String? _error; + bool _hasFetched = false; + + List get categories => List.unmodifiable(_categories); + bool get isLoading => _isLoading; + String? get error => _error; + bool get hasFetched => _hasFetched; + + Future fetchCategories({required String accessToken}) async { + if (_isLoading || _hasFetched) return; + + _isLoading = true; + _error = null; + notifyListeners(); + + try { + final result = await _categoriesService.getCategories( + accessToken: accessToken, + perPage: 100, + ); + + if (result['success'] == true) { + _categories = result['categories'] as List; + _hasFetched = true; + _log.info('CategoriesProvider', 'Fetched ${_categories.length} categories'); + } else { + _error = result['error'] as String?; + _log.error('CategoriesProvider', 'Failed to fetch categories: $_error'); + } + } catch (e) { + _error = 'Failed to load categories'; + _log.error('CategoriesProvider', 'Exception fetching categories: $e'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + void clear() { + _categories = []; + _hasFetched = false; + _error = null; + notifyListeners(); + } +} diff --git a/mobile/lib/providers/transactions_provider.dart b/mobile/lib/providers/transactions_provider.dart index 54e682178..634f1f520 100644 --- a/mobile/lib/providers/transactions_provider.dart +++ b/mobile/lib/providers/transactions_provider.dart @@ -149,6 +149,8 @@ class TransactionsProvider with ChangeNotifier { required String currency, required String nature, String? notes, + String? categoryId, + String? categoryName, }) async { _lastAccessToken = accessToken; // Store for auto-sync @@ -166,6 +168,8 @@ class TransactionsProvider with ChangeNotifier { currency: currency, nature: nature, notes: notes, + categoryId: categoryId, + categoryName: categoryName, syncStatus: SyncStatus.pending, // Start as pending ); @@ -188,6 +192,7 @@ class TransactionsProvider with ChangeNotifier { currency: currency, nature: nature, notes: notes, + categoryId: categoryId, ).then((result) async { if (_isDisposed) return; diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 867e22c7b..725041cc1 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../providers/auth_provider.dart'; +import '../providers/categories_provider.dart'; import '../providers/theme_provider.dart'; import '../services/offline_storage_service.dart'; import '../services/log_service.dart'; @@ -42,10 +43,10 @@ class _SettingsScreenState extends State { } Future _loadPreferences() async { - final value = await PreferencesService.instance.getGroupByType(); + final groupByType = await PreferencesService.instance.getGroupByType(); if (mounted) { setState(() { - _groupByType = value; + _groupByType = groupByType; }); } } @@ -83,6 +84,9 @@ class _SettingsScreenState extends State { log.info('Settings', 'Clearing all local data...'); await offlineStorage.clearAllData(); + if (context.mounted) { + Provider.of(context, listen: false).clear(); + } log.info('Settings', 'Local data cleared successfully'); if (context.mounted) { @@ -164,6 +168,9 @@ class _SettingsScreenState extends State { if (result['success'] == true) { await OfflineStorageService().clearAllData(); + if (context.mounted) { + Provider.of(context, listen: false).clear(); + } if (!context.mounted) return; diff --git a/mobile/lib/screens/transaction_form_screen.dart b/mobile/lib/screens/transaction_form_screen.dart index cc4743080..d5513615b 100644 --- a/mobile/lib/screens/transaction_form_screen.dart +++ b/mobile/lib/screens/transaction_form_screen.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../models/account.dart'; +import '../models/category.dart' as models; import '../providers/auth_provider.dart'; +import '../providers/categories_provider.dart'; import '../providers/transactions_provider.dart'; import '../services/log_service.dart'; import '../services/connectivity_service.dart'; @@ -29,6 +31,7 @@ class _TransactionFormScreenState extends State { String _nature = 'expense'; bool _showMoreFields = false; bool _isSubmitting = false; + models.Category? _selectedCategory; @override void initState() { @@ -38,6 +41,16 @@ class _TransactionFormScreenState extends State { final formattedDate = DateFormat('yyyy/MM/dd').format(now); _dateController.text = formattedDate; _nameController.text = 'SureApp'; + _fetchCategories(); + } + + Future _fetchCategories() async { + final authProvider = Provider.of(context, listen: false); + final categoriesProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + categoriesProvider.fetchCategories(accessToken: accessToken); + } } @override @@ -126,6 +139,8 @@ class _TransactionFormScreenState extends State { currency: widget.account.currency, nature: _nature, notes: 'This transaction via mobile app.', + categoryId: _selectedCategory?.id, + categoryName: _selectedCategory?.name, ); if (mounted) { @@ -359,6 +374,56 @@ class _TransactionFormScreenState extends State { helperText: 'Optional (default: SureApp)', ), ), + const SizedBox(height: 16), + + // Category picker + Consumer( + builder: (context, categoriesProvider, _) { + if (categoriesProvider.isLoading) { + return const InputDecorator( + decoration: InputDecoration( + labelText: 'Category', + prefixIcon: Icon(Icons.category), + ), + child: Text('Loading categories...'), + ); + } + + final categories = categoriesProvider.categories; + + return DropdownButtonFormField( + value: _selectedCategory?.id, + decoration: const InputDecoration( + labelText: 'Category', + prefixIcon: Icon(Icons.category), + helperText: 'Optional', + ), + isExpanded: true, + items: [ + const DropdownMenuItem( + value: null, + child: Text('No category'), + ), + ...categories.map((category) { + return DropdownMenuItem( + value: category.id, + child: Text(category.displayName), + ); + }), + ], + onChanged: (value) { + setState(() { + if (value == null) { + _selectedCategory = null; + } else { + _selectedCategory = categories + .firstWhere((c) => c.id == value); + } + }); + }, + ); + }, + ), ], const SizedBox(height: 32), diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index df5bced82..0aa6ab131 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -4,8 +4,10 @@ import '../models/account.dart'; import '../models/transaction.dart'; import '../models/offline_transaction.dart'; import '../providers/auth_provider.dart'; +import '../providers/categories_provider.dart'; import '../providers/transactions_provider.dart'; import '../screens/transaction_form_screen.dart'; +import '../widgets/category_filter.dart'; import '../widgets/sync_status_badge.dart'; import '../services/log_service.dart'; @@ -24,11 +26,13 @@ class TransactionsListScreen extends StatefulWidget { class _TransactionsListScreenState extends State { bool _isSelectionMode = false; final Set _selectedTransactions = {}; + Set _selectedCategoryIds = {}; @override void initState() { super.initState(); _loadTransactions(); + _loadCategories(); } // Parse and display amount information @@ -91,6 +95,31 @@ class _TransactionsListScreenState extends State { } } + Future _loadCategories() async { + final authProvider = Provider.of(context, listen: false); + final categoriesProvider = Provider.of(context, listen: false); + final accessToken = await authProvider.getValidAccessToken(); + if (accessToken != null) { + await categoriesProvider.fetchCategories(accessToken: accessToken); + } + } + + String? _getCategoryDisplayName(String? categoryId, String? fallbackName) { + if (categoryId == null) return fallbackName; + final categoriesProvider = Provider.of(context); + for (final cat in categoriesProvider.categories) { + if (cat.id == categoryId) return cat.displayName; + } + return fallbackName; + } + + List _getFilteredTransactions(List transactions) { + if (_selectedCategoryIds.isEmpty) return transactions; + return transactions.where((t) => + t.categoryId != null && _selectedCategoryIds.contains(t.categoryId) + ).toList(); + } + Future _loadTransactions() async { final authProvider = Provider.of(context, listen: false); final transactionsProvider = Provider.of(context, listen: false); @@ -368,9 +397,9 @@ class _TransactionsListScreenState extends State { ); } - final transactions = transactionsProvider.offlineTransactions; + final allTransactions = transactionsProvider.offlineTransactions; - if (transactions.isEmpty) { + if (allTransactions.isEmpty) { return RefreshIndicator( onRefresh: _loadTransactions, child: CustomScrollView( @@ -410,9 +439,48 @@ class _TransactionsListScreenState extends State { ); } + final transactions = _getFilteredTransactions(allTransactions); + return RefreshIndicator( onRefresh: _loadTransactions, - child: ListView.builder( + child: Column( + children: [ + Consumer( + builder: (context, categoriesProvider, _) { + if (categoriesProvider.isLoading || categoriesProvider.categories.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: CategoryFilter( + availableCategories: categoriesProvider.categories, + selectedCategoryIds: _selectedCategoryIds, + onSelectionChanged: (categoryIds) { + setState(() { + _selectedCategoryIds = categoryIds; + }); + }, + ), + ); + }, + ), + Expanded( + child: transactions.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.4, + child: Center( + child: Text( + 'No transactions match this category', + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), + ), + ), + ], + ) + : ListView.builder( padding: const EdgeInsets.all(16), itemCount: transactions.length, itemBuilder: (context, index) { @@ -485,11 +553,42 @@ class _TransactionsListScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - transaction.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + Row( + children: [ + Flexible( + child: Text( + transaction.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, ), + ), + if (transaction.categoryName != null) ...[ + const SizedBox(width: 8), + Flexible( + flex: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Text( + _getCategoryDisplayName(transaction.categoryId, transaction.categoryName) ?? '', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ], ), const SizedBox(height: 4), Text( @@ -566,6 +665,9 @@ class _TransactionsListScreenState extends State { ), ); }, + ), + ), + ], ), ); }, diff --git a/mobile/lib/services/categories_service.dart b/mobile/lib/services/categories_service.dart new file mode 100644 index 000000000..c15d7f437 --- /dev/null +++ b/mobile/lib/services/categories_service.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/category.dart'; +import 'api_config.dart'; + +class CategoriesService { + Future> getCategories({ + required String accessToken, + int? page, + int? perPage, + bool? rootsOnly, + String? parentId, + }) async { + final Map queryParams = {}; + + if (page != null) { + queryParams['page'] = page.toString(); + } + if (perPage != null) { + queryParams['per_page'] = perPage.toString(); + } + if (rootsOnly == true) { + queryParams['roots_only'] = 'true'; + } + if (parentId != null) { + queryParams['parent_id'] = parentId; + } + + final baseUri = Uri.parse('${ApiConfig.baseUrl}/api/v1/categories'); + final url = queryParams.isNotEmpty + ? baseUri.replace(queryParameters: queryParams) + : baseUri; + + try { + final response = await http.get( + url, + headers: { + ...ApiConfig.getAuthHeaders(accessToken), + 'Content-Type': 'application/json', + }, + ).timeout(const Duration(seconds: 30)); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + List categoriesJson; + if (responseData is List) { + categoriesJson = responseData; + } else if (responseData is Map && responseData.containsKey('categories')) { + categoriesJson = responseData['categories']; + } else { + categoriesJson = []; + } + + final categories = categoriesJson + .map((json) => Category.fromJson(json)) + .toList(); + + return { + 'success': true, + 'categories': categories, + }; + } else if (response.statusCode == 401) { + return { + 'success': false, + 'error': 'unauthorized', + }; + } else { + return { + 'success': false, + 'error': 'Failed to fetch categories', + }; + } + } catch (e) { + return { + 'success': false, + 'error': 'Network error: ${e.toString()}', + }; + } + } +} diff --git a/mobile/lib/services/database_helper.dart b/mobile/lib/services/database_helper.dart index 0ec4efc0f..fe893b85f 100644 --- a/mobile/lib/services/database_helper.dart +++ b/mobile/lib/services/database_helper.dart @@ -63,8 +63,9 @@ class DatabaseHelper { return await openDatabase( path, - version: 1, + version: 2, onCreate: _createDB, + onUpgrade: _upgradeDB, ); } catch (e, stackTrace) { _log.error('DatabaseHelper', 'Error opening database file "$filePath": $e'); @@ -94,6 +95,8 @@ class DatabaseHelper { currency TEXT NOT NULL, nature TEXT NOT NULL, notes TEXT, + category_id TEXT, + category_name TEXT, sync_status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -148,6 +151,19 @@ class DatabaseHelper { } } + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + final columns = await db.rawQuery('PRAGMA table_info(transactions)'); + final columnNames = columns.map((c) => c['name'] as String).toSet(); + if (!columnNames.contains('category_id')) { + await db.execute('ALTER TABLE transactions ADD COLUMN category_id TEXT'); + } + if (!columnNames.contains('category_name')) { + await db.execute('ALTER TABLE transactions ADD COLUMN category_name TEXT'); + } + } + } + // Transaction CRUD operations Future insertTransaction(Map transaction) async { if (_useInMemoryStore) { diff --git a/mobile/lib/services/offline_storage_service.dart b/mobile/lib/services/offline_storage_service.dart index 9f5567dc6..06b0a687b 100644 --- a/mobile/lib/services/offline_storage_service.dart +++ b/mobile/lib/services/offline_storage_service.dart @@ -19,6 +19,8 @@ class OfflineStorageService { required String currency, required String nature, String? notes, + String? categoryId, + String? categoryName, String? serverId, SyncStatus syncStatus = SyncStatus.pending, }) async { @@ -35,6 +37,8 @@ class OfflineStorageService { currency: currency, nature: nature, notes: notes, + categoryId: categoryId, + categoryName: categoryName, syncStatus: syncStatus, ); @@ -238,6 +242,8 @@ class OfflineStorageService { currency: transaction.currency, nature: transaction.nature, notes: transaction.notes, + categoryId: transaction.categoryId ?? existing.categoryId, + categoryName: transaction.categoryName ?? existing.categoryName, syncStatus: SyncStatus.synced, ); await _dbHelper.updateTransaction(existing.localId, updated.toDatabaseMap()); @@ -257,6 +263,8 @@ class OfflineStorageService { currency: transaction.currency, nature: transaction.nature, notes: transaction.notes, + categoryId: transaction.categoryId, + categoryName: transaction.categoryName, syncStatus: SyncStatus.synced, ); await _dbHelper.insertTransaction(offlineTransaction.toDatabaseMap()); diff --git a/mobile/lib/services/preferences_service.dart b/mobile/lib/services/preferences_service.dart index 35b671e71..045f60a02 100644 --- a/mobile/lib/services/preferences_service.dart +++ b/mobile/lib/services/preferences_service.dart @@ -2,6 +2,7 @@ import 'package:shared_preferences/shared_preferences.dart'; class PreferencesService { static const _groupByTypeKey = 'dashboard_group_by_type'; + static const _showCategoryFilterKey = 'dashboard_show_category_filter'; static const _themeModeKey = 'theme_mode'; static PreferencesService? _instance; @@ -29,6 +30,16 @@ class PreferencesService { await prefs.setBool(_groupByTypeKey, value); } + Future getShowCategoryFilter() async { + final prefs = await _preferences; + return prefs.getBool(_showCategoryFilterKey) ?? false; + } + + Future setShowCategoryFilter(bool value) async { + final prefs = await _preferences; + await prefs.setBool(_showCategoryFilterKey, value); + } + /// Returns 'light', 'dark', or 'system' (default). Future getThemeMode() async { final prefs = await _preferences; diff --git a/mobile/lib/services/sync_service.dart b/mobile/lib/services/sync_service.dart index 82abbf575..a5dab6543 100644 --- a/mobile/lib/services/sync_service.dart +++ b/mobile/lib/services/sync_service.dart @@ -125,6 +125,7 @@ class SyncService with ChangeNotifier { currency: transaction.currency, nature: transaction.nature, notes: transaction.notes, + categoryId: transaction.categoryId, ); if (result['success'] == true) { diff --git a/mobile/lib/services/transactions_service.dart b/mobile/lib/services/transactions_service.dart index c36d86ac2..83af27de8 100644 --- a/mobile/lib/services/transactions_service.dart +++ b/mobile/lib/services/transactions_service.dart @@ -13,6 +13,7 @@ class TransactionsService { required String currency, required String nature, String? notes, + String? categoryId, }) async { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/transactions'); @@ -25,6 +26,7 @@ class TransactionsService { 'currency': currency, 'nature': nature, if (notes != null) 'notes': notes, + if (categoryId != null) 'category_id': categoryId, } }; diff --git a/mobile/lib/widgets/category_filter.dart b/mobile/lib/widgets/category_filter.dart new file mode 100644 index 000000000..448c1fc2b --- /dev/null +++ b/mobile/lib/widgets/category_filter.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import '../models/category.dart' as models; + +class CategoryFilter extends StatelessWidget { + final List availableCategories; + final Set selectedCategoryIds; + final ValueChanged> onSelectionChanged; + + const CategoryFilter({ + super.key, + required this.availableCategories, + required this.selectedCategoryIds, + required this.onSelectionChanged, + }); + + @override + Widget build(BuildContext context) { + if (availableCategories.isEmpty) { + return const SizedBox.shrink(); + } + + final colorScheme = Theme.of(context).colorScheme; + final isAllSelected = selectedCategoryIds.isEmpty; + + return Container( + height: 44, + margin: const EdgeInsets.symmetric(horizontal: 16), + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + // "All" chip + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: const Text('All'), + selected: isAllSelected, + onSelected: (_) { + onSelectionChanged({}); + }, + backgroundColor: colorScheme.surfaceContainerHighest, + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isAllSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: isAllSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isAllSelected + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.3), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ), + + // Category chips + ...availableCategories.map((category) { + final isSelected = + selectedCategoryIds.contains(category.id) && !isAllSelected; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(category.displayName), + selected: isSelected, + onSelected: (_) { + final newSelection = Set.from(selectedCategoryIds); + if (isSelected) { + newSelection.remove(category.id); + } else { + if (isAllSelected) { + newSelection.clear(); + } + newSelection.add(category.id); + } + if (newSelection.length == availableCategories.length) { + onSelectionChanged({}); + } else { + onSelectionChanged(newSelection); + } + }, + backgroundColor: colorScheme.surfaceContainerHighest, + selectedColor: colorScheme.primaryContainer, + checkmarkColor: colorScheme.onPrimaryContainer, + labelStyle: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + side: BorderSide( + color: isSelected + ? colorScheme.primary + : colorScheme.outline.withValues(alpha: 0.3), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + ); + }), + ], + ), + ); + } +}