From fdc2ce1febb2b55e45b440f0d86619b91d3cf7a6 Mon Sep 17 00:00:00 2001 From: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:01:08 +0800 Subject: [PATCH] Add category support to transactions (#1251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move debug logs button from Home to Settings page, remove refresh/logout from Home AppBar - Remove Debug Logs, Refresh, and Sign Out buttons from DashboardScreen AppBar - Add Debug Logs ListTile entry in SettingsScreen under app info section - Remove unused _handleLogout method from DashboardScreen - Remove unused log_viewer_screen.dart import from DashboardScreen https://claude.ai/code/session_017XQZdaEwUuRS75tJMcHzB9 * Add category picker to Android transaction form Implements category selection when creating transactions in the mobile app. Uses the existing /api/v1/categories endpoint to fetch categories and sends category_id when creating transactions via the API. New files: - Category model, CategoriesService, CategoriesProvider Updated: - Transaction/OfflineTransaction models with categoryId/categoryName - TransactionsService/Provider to pass category_id - DB schema v2 migration for category columns - TransactionFormScreen with category dropdown in "More" section Closes #78 https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix ambiguous Category import in CategoriesProvider Hide Flutter's built-in Category annotation from foundation.dart to resolve name collision with our Category model. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Add category filter on Dashboard, clear categories on data reset, fix ambiguous imports - Add CategoryFilter widget (horizontal chip row like CurrencyFilter) - Show category filter on Dashboard below currency filter (2nd row) - Add "Show Category Filter" toggle in Settings > Display section - Clear CategoriesProvider on "Clear Local Data" and "Reset Account" - Fix Category name collision: hide Flutter's Category from material.dart - Add getShowCategoryFilter/setShowCategoryFilter to PreferencesService https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix Category name collision using prefixed imports Use 'import as models' instead of 'hide Category' to avoid undefined_hidden_name warnings with flutter/material.dart. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix duplicate column error in SQLite migration Check if category_id/category_name columns exist before running ALTER TABLE, preventing crashes when the DB was already at v2 or the migration had partially succeeded. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Move CategoryFilter from dashboard to transaction list screen CategoryFilter was filtering accounts on the dashboard but accounts are already grouped by type. Moved it to TransactionsListScreen where it filters transactions by category, which is the correct placement. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Add category tag badge next to transaction name Shows an oval-bordered category label after each transaction's name for quick visual identification of transaction types. https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Address review findings for category feature 1. Category.fromJson now recursively parses parent chain; displayName walks all ancestors (e.g. "Grandparent > Parent > Child") 2. CategoriesProvider.fetchCategories guards against concurrent/duplicate calls by checking _isLoading and _hasFetched early 3. CategoryFilter chips use displayName to distinguish subcategories 4. Transaction badge resolves full displayName from CategoriesProvider with overflow ellipsis for long paths 5. Offline storage preserves local category values when server response omits them (coalesce with ??) https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix missing closing brace in PreferencesService causing theme_provider analyze errors https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ * Fix sync category upload, empty-state refresh, badge reactivity, and preferences syntax - Add categoryId to SyncService pending transaction upload payload - Replace non-scrollable Center with ListView for empty filter state so RefreshIndicator works when no transactions match - Use listen:true for CategoriesProvider in badge display so badges rebuild when categories finish loading - Fix missing closing brace in PreferencesService.setShowCategoryFilter https://claude.ai/code/session_01Dgj8tYrCkoUaLW2WrQ3vMJ --------- Signed-off-by: Juan José Mata Co-authored-by: Claude Co-authored-by: Juan José Mata --- mobile/lib/main.dart | 2 + mobile/lib/models/category.dart | 44 +++++++ mobile/lib/models/offline_transaction.dart | 14 +++ mobile/lib/models/transaction.dart | 19 +++ mobile/lib/providers/categories_provider.dart | 56 +++++++++ .../lib/providers/transactions_provider.dart | 5 + mobile/lib/screens/settings_screen.dart | 11 +- .../lib/screens/transaction_form_screen.dart | 65 ++++++++++ .../lib/screens/transactions_list_screen.dart | 116 ++++++++++++++++-- mobile/lib/services/categories_service.dart | 81 ++++++++++++ mobile/lib/services/database_helper.dart | 18 ++- .../lib/services/offline_storage_service.dart | 8 ++ mobile/lib/services/preferences_service.dart | 11 ++ mobile/lib/services/sync_service.dart | 1 + mobile/lib/services/transactions_service.dart | 2 + mobile/lib/widgets/category_filter.dart | 106 ++++++++++++++++ 16 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 mobile/lib/models/category.dart create mode 100644 mobile/lib/providers/categories_provider.dart create mode 100644 mobile/lib/services/categories_service.dart create mode 100644 mobile/lib/widgets/category_filter.dart 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), + ), + ); + }), + ], + ), + ); + } +}