mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
* 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>
456 lines
17 KiB
Dart
456 lines
17 KiB
Dart
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';
|
|
|
|
class TransactionFormScreen extends StatefulWidget {
|
|
final Account account;
|
|
|
|
const TransactionFormScreen({
|
|
super.key,
|
|
required this.account,
|
|
});
|
|
|
|
@override
|
|
State<TransactionFormScreen> createState() => _TransactionFormScreenState();
|
|
}
|
|
|
|
class _TransactionFormScreenState extends State<TransactionFormScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _amountController = TextEditingController();
|
|
final _dateController = TextEditingController();
|
|
final _nameController = TextEditingController();
|
|
final _log = LogService.instance;
|
|
|
|
String _nature = 'expense';
|
|
bool _showMoreFields = false;
|
|
bool _isSubmitting = false;
|
|
models.Category? _selectedCategory;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Set default values
|
|
final now = DateTime.now();
|
|
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
|
|
void dispose() {
|
|
_amountController.dispose();
|
|
_dateController.dispose();
|
|
_nameController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _validateAmount(String? value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter an amount';
|
|
}
|
|
|
|
final amount = double.tryParse(value.trim());
|
|
if (amount == null) {
|
|
return 'Please enter a valid number';
|
|
}
|
|
|
|
if (amount <= 0) {
|
|
return 'Amount must be greater than 0';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Future<void> _selectDate() async {
|
|
final DateTime? picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.now(),
|
|
firstDate: DateTime(2000),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
|
|
if (picked != null && mounted) {
|
|
setState(() {
|
|
_dateController.text = DateFormat('yyyy/MM/dd').format(picked);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSubmit() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isSubmitting = true;
|
|
});
|
|
|
|
_log.info('TransactionForm', 'Starting transaction creation...');
|
|
|
|
try {
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
|
|
if (accessToken == null) {
|
|
_log.warning('TransactionForm', 'Access token is null, session expired');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Session expired. Please login again.'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
await authProvider.logout();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Convert date format from yyyy/MM/dd to yyyy-MM-dd
|
|
final parsedDate = DateFormat('yyyy/MM/dd').parse(_dateController.text);
|
|
final apiDate = DateFormat('yyyy-MM-dd').format(parsedDate);
|
|
|
|
_log.info('TransactionForm', 'Calling TransactionsProvider.createTransaction (offline-first)');
|
|
|
|
// Use TransactionsProvider for offline-first transaction creation
|
|
final success = await transactionsProvider.createTransaction(
|
|
accessToken: accessToken,
|
|
accountId: widget.account.id,
|
|
name: _nameController.text.trim(),
|
|
date: apiDate,
|
|
amount: _amountController.text.trim(),
|
|
currency: widget.account.currency,
|
|
nature: _nature,
|
|
notes: 'This transaction via mobile app.',
|
|
categoryId: _selectedCategory?.id,
|
|
categoryName: _selectedCategory?.name,
|
|
);
|
|
|
|
if (mounted) {
|
|
if (success) {
|
|
_log.info('TransactionForm', 'Transaction created successfully (saved locally)');
|
|
|
|
// Check current connectivity status to show appropriate message
|
|
final connectivityService = Provider.of<ConnectivityService>(context, listen: false);
|
|
final isOnline = connectivityService.isOnline;
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
isOnline
|
|
? 'Transaction created successfully'
|
|
: 'Transaction saved (will sync when online)'
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
Navigator.pop(context, true); // Return true to indicate success
|
|
} else {
|
|
_log.error('TransactionForm', 'Failed to create transaction');
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Failed to create transaction'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.error('TransactionForm', 'Exception during transaction creation: $e');
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Error: ${e.toString()}'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isSubmitting = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(20),
|
|
topRight: Radius.circular(20),
|
|
),
|
|
),
|
|
child: DraggableScrollableSheet(
|
|
initialChildSize: 0.9,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) {
|
|
return Column(
|
|
children: [
|
|
// Handle bar
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
// Title
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'New Transaction',
|
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
// Form content
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
controller: scrollController,
|
|
padding: EdgeInsets.only(
|
|
left: 24,
|
|
right: 24,
|
|
top: 16,
|
|
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
|
),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Account info card
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.account_balance_wallet,
|
|
color: colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.account.name,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${widget.account.balance} ${widget.account.currency}',
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Transaction type selection
|
|
Text(
|
|
'Type',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SegmentedButton<String>(
|
|
segments: const [
|
|
ButtonSegment<String>(
|
|
value: 'expense',
|
|
label: Text('Expense'),
|
|
icon: Icon(Icons.arrow_downward),
|
|
),
|
|
ButtonSegment<String>(
|
|
value: 'income',
|
|
label: Text('Income'),
|
|
icon: Icon(Icons.arrow_upward),
|
|
),
|
|
],
|
|
selected: {_nature},
|
|
onSelectionChanged: (Set<String> newSelection) {
|
|
setState(() {
|
|
_nature = newSelection.first;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Amount field
|
|
TextFormField(
|
|
controller: _amountController,
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
decoration: InputDecoration(
|
|
labelText: 'Amount *',
|
|
prefixIcon: const Icon(Icons.attach_money),
|
|
suffixText: widget.account.currency,
|
|
helperText: 'Required',
|
|
),
|
|
validator: _validateAmount,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// More button
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
setState(() {
|
|
_showMoreFields = !_showMoreFields;
|
|
});
|
|
},
|
|
icon: Icon(_showMoreFields ? Icons.expand_less : Icons.expand_more),
|
|
label: Text(_showMoreFields ? 'Less' : 'More'),
|
|
),
|
|
|
|
// Optional fields (shown when More is clicked)
|
|
if (_showMoreFields) ...[
|
|
const SizedBox(height: 16),
|
|
|
|
// Date field
|
|
TextFormField(
|
|
controller: _dateController,
|
|
readOnly: true,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Date',
|
|
prefixIcon: Icon(Icons.calendar_today),
|
|
helperText: 'Optional (default: today)',
|
|
),
|
|
onTap: _selectDate,
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Name field
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Name',
|
|
prefixIcon: Icon(Icons.label),
|
|
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),
|
|
|
|
// Submit button
|
|
ElevatedButton(
|
|
onPressed: _isSubmitting ? null : _handleSubmit,
|
|
child: _isSubmitting
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
),
|
|
)
|
|
: const Text('Create Transaction'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|