mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add category support to transactions (#1251)
* 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 <juanjo.mata@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -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<ConnectivityService, AccountsProvider>(
|
||||
create: (_) => AccountsProvider(),
|
||||
|
||||
44
mobile/lib/models/category.dart
Normal file
44
mobile/lib/models/category.dart
Normal file
@@ -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<String, dynamic> json) {
|
||||
Category? parent;
|
||||
if (json['parent'] != null && json['parent'] is Map) {
|
||||
parent = Category.fromJson(Map<String, dynamic>.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 = <String>[];
|
||||
Category? current = this;
|
||||
while (current != null) {
|
||||
parts.add(current.name);
|
||||
current = current.parent;
|
||||
}
|
||||
return parts.reversed.join(' > ');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, dynamic> 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
56
mobile/lib/providers/categories_provider.dart
Normal file
56
mobile/lib/providers/categories_provider.dart
Normal file
@@ -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<models.Category> _categories = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
bool _hasFetched = false;
|
||||
|
||||
List<models.Category> get categories => List.unmodifiable(_categories);
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
bool get hasFetched => _hasFetched;
|
||||
|
||||
Future<void> 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<models.Category>;
|
||||
_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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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<SettingsScreen> {
|
||||
|
||||
log.info('Settings', 'Clearing all local data...');
|
||||
await offlineStorage.clearAllData();
|
||||
if (context.mounted) {
|
||||
Provider.of<CategoriesProvider>(context, listen: false).clear();
|
||||
}
|
||||
log.info('Settings', 'Local data cleared successfully');
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -164,6 +168,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
if (result['success'] == true) {
|
||||
await OfflineStorageService().clearAllData();
|
||||
if (context.mounted) {
|
||||
Provider.of<CategoriesProvider>(context, listen: false).clear();
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
|
||||
@@ -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<TransactionFormScreen> {
|
||||
String _nature = 'expense';
|
||||
bool _showMoreFields = false;
|
||||
bool _isSubmitting = false;
|
||||
models.Category? _selectedCategory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -38,6 +41,16 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
|
||||
final formattedDate = DateFormat('yyyy/MM/dd').format(now);
|
||||
_dateController.text = formattedDate;
|
||||
_nameController.text = 'SureApp';
|
||||
_fetchCategories();
|
||||
}
|
||||
|
||||
Future<void> _fetchCategories() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final categoriesProvider = Provider.of<CategoriesProvider>(context, listen: false);
|
||||
final accessToken = await authProvider.getValidAccessToken();
|
||||
if (accessToken != null) {
|
||||
categoriesProvider.fetchCategories(accessToken: accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -126,6 +139,8 @@ class _TransactionFormScreenState extends State<TransactionFormScreen> {
|
||||
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<TransactionFormScreen> {
|
||||
helperText: 'Optional (default: SureApp)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Category picker
|
||||
Consumer<CategoriesProvider>(
|
||||
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<String?>(
|
||||
value: _selectedCategory?.id,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Category',
|
||||
prefixIcon: Icon(Icons.category),
|
||||
helperText: 'Optional',
|
||||
),
|
||||
isExpanded: true,
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('No category'),
|
||||
),
|
||||
...categories.map((category) {
|
||||
return DropdownMenuItem<String?>(
|
||||
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),
|
||||
|
||||
@@ -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<TransactionsListScreen> {
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedTransactions = {};
|
||||
Set<String> _selectedCategoryIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTransactions();
|
||||
_loadCategories();
|
||||
}
|
||||
|
||||
// Parse and display amount information
|
||||
@@ -91,6 +95,31 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCategories() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final categoriesProvider = Provider.of<CategoriesProvider>(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<CategoriesProvider>(context);
|
||||
for (final cat in categoriesProvider.categories) {
|
||||
if (cat.id == categoryId) return cat.displayName;
|
||||
}
|
||||
return fallbackName;
|
||||
}
|
||||
|
||||
List<OfflineTransaction> _getFilteredTransactions(List<OfflineTransaction> transactions) {
|
||||
if (_selectedCategoryIds.isEmpty) return transactions;
|
||||
return transactions.where((t) =>
|
||||
t.categoryId != null && _selectedCategoryIds.contains(t.categoryId)
|
||||
).toList();
|
||||
}
|
||||
|
||||
Future<void> _loadTransactions() async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
|
||||
@@ -368,9 +397,9 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<TransactionsListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final transactions = _getFilteredTransactions(allTransactions);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadTransactions,
|
||||
child: ListView.builder(
|
||||
child: Column(
|
||||
children: [
|
||||
Consumer<CategoriesProvider>(
|
||||
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<TransactionsListScreen> {
|
||||
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<TransactionsListScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
81
mobile/lib/services/categories_service.dart
Normal file
81
mobile/lib/services/categories_service.dart
Normal file
@@ -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<Map<String, dynamic>> getCategories({
|
||||
required String accessToken,
|
||||
int? page,
|
||||
int? perPage,
|
||||
bool? rootsOnly,
|
||||
String? parentId,
|
||||
}) async {
|
||||
final Map<String, String> 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<dynamic> 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()}',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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<String> insertTransaction(Map<String, dynamic> transaction) async {
|
||||
if (_useInMemoryStore) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<bool> getShowCategoryFilter() async {
|
||||
final prefs = await _preferences;
|
||||
return prefs.getBool(_showCategoryFilterKey) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setShowCategoryFilter(bool value) async {
|
||||
final prefs = await _preferences;
|
||||
await prefs.setBool(_showCategoryFilterKey, value);
|
||||
}
|
||||
|
||||
/// Returns 'light', 'dark', or 'system' (default).
|
||||
Future<String> getThemeMode() async {
|
||||
final prefs = await _preferences;
|
||||
|
||||
@@ -125,6 +125,7 @@ class SyncService with ChangeNotifier {
|
||||
currency: transaction.currency,
|
||||
nature: transaction.nature,
|
||||
notes: transaction.notes,
|
||||
categoryId: transaction.categoryId,
|
||||
);
|
||||
|
||||
if (result['success'] == true) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
mobile/lib/widgets/category_filter.dart
Normal file
106
mobile/lib/widgets/category_filter.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/category.dart' as models;
|
||||
|
||||
class CategoryFilter extends StatelessWidget {
|
||||
final List<models.Category> availableCategories;
|
||||
final Set<String> selectedCategoryIds;
|
||||
final ValueChanged<Set<String>> 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<String>.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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user