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>
682 lines
26 KiB
Dart
682 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
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';
|
|
|
|
class TransactionsListScreen extends StatefulWidget {
|
|
final Account account;
|
|
|
|
const TransactionsListScreen({
|
|
super.key,
|
|
required this.account,
|
|
});
|
|
|
|
@override
|
|
State<TransactionsListScreen> createState() => _TransactionsListScreenState();
|
|
}
|
|
|
|
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
|
|
// Amount is a currency-formatted string returned by the API (e.g. may include
|
|
// currency symbol, grouping separators, locale-dependent decimal separator,
|
|
// and a sign either before or after the symbol)
|
|
Map<String, dynamic> _getAmountDisplayInfo(String amount, bool isAsset) {
|
|
try {
|
|
// Trim whitespace
|
|
String trimmedAmount = amount.trim();
|
|
|
|
// Normalize common minus characters (U+002D HYPHEN-MINUS, U+2212 MINUS SIGN)
|
|
trimmedAmount = trimmedAmount.replaceAll('\u2212', '-');
|
|
|
|
// Detect if the amount has a negative sign (leading or trailing)
|
|
bool hasNegativeSign = trimmedAmount.startsWith('-') || trimmedAmount.endsWith('-');
|
|
|
|
// Remove all non-numeric characters except decimal point and minus sign
|
|
String numericString = trimmedAmount.replaceAll(RegExp(r'[^\d.\-]'), '');
|
|
|
|
// Parse the numeric value
|
|
double numericValue = double.tryParse(numericString.replaceAll('-', '')) ?? 0.0;
|
|
|
|
// Apply the sign from the string
|
|
if (hasNegativeSign) {
|
|
numericValue = -numericValue;
|
|
}
|
|
|
|
// For asset accounts, flip the sign to match accounting conventions
|
|
if (isAsset) {
|
|
numericValue = -numericValue;
|
|
}
|
|
|
|
// Determine if the final value is positive
|
|
bool isPositive = numericValue >= 0;
|
|
|
|
// Get the display amount by removing the sign and currency symbols
|
|
String displayAmount = trimmedAmount
|
|
.replaceAll('-', '')
|
|
.replaceAll('\u2212', '')
|
|
.trim();
|
|
|
|
return {
|
|
'isPositive': isPositive,
|
|
'displayAmount': displayAmount,
|
|
'color': isPositive ? Colors.green : Colors.red,
|
|
'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward,
|
|
'prefix': isPositive ? '' : '-',
|
|
};
|
|
} catch (e) {
|
|
// Fallback if parsing fails - log and return neutral state
|
|
LogService.instance.error('TransactionsListScreen', 'Failed to parse amount "$amount": $e');
|
|
return {
|
|
'isPositive': true,
|
|
'displayAmount': amount,
|
|
'color': Colors.grey,
|
|
'icon': Icons.help_outline,
|
|
'prefix': '',
|
|
};
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
if (accessToken == null) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Authentication failed: Please log in again'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await transactionsProvider.fetchTransactions(
|
|
accessToken: accessToken,
|
|
accountId: widget.account.id,
|
|
);
|
|
}
|
|
|
|
void _toggleSelectionMode() {
|
|
setState(() {
|
|
_isSelectionMode = !_isSelectionMode;
|
|
if (!_isSelectionMode) {
|
|
_selectedTransactions.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
void _toggleTransactionSelection(String transactionId) {
|
|
setState(() {
|
|
if (_selectedTransactions.contains(transactionId)) {
|
|
_selectedTransactions.remove(transactionId);
|
|
} else {
|
|
_selectedTransactions.add(transactionId);
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _deleteSelectedTransactions() async {
|
|
if (_selectedTransactions.isEmpty) return;
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Transactions'),
|
|
content: Text('Are you sure you want to delete ${_selectedTransactions.length} transaction(s)?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
|
|
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
if (accessToken != null) {
|
|
final success = await transactionsProvider.deleteMultipleTransactions(
|
|
accessToken: accessToken,
|
|
transactionIds: _selectedTransactions.toList(),
|
|
);
|
|
|
|
if (mounted) {
|
|
if (success) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Deleted ${_selectedTransactions.length} transaction(s)'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
setState(() {
|
|
_selectedTransactions.clear();
|
|
_isSelectionMode = false;
|
|
});
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Failed to delete transactions'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _undoTransaction(OfflineTransaction transaction) async {
|
|
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Undo Transaction'),
|
|
content: Text(
|
|
transaction.syncStatus == SyncStatus.pending
|
|
? 'Remove this pending transaction?'
|
|
: 'Restore this transaction?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: const Text('Undo'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true) return;
|
|
|
|
final success = await transactionsProvider.undoPendingTransaction(
|
|
localId: transaction.localId,
|
|
syncStatus: transaction.syncStatus,
|
|
);
|
|
|
|
if (mounted) {
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
success
|
|
? (transaction.syncStatus == SyncStatus.pending
|
|
? 'Pending transaction removed'
|
|
: 'Transaction restored')
|
|
: 'Failed to undo transaction',
|
|
),
|
|
backgroundColor: success ? Colors.green : Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<bool> _confirmAndDeleteTransaction(Transaction transaction) async {
|
|
if (transaction.id == null) return false;
|
|
|
|
// Show confirmation dialog
|
|
// Capture providers before async gap
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
|
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Delete Transaction'),
|
|
content: Text('Are you sure you want to delete "${transaction.name}"?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed != true) return false;
|
|
|
|
// Perform the deletion
|
|
final accessToken = await authProvider.getValidAccessToken();
|
|
|
|
if (accessToken == null) {
|
|
scaffoldMessenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Failed to delete: No access token'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
final success = await transactionsProvider.deleteTransaction(
|
|
accessToken: accessToken,
|
|
transactionId: transaction.id!,
|
|
);
|
|
|
|
if (mounted) {
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(success ? 'Transaction deleted' : 'Failed to delete transaction'),
|
|
backgroundColor: success ? Colors.green : Colors.red,
|
|
),
|
|
);
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
void _showAddTransactionForm() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (context) => TransactionFormScreen(account: widget.account),
|
|
).then((_) {
|
|
if (mounted) {
|
|
_loadTransactions();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.account.name),
|
|
actions: [
|
|
if (_isSelectionMode)
|
|
IconButton(
|
|
icon: const Icon(Icons.delete),
|
|
onPressed: _selectedTransactions.isEmpty ? null : _deleteSelectedTransactions,
|
|
),
|
|
IconButton(
|
|
icon: Icon(_isSelectionMode ? Icons.close : Icons.checklist),
|
|
onPressed: _toggleSelectionMode,
|
|
),
|
|
],
|
|
),
|
|
body: Consumer<TransactionsProvider>(
|
|
builder: (context, transactionsProvider, child) {
|
|
if (transactionsProvider.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (transactionsProvider.error != null) {
|
|
return RefreshIndicator(
|
|
onRefresh: _loadTransactions,
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
transactionsProvider.error!,
|
|
style: const TextStyle(color: Colors.red),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: _loadTransactions,
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final allTransactions = transactionsProvider.offlineTransactions;
|
|
|
|
if (allTransactions.isEmpty) {
|
|
return RefreshIndicator(
|
|
onRefresh: _loadTransactions,
|
|
child: CustomScrollView(
|
|
slivers: [
|
|
SliverFillRemaining(
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.receipt_long_outlined,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No transactions yet',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Tap + to add your first transaction',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final transactions = _getFilteredTransactions(allTransactions);
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _loadTransactions,
|
|
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) {
|
|
final transaction = transactions[index];
|
|
final isSelected = transaction.id != null &&
|
|
_selectedTransactions.contains(transaction.id);
|
|
final isPending = transaction.syncStatus == SyncStatus.pending;
|
|
final isPendingDelete = transaction.syncStatus == SyncStatus.pendingDelete;
|
|
final isFailed = transaction.syncStatus == SyncStatus.failed;
|
|
final hasPendingStatus = isPending || isPendingDelete;
|
|
|
|
// Compute display info once to avoid duplicate parsing
|
|
final displayInfo = _getAmountDisplayInfo(
|
|
transaction.amount,
|
|
widget.account.isAsset,
|
|
);
|
|
|
|
return Dismissible(
|
|
key: Key(transaction.id ?? 'transaction_$index'),
|
|
direction: _isSelectionMode
|
|
? DismissDirection.none
|
|
: DismissDirection.endToStart,
|
|
background: Container(
|
|
alignment: Alignment.centerRight,
|
|
padding: const EdgeInsets.only(right: 20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Icon(Icons.delete, color: Colors.white),
|
|
),
|
|
confirmDismiss: (direction) => _confirmAndDeleteTransaction(transaction),
|
|
child: Opacity(
|
|
opacity: hasPendingStatus ? 0.5 : 1.0,
|
|
child: Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: InkWell(
|
|
onTap: _isSelectionMode && transaction.id != null
|
|
? () => _toggleTransactionSelection(transaction.id!)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
if (_isSelectionMode)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: Checkbox(
|
|
value: isSelected,
|
|
onChanged: transaction.id != null
|
|
? (value) => _toggleTransactionSelection(transaction.id!)
|
|
: null,
|
|
),
|
|
),
|
|
Container(
|
|
width: 48,
|
|
height: 48,
|
|
decoration: BoxDecoration(
|
|
color: (displayInfo['color'] as Color).withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
displayInfo['icon'] as IconData,
|
|
color: displayInfo['color'] as Color,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
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(
|
|
transaction.date,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (hasPendingStatus || isFailed)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: SyncStatusBadge(
|
|
syncStatus: transaction.syncStatus,
|
|
compact: true,
|
|
),
|
|
),
|
|
Text(
|
|
'${displayInfo['prefix']}${displayInfo['displayAmount']}',
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: displayInfo['color'] as Color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (hasPendingStatus) ...[
|
|
const SizedBox(height: 4),
|
|
InkWell(
|
|
onTap: () => _undoTransaction(transaction),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Colors.blue.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: const Text(
|
|
'Undo',
|
|
style: TextStyle(
|
|
color: Colors.blue,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
transaction.currency,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: _showAddTransactionForm,
|
|
child: const Icon(Icons.add),
|
|
),
|
|
);
|
|
}
|
|
}
|