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:
Lazy Bone
2026-04-14 02:01:08 +08:00
committed by GitHub
parent 60929cdee0
commit fdc2ce1feb
16 changed files with 549 additions and 10 deletions

View File

@@ -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(),

View 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(' > ');
}
}

View File

@@ -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,

View File

@@ -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,
};
}

View 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();
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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(
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> {
),
);
},
),
),
],
),
);
},

View 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()}',
};
}
}
}

View File

@@ -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) {

View File

@@ -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());

View File

@@ -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;

View File

@@ -125,6 +125,7 @@ class SyncService with ChangeNotifier {
currency: transaction.currency,
nature: transaction.nature,
notes: transaction.notes,
categoryId: transaction.categoryId,
);
if (result['success'] == true) {

View File

@@ -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,
}
};

View 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),
),
);
}),
],
),
);
}
}