feat: Implement Drift database for transaction management
This commit is contained in:
@@ -1,17 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'screens/expenses_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'database/database.dart'; // Import the database
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
const MyApp({Key? key}) : super(key: key);
|
||||
final AppDatabase database; // Принимаем экземпляр базы данных
|
||||
|
||||
const MyApp({Key? key, required this.database}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
// Состояние для управления темой (светлая/темная)
|
||||
bool _isDarkMode = false;
|
||||
|
||||
// Метод для переключения темы
|
||||
void toggleTheme() {
|
||||
setState(() {
|
||||
_isDarkMode = !_isDarkMode;
|
||||
@@ -21,12 +26,16 @@ class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Expenses Tracker',
|
||||
debugShowCheckedModeBanner: false,
|
||||
themeMode: _isDarkMode ? ThemeMode.dark : ThemeMode.light,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
home: ExpensesScreen(toggleTheme: toggleTheme, isDarkMode: _isDarkMode),
|
||||
title: 'Finance App', // Обновленный заголовок
|
||||
debugShowCheckedModeBanner: false, // Скрыть баннер Debug
|
||||
themeMode: _isDarkMode ? ThemeMode.dark : ThemeMode.light, // Управление темой
|
||||
theme: AppTheme.lightTheme, // Светлая тема
|
||||
darkTheme: AppTheme.darkTheme, // Темная тема
|
||||
home: ExpensesScreen(
|
||||
database: widget.database, // Передаем базу данных в главный экран
|
||||
toggleTheme: toggleTheme, // Передаем функцию переключения темы
|
||||
isDarkMode: _isDarkMode, // Передаем текущее состояние темы
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,21 @@ import 'dart:io';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart'; // Required for Color and IconData in Category
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:rxdart/rxdart.dart'; // Import rxdart for combining streams
|
||||
|
||||
// Импортируем модель категории для возвращаемого типа и утилиты
|
||||
import '../models/category.dart';
|
||||
import '../utils/category_utils.dart'; // Helper for category details
|
||||
|
||||
// Эта строка указывает Drift сгенерировать файл database.g.dart
|
||||
part 'database.g.dart';
|
||||
|
||||
// Определение таблицы Transactions
|
||||
// Имя таблицы в SQL будет 'transactions' (snake_case от имени класса)
|
||||
@DataClassName('Transaction') // Keep the generated class name as Transaction
|
||||
class Transactions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()(); // Primary key
|
||||
TextColumn get categoryName => text().named('category_name')(); // Имя категории
|
||||
@@ -28,22 +35,102 @@ class AppDatabase extends _$AppDatabase {
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
// Методы для взаимодействия с таблицей Transactions будут добавлены здесь
|
||||
// Например:
|
||||
// Future<List<Transaction>> getAllTransactions() => select(transactions).get();
|
||||
// Stream<List<Transaction>> watchAllTransactions() => select(transactions).watch();
|
||||
// Future<int> addTransaction(TransactionsCompanion entry) => into(transactions).insert(entry);
|
||||
// --- Методы для работы с транзакциями ---
|
||||
|
||||
// Получить все транзакции в виде потока, упорядоченные по дате (сначала новые)
|
||||
Stream<List<Transaction>> watchAllTransactions() {
|
||||
return (select(transactions)
|
||||
..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
// Получить транзакции, отфильтрованные по категории, в виде потока
|
||||
Stream<List<Transaction>> watchFilteredTransactions(String categoryName) {
|
||||
if (categoryName == 'All') {
|
||||
return watchAllTransactions(); // Return all if filter is 'All'
|
||||
}
|
||||
// Otherwise, filter by the provided category name
|
||||
return (select(transactions)
|
||||
..where((t) => t.categoryName.equals(categoryName))
|
||||
..orderBy([(t) => OrderingTerm(expression: t.date, mode: OrderingMode.desc)]))
|
||||
.watch();
|
||||
}
|
||||
|
||||
// Добавить новую транзакцию
|
||||
Future<int> addTransaction(TransactionsCompanion entry) {
|
||||
return into(transactions).insert(entry);
|
||||
}
|
||||
|
||||
// --- Методы для работы с категориями (вычисляются из транзакций) ---
|
||||
|
||||
// Вычислить и наблюдать за общими суммами по категориям
|
||||
Stream<List<Category>> calculateCategoryTotals() {
|
||||
// 1. Получаем поток всех транзакций
|
||||
return watchAllTransactions().map((transactionList) {
|
||||
// 2. Группируем транзакции по categoryName и суммируем amount
|
||||
final categoryTotals = <String, double>{};
|
||||
for (var transaction in transactionList) {
|
||||
categoryTotals.update(
|
||||
transaction.categoryName,
|
||||
(value) => value + transaction.amount,
|
||||
ifAbsent: () => transaction.amount,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Преобразуем сгруппированные данные в список объектов Category
|
||||
return categoryTotals.entries.map((entry) {
|
||||
// Используем CategoryUtils для получения деталей (иконка, цвет)
|
||||
final categoryDetails = CategoryUtils.getCategoryDetails(entry.key);
|
||||
return Category(
|
||||
entry.key, // name
|
||||
entry.value, // amount
|
||||
categoryDetails.color,
|
||||
categoryDetails.icon,
|
||||
);
|
||||
}).toList()
|
||||
// Сортируем категории по сумме (от большей к меньшей) для диаграммы/легенды
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
});
|
||||
}
|
||||
|
||||
// Пример добавления начальных данных (если база данных пуста)
|
||||
Future<void> insertInitialDataIfNeeded() async {
|
||||
// Проверяем, есть ли уже транзакции
|
||||
final existingTransactions = await select(transactions).get();
|
||||
if (existingTransactions.isEmpty) {
|
||||
print("Database is empty. Inserting initial data...");
|
||||
// Используем batch для эффективной вставки нескольких записей
|
||||
await batch((batch) {
|
||||
batch.insertAll(transactions, [
|
||||
// Используем TransactionsCompanion для создания записей для вставки
|
||||
TransactionsCompanion.insert(categoryName: 'Groceries', amount: 45.99, date: DateTime.now().subtract(const Duration(days: 1, hours: 2)), merchant: 'Whole Foods Market'),
|
||||
TransactionsCompanion.insert(categoryName: 'Subscriptions', amount: 39.99, date: DateTime.now().subtract(const Duration(days: 2, hours: 5)), merchant: 'Netflix Premium'),
|
||||
TransactionsCompanion.insert(categoryName: 'Restaurant', amount: 78.50, date: DateTime.now().subtract(const Duration(days: 2, hours: 19)), merchant: 'Italian Corner'),
|
||||
TransactionsCompanion.insert(categoryName: 'Shopping', amount: 132.75, date: DateTime.now().subtract(const Duration(days: 3, hours: 11)), merchant: 'Apple Store'),
|
||||
TransactionsCompanion.insert(categoryName: 'Groceries', amount: 23.45, date: DateTime.now().subtract(const Duration(days: 4, hours: 9)), merchant: 'Local Market'),
|
||||
TransactionsCompanion.insert(categoryName: 'Transport', amount: 15.00, date: DateTime.now().subtract(const Duration(days: 5, hours: 8)), merchant: 'City Bus'),
|
||||
TransactionsCompanion.insert(categoryName: 'Restaurant', amount: 56.80, date: DateTime.now().subtract(const Duration(days: 5, hours: 20)), merchant: 'Sushi Express'),
|
||||
TransactionsCompanion.insert(categoryName: 'Utilities', amount: 85.20, date: DateTime.now().subtract(const Duration(days: 6, hours: 10)), merchant: 'Electricity Bill'),
|
||||
]);
|
||||
});
|
||||
print("Initial data inserted successfully.");
|
||||
} else {
|
||||
print("Database already contains data (${existingTransactions.length} transactions). Skipping initial data insertion.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для открытия соединения с базой данных
|
||||
LazyDatabase _openConnection() {
|
||||
// Вычисление пути к файлу базы данных
|
||||
// Вычисление пути к файлу базы данных в папке документов приложения
|
||||
return LazyDatabase(() async {
|
||||
final dbFolder = await getApplicationDocumentsDirectory();
|
||||
// Создаем файл 'db.sqlite' в этой папке
|
||||
final file = File(p.join(dbFolder.path, 'db.sqlite'));
|
||||
print("Database file path: ${file.path}"); // Логируем путь для отладки
|
||||
|
||||
// При необходимости можно включить логирование SQL-запросов
|
||||
// return NativeDatabase(file, logStatements: true);
|
||||
return NativeDatabase(file);
|
||||
// Используем NativeDatabase для открытия соединения
|
||||
// logStatements: true полезен для отладки SQL-запросов
|
||||
return NativeDatabase(file, logStatements: false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app.dart'; // Import the new app root widget
|
||||
import 'database/database.dart'; // Import the database
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp()); // Run the app using the MyApp widget
|
||||
Future<void> main() async {
|
||||
// Необходимо для асинхронных операций перед runApp, например, инициализации БД
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Создаем единственный экземпляр базы данных для всего приложения
|
||||
final database = AppDatabase();
|
||||
|
||||
// Опционально: Вставляем начальные данные, если база данных пуста
|
||||
// Это полезно для первого запуска или демонстрации
|
||||
await database.insertInitialDataIfNeeded();
|
||||
|
||||
// Запускаем приложение, передавая экземпляр базы данных
|
||||
runApp(MyApp(database: database));
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import 'package:drift/drift.dart' show Value; // Only import Value for optional fields
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:fl_chart/fl_chart.dart'; // Used indirectly by SpendingPieChart
|
||||
import 'package:intl/intl.dart'; // For date formatting
|
||||
import 'dart:async';
|
||||
import 'dart:math'; // For random data generation in sample add
|
||||
|
||||
import '../models/transaction.dart';
|
||||
import '../models/category.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
import '../models/category.dart'; // Keep Category model for UI structure (SpendingPieChart)
|
||||
import '../widgets/summary_item.dart';
|
||||
import '../widgets/expandable_section.dart';
|
||||
import '../widgets/spending_pie_chart.dart';
|
||||
import '../widgets/transaction_list_item.dart';
|
||||
import '../widgets/filter_chip_widget.dart';
|
||||
import 'profile_screen.dart';
|
||||
import '../utils/category_utils.dart'; // Import category utils
|
||||
import 'profile_screen.dart'; // Import profile screen
|
||||
|
||||
class ExpensesScreen extends StatefulWidget {
|
||||
final Function toggleTheme;
|
||||
final bool isDarkMode;
|
||||
final db.AppDatabase database; // Accept database instance
|
||||
|
||||
const ExpensesScreen({
|
||||
Key? key,
|
||||
required this.toggleTheme,
|
||||
required this.isDarkMode,
|
||||
required this.database, // Require database instance
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -27,441 +32,553 @@ class ExpensesScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStateMixin {
|
||||
late AnimationController _listAnimationController;
|
||||
// Animation controllers for UI elements
|
||||
late AnimationController _pieChartAnimationController;
|
||||
late Animation<double> _pieChartAnimation;
|
||||
late AnimationController _pieChartExpandController;
|
||||
late Animation<double> _pieChartHeightFactor;
|
||||
|
||||
final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
|
||||
final List<Transaction> _transactions = [
|
||||
Transaction('Groceries', 45.99, Icons.shopping_cart, Colors.green, DateTime.now().subtract(const Duration(days: 1)), 'Whole Foods Market'),
|
||||
Transaction('Subscriptions', 39.99, Icons.subscriptions, Colors.orange, DateTime.now().subtract(const Duration(days: 2)), 'Netflix Premium'),
|
||||
Transaction('Restaurant', 78.50, Icons.restaurant, Colors.red, DateTime.now().subtract(const Duration(days: 2)), 'Italian Corner'),
|
||||
Transaction('Shopping', 132.75, Icons.shopping_bag, Colors.blue, DateTime.now().subtract(const Duration(days: 3)), 'Apple Store'),
|
||||
Transaction('Groceries', 23.45, Icons.shopping_cart, Colors.green, DateTime.now().subtract(const Duration(days: 4)), 'Local Market'),
|
||||
Transaction('Restaurant', 56.80, Icons.restaurant, Colors.red, DateTime.now().subtract(const Duration(days: 5)), 'Sushi Express'),
|
||||
];
|
||||
|
||||
// Use a separate list for the AnimatedList to manage insertions/removals
|
||||
final List<Transaction> _animatedListTransactions = [];
|
||||
|
||||
int _selectedPieIndex = -1;
|
||||
int _selectedNavIndex = 0;
|
||||
bool _isPieChartExpanded = true;
|
||||
bool _isFilterVisible = false;
|
||||
String _selectedFilter = 'All';
|
||||
|
||||
// Sample category data (should ideally be derived from transactions)
|
||||
final List<Category> _categories = [
|
||||
Category('Groceries', 69.44, Colors.green, Icons.shopping_cart), // Sum of Groceries
|
||||
Category('Subscriptions', 39.99, Colors.orange, Icons.subscriptions),
|
||||
Category('Restaurant', 135.30, Colors.red, Icons.restaurant), // Sum of Restaurant
|
||||
Category('Shopping', 132.75, Colors.blue, Icons.shopping_bag),
|
||||
];
|
||||
// State variables
|
||||
int _selectedPieIndex = -1; // Index of the selected pie chart slice
|
||||
int _selectedNavIndex = 0; // Index for bottom navigation bar
|
||||
bool _isPieChartExpanded = true; // Controls visibility of the pie chart section
|
||||
bool _isFilterVisible = false; // Controls visibility of the filter chips
|
||||
String _selectedFilter = 'All'; // Currently selected transaction filter
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Controller for list item animations
|
||||
_listAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500), // Adjust duration as needed
|
||||
);
|
||||
|
||||
// Controller for pie chart appearance animation
|
||||
// Initialize animation controller for pie chart fade/scale effect
|
||||
_pieChartAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800), // Slower fade/scale in
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
|
||||
_pieChartAnimation = CurvedAnimation(
|
||||
parent: _pieChartAnimationController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
// Controller for pie chart expand/collapse animation
|
||||
// Initialize animation controller for pie chart expand/collapse effect
|
||||
_pieChartExpandController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
value: 1.0, // Start expanded
|
||||
);
|
||||
|
||||
_pieChartHeightFactor = CurvedAnimation(
|
||||
parent: _pieChartExpandController,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
|
||||
// Start animations
|
||||
// Start the pie chart appearance animation
|
||||
_pieChartAnimationController.forward();
|
||||
_loadInitialTransactions(); // Load transactions with animation
|
||||
}
|
||||
|
||||
void _loadInitialTransactions() {
|
||||
// Animate list items appearing one by one
|
||||
Future.delayed(const Duration(milliseconds: 500), () { // Start list animation after pie chart starts
|
||||
for (int i = 0; i < _transactions.length; i++) {
|
||||
Timer(Duration(milliseconds: 150 * i), () {
|
||||
if (mounted && _listKey.currentState != null) {
|
||||
_animatedListTransactions.add(_transactions[i]);
|
||||
_listKey.currentState!.insertItem(_animatedListTransactions.length - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_listAnimationController.dispose();
|
||||
// Dispose controllers to free up resources
|
||||
_pieChartAnimationController.dispose();
|
||||
_pieChartExpandController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get totalExpenses => _categories.fold(0, (sum, item) => sum + item.amount);
|
||||
// Stream that provides transactions based on the selected filter
|
||||
Stream<List<db.Transaction>> _watchTransactions() {
|
||||
return widget.database.watchFilteredTransactions(_selectedFilter);
|
||||
}
|
||||
|
||||
// Stream that provides category totals calculated from transactions
|
||||
Stream<List<Category>> _watchCategoryTotals() {
|
||||
return widget.database.calculateCategoryTotals();
|
||||
}
|
||||
|
||||
// Toggles the visibility of the pie chart section with animation
|
||||
void _togglePieChartVisibility() {
|
||||
setState(() {
|
||||
_isPieChartExpanded = !_isPieChartExpanded;
|
||||
if (_isPieChartExpanded) {
|
||||
_pieChartExpandController.forward();
|
||||
_pieChartExpandController.forward(); // Expand animation
|
||||
} else {
|
||||
_pieChartExpandController.reverse();
|
||||
_pieChartExpandController.reverse(); // Collapse animation
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggles the visibility of the filter chip row
|
||||
void _toggleFilterVisibility() {
|
||||
setState(() {
|
||||
_isFilterVisible = !_isFilterVisible;
|
||||
});
|
||||
}
|
||||
|
||||
// Handles selection of a pie chart slice
|
||||
void _selectPieCategory(int index) {
|
||||
setState(() {
|
||||
_selectedPieIndex = index; // No need to toggle off here, handled by PieTouchData
|
||||
// If the same index is selected, deselect (-1), otherwise select the new index
|
||||
_selectedPieIndex = (_selectedPieIndex == index) ? -1 : index;
|
||||
});
|
||||
// Optionally apply filter when pie slice is selected/deselected
|
||||
// This requires getting the category name from the index, potentially async
|
||||
// _applyFilterBasedOnPieSelection(index);
|
||||
}
|
||||
|
||||
// Applies the selected filter to the transaction list
|
||||
void _applyFilter(String filter) {
|
||||
setState(() {
|
||||
_selectedFilter = filter;
|
||||
// TODO: Implement actual filtering logic for the transaction list
|
||||
// This might involve removing/inserting items in the AnimatedList
|
||||
// based on the selected filter. For now, just updates the chip state.
|
||||
// The StreamBuilder listening to _watchTransactions() will automatically
|
||||
// rebuild with the new stream based on the updated _selectedFilter.
|
||||
});
|
||||
}
|
||||
|
||||
// --- Function to add a sample transaction (for testing/demo) ---
|
||||
void _addSampleTransaction() async {
|
||||
final random = Random();
|
||||
// Get available category names from our utility class
|
||||
final categories = CategoryUtils.getAllCategoryNames();
|
||||
// Pick a random category, amount, date, and merchant
|
||||
final randomCategory = categories[random.nextInt(categories.length)];
|
||||
final randomAmount = (random.nextDouble() * 100) + 5; // Amount between 5 and 105
|
||||
final randomDay = random.nextInt(7); // Within the last week
|
||||
final randomHour = random.nextInt(24);
|
||||
final randomMerchant = ['Amazon', 'Local Cafe', 'Gas Station', 'Online Store', 'Supermarket'][random.nextInt(5)];
|
||||
|
||||
// Create a companion object for inserting into the Drift table
|
||||
final newTransaction = db.TransactionsCompanion.insert(
|
||||
categoryName: randomCategory,
|
||||
amount: randomAmount,
|
||||
date: DateTime.now().subtract(Duration(days: randomDay, hours: randomHour)),
|
||||
merchant: randomMerchant,
|
||||
);
|
||||
|
||||
try {
|
||||
// Insert the transaction into the database
|
||||
await widget.database.addTransaction(newTransaction);
|
||||
print('Sample transaction added: $randomCategory - \$${randomAmount.toStringAsFixed(2)}');
|
||||
// Show a confirmation message
|
||||
if (mounted) { // Check if the widget is still in the tree
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Added ${randomCategory} transaction'),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error adding transaction: $e');
|
||||
// Show an error message
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error adding transaction: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- End of add sample transaction function ---
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context); // Get theme for easier access
|
||||
final theme = Theme.of(context); // Get theme for easier access to styles
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// Title with icon
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_balance_wallet,
|
||||
color: isDark ? Colors.greenAccent : Colors.green.shade700,
|
||||
Icons.account_balance_wallet_outlined, // Updated icon
|
||||
color: isDark ? Colors.greenAccent.shade100 : Colors.green.shade800,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Finances'), // Title uses AppBarTheme's textStyle
|
||||
const Text('My Finances'), // Updated title
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
// Theme toggle button
|
||||
leading: IconButton(
|
||||
tooltip: isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode',
|
||||
icon: Icon(
|
||||
widget.isDarkMode ? Icons.wb_sunny_outlined : Icons.nightlight_round,
|
||||
color: widget.isDarkMode ? Colors.yellow : Colors.blue.shade700,
|
||||
color: widget.isDarkMode ? Colors.yellow.shade300 : Colors.blue.shade700,
|
||||
),
|
||||
onPressed: () => widget.toggleTheme(),
|
||||
),
|
||||
// Profile avatar button
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
padding: const EdgeInsets.only(right: 12.0), // Adjusted padding
|
||||
child: Hero(
|
||||
tag: 'profileAvatar', // Tag must match the one in ProfileScreen
|
||||
child: Material( // Wrap with Material for Hero animation
|
||||
type: MaterialType.transparency,
|
||||
child: CircleAvatar(
|
||||
backgroundColor: isDark ? Colors.green.shade800 : Colors.green.shade100,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.person, color: Colors.green),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => const ProfileScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 300), // Adjust duration
|
||||
),
|
||||
);
|
||||
},
|
||||
tag: 'profileAvatar', // Tag for Hero animation
|
||||
child: Material(
|
||||
type: MaterialType.transparency, // Needed for Hero animation across routes
|
||||
child: IconButton(
|
||||
tooltip: 'View Profile',
|
||||
icon: CircleAvatar(
|
||||
radius: 18, // Slightly smaller avatar
|
||||
backgroundColor: isDark ? Colors.green.shade800 : Colors.green.shade100,
|
||||
child: const Icon(Icons.person_outline, color: Colors.green, size: 20),
|
||||
),
|
||||
onPressed: () {
|
||||
// Navigate to ProfileScreen with a fade transition
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (_, __, ___) => const ProfileScreen(),
|
||||
transitionsBuilder: (_, animation, __, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 350), // Adjusted duration
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// elevation is handled by AppBarTheme
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Summary Card
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card( // Uses CardTheme
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Total Expenses',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontSize: 16), // Use theme text style
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// Use StreamBuilder to get category totals for the summary and pie chart
|
||||
body: StreamBuilder<List<Category>>(
|
||||
stream: _watchCategoryTotals(),
|
||||
builder: (context, categorySnapshot) {
|
||||
// Handle loading state
|
||||
if (categorySnapshot.connectionState == ConnectionState.waiting && !categorySnapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
// Handle error state
|
||||
if (categorySnapshot.hasError) {
|
||||
return Center(child: Text('Error loading categories: ${categorySnapshot.error}'));
|
||||
}
|
||||
|
||||
// Get categories data (or empty list if null)
|
||||
final categories = categorySnapshot.data ?? [];
|
||||
// Calculate total expenses from the categories stream data
|
||||
final totalExpenses = categories.fold(0.0, (sum, item) => sum + item.amount);
|
||||
|
||||
// Main column layout
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Stretch children horizontally
|
||||
children: [
|
||||
Expanded(
|
||||
// Use SingleChildScrollView for content that might overflow
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(), // iOS-like scroll physics
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// --- Summary Card ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Card(
|
||||
elevation: 2, // Subtle shadow
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0), // Adjusted padding
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.green.shade300 : Colors.green,
|
||||
),
|
||||
Text(
|
||||
'Total Expenses This Period', // More descriptive title
|
||||
style: theme.textTheme.titleMedium, // Use theme style
|
||||
),
|
||||
Text(
|
||||
totalExpenses.toStringAsFixed(2),
|
||||
style: TextStyle(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.green.shade300 : Colors.green,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Display total expenses amount
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // Align baseline
|
||||
children: [
|
||||
Text(
|
||||
'\$',
|
||||
style: TextStyle(
|
||||
fontSize: 20, // Smaller dollar sign
|
||||
fontWeight: FontWeight.w500, // Medium weight
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalExpenses), // Format number
|
||||
style: TextStyle(
|
||||
fontSize: 36, // Slightly smaller amount
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
letterSpacing: -1, // Tighten spacing
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Divider line
|
||||
Divider(height: 1, thickness: 1, indent: 20, endIndent: 20, color: theme.dividerColor.withOpacity(0.5)),
|
||||
const SizedBox(height: 16),
|
||||
// Income / Expenses summary items
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround, // Distribute space
|
||||
children: [
|
||||
const SummaryItem( // Example Income - Replace with real data
|
||||
icon: Icons.arrow_downward_rounded,
|
||||
title: 'Income',
|
||||
amount: '\$2,450.00',
|
||||
color: Colors.green,
|
||||
),
|
||||
// Vertical divider
|
||||
Container(
|
||||
height: 35,
|
||||
width: 1,
|
||||
color: theme.dividerColor.withOpacity(0.5),
|
||||
),
|
||||
SummaryItem( // Expenses using calculated total
|
||||
icon: Icons.arrow_upward_rounded,
|
||||
title: 'Expenses',
|
||||
amount: '\$${NumberFormat.currency(symbol: '', decimalDigits: 2).format(totalExpenses)}',
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
SummaryItem( // Use SummaryItem widget
|
||||
icon: Icons.arrow_downward,
|
||||
title: 'Income',
|
||||
amount: '\$2,450.00', // Example data
|
||||
color: Colors.green,
|
||||
),
|
||||
Container(
|
||||
height: 30,
|
||||
width: 1,
|
||||
color: isDark ? Colors.grey.shade700 : Colors.grey.shade300,
|
||||
),
|
||||
SummaryItem( // Use SummaryItem widget
|
||||
icon: Icons.arrow_upward,
|
||||
title: 'Expenses',
|
||||
amount: '\$${totalExpenses.toStringAsFixed(2)}', // Use calculated total
|
||||
color: Colors.red,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Pie chart section
|
||||
ExpandableSection( // Use ExpandableSection widget
|
||||
title: 'Spending Breakdown',
|
||||
icon: Icons.pie_chart,
|
||||
isExpanded: _isPieChartExpanded,
|
||||
onTap: _togglePieChartVisibility,
|
||||
heightFactor: _pieChartHeightFactor,
|
||||
child: SpendingPieChart( // Use SpendingPieChart widget
|
||||
categories: _categories,
|
||||
totalExpenses: totalExpenses,
|
||||
selectedPieIndex: _selectedPieIndex,
|
||||
onSelectPieCategory: _selectPieCategory,
|
||||
animation: _pieChartAnimation,
|
||||
),
|
||||
),
|
||||
// --- Pie Chart Section ---
|
||||
ExpandableSection(
|
||||
title: 'Spending Breakdown',
|
||||
icon: Icons.pie_chart_outline_rounded, // Updated icon
|
||||
isExpanded: _isPieChartExpanded,
|
||||
onTap: _togglePieChartVisibility,
|
||||
heightFactor: _pieChartHeightFactor, // Animation controller
|
||||
child: SpendingPieChart(
|
||||
categories: categories, // Pass categories from stream snapshot
|
||||
totalExpenses: totalExpenses, // Pass calculated total
|
||||
selectedPieIndex: _selectedPieIndex,
|
||||
onSelectPieCategory: _selectPieCategory, // Callback for selection
|
||||
animation: _pieChartAnimation, // Appearance animation
|
||||
),
|
||||
),
|
||||
|
||||
// Transaction list section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header section
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Transactions',
|
||||
style: theme.textTheme.titleMedium?.copyWith( // Use theme text style
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.grey.shade800,
|
||||
// --- Transaction List Section ---
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with Title and Filter/See All buttons
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 4), // Adjusted padding
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Recent Transactions',
|
||||
style: theme.textTheme.titleLarge?.copyWith( // Use theme style
|
||||
fontWeight: FontWeight.w600, // Bold weight
|
||||
),
|
||||
),
|
||||
// Filter button
|
||||
Row(
|
||||
children: [
|
||||
InkWell( // Use InkWell for ripple effect
|
||||
onTap: _toggleFilterVisibility,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey.shade800 : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_selectedFilter, // Display current filter
|
||||
style: TextStyle(
|
||||
fontSize: 13, // Smaller font
|
||||
color: isDark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.filter_list_alt, // Updated icon
|
||||
size: 18, // Slightly larger icon
|
||||
color: isDark ? Colors.white70 : Colors.black87,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// "See All" button (optional)
|
||||
// TextButton(
|
||||
// onPressed: () { /* Navigate to full list */ },
|
||||
// child: Text('See All'),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- Filter Chips Row (Animated Visibility) ---
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300), // Animation duration
|
||||
curve: Curves.easeInOut, // Animation curve
|
||||
height: _isFilterVisible ? 50 : 0, // Animate height
|
||||
clipBehavior: Clip.hardEdge, // Prevent overflow during animation
|
||||
decoration: const BoxDecoration(), // Needed for clipBehavior
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: _isFilterVisible ? 8 : 0, // Animate padding
|
||||
),
|
||||
child: ListView( // Use ListView for horizontal scrolling
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: [
|
||||
// "All" filter chip
|
||||
FilterChipWidget(
|
||||
label: 'All',
|
||||
isSelected: _selectedFilter == 'All',
|
||||
onTap: () => _applyFilter('All')
|
||||
),
|
||||
// Dynamically generate filter chips from categories
|
||||
...CategoryUtils.getAllCategoryNames().map((name) =>
|
||||
FilterChipWidget(
|
||||
label: name,
|
||||
isSelected: _selectedFilter == name,
|
||||
onTap: () => _applyFilter(name)
|
||||
)
|
||||
).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// --- Transactions List (using StreamBuilder) ---
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Card( // Wrap list in a Card for background/border
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0, // No shadow for inner card
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.dividerColor.withOpacity(0.5), width: 1) // Subtle border
|
||||
),
|
||||
clipBehavior: Clip.antiAlias, // Clip list items to card shape
|
||||
child: StreamBuilder<List<db.Transaction>>(
|
||||
stream: _watchTransactions(), // Stream based on _selectedFilter
|
||||
builder: (context, transactionSnapshot) {
|
||||
// Handle loading state for transactions
|
||||
if (transactionSnapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(
|
||||
height: 150, // Placeholder height
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
);
|
||||
}
|
||||
// Handle error state for transactions
|
||||
if (transactionSnapshot.hasError) {
|
||||
return SizedBox(
|
||||
height: 150,
|
||||
child: Center(child: Text('Error: ${transactionSnapshot.error}')),
|
||||
);
|
||||
}
|
||||
// Get transaction data (or empty list)
|
||||
final transactions = transactionSnapshot.data ?? [];
|
||||
|
||||
// Display message if no transactions match the filter
|
||||
if (transactions.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 150,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_selectedFilter == 'All'
|
||||
? 'No transactions yet.'
|
||||
: 'No transactions found for $_selectedFilter.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Use ListView.builder to display transactions efficiently
|
||||
return ListView.separated(
|
||||
itemCount: transactions.length,
|
||||
physics: const NeverScrollableScrollPhysics(), // Disable inner scrolling
|
||||
shrinkWrap: true, // Fit content height
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0), // Padding inside the card
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1, thickness: 1, indent: 16, endIndent: 16,
|
||||
color: theme.dividerColor.withOpacity(0.3),
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final transaction = transactions[index];
|
||||
// Use the TransactionListItem widget
|
||||
// Pass a dummy animation for FadeTransition inside item
|
||||
return TransactionListItem(
|
||||
transaction: transaction,
|
||||
animation: kAlwaysCompleteAnimation, // Required by FadeTransition
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _toggleFilterVisibility,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? Colors.grey.shade800 : Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_selectedFilter,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.filter_list,
|
||||
size: 16,
|
||||
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to 'See All' transactions screen
|
||||
},
|
||||
child: Text(
|
||||
'See All',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.green.shade300 : Colors.green,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Filter options
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: _isFilterVisible ? 44 : 0,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: _isFilterVisible ? 6 : 0,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
FilterChipWidget(label: 'All', isSelected: _selectedFilter == 'All', onTap: () => _applyFilter('All')),
|
||||
FilterChipWidget(label: 'Groceries', isSelected: _selectedFilter == 'Groceries', onTap: () => _applyFilter('Groceries')),
|
||||
FilterChipWidget(label: 'Subscriptions', isSelected: _selectedFilter == 'Subscriptions', onTap: () => _applyFilter('Subscriptions')),
|
||||
FilterChipWidget(label: 'Restaurant', isSelected: _selectedFilter == 'Restaurant', onTap: () => _applyFilter('Restaurant')),
|
||||
FilterChipWidget(label: 'Shopping', isSelected: _selectedFilter == 'Shopping', onTap: () => _applyFilter('Shopping')),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16), // Bottom padding inside scroll view
|
||||
],
|
||||
),
|
||||
|
||||
// Transactions list
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
child: Card( // Uses CardTheme
|
||||
margin: EdgeInsets.zero,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 200), // Adjust min height as needed
|
||||
child: AnimatedList(
|
||||
key: _listKey,
|
||||
initialItemCount: _animatedListTransactions.length, // Start with items added in initState
|
||||
physics: const NeverScrollableScrollPhysics(), // Disable scrolling within the list itself
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemBuilder: (context, index, animation) {
|
||||
// Use the TransactionListItem widget
|
||||
return TransactionListItem(
|
||||
transaction: _animatedListTransactions[index],
|
||||
animation: animation, // Pass animation controller
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16), // Bottom padding inside scroll view
|
||||
const SizedBox(height: 80), // Extra bottom padding below list to avoid FAB overlap
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12), // Extra bottom padding
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
// Bottom Navigation Bar
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _selectedNavIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedNavIndex = index;
|
||||
// TODO: Handle navigation based on index (e.g., switch screens)
|
||||
});
|
||||
},
|
||||
items: const [ // Use const for static items
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_filled), // Use filled icon for selected state
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.bar_chart_rounded),
|
||||
label: 'Reports',
|
||||
),
|
||||
// Example with Badge (replace with actual notification count)
|
||||
// BottomNavigationBarItem(
|
||||
// icon: Badge(
|
||||
// label: Text('3'), // Example badge count
|
||||
// child: Icon(Icons.notifications_none_rounded),
|
||||
// ),
|
||||
// activeIcon: Badge( // Optional: different badge style when active
|
||||
// label: Text('3'),
|
||||
// child: Icon(Icons.notifications_rounded),
|
||||
// ),
|
||||
// label: 'Notifications',
|
||||
// ),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
activeIcon: Icon(Icons.settings), // Filled icon when active
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
child: BottomNavigationBar( // Uses BottomNavigationBarTheme
|
||||
currentIndex: _selectedNavIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_selectedNavIndex = index;
|
||||
// TODO: Handle navigation based on index
|
||||
});
|
||||
},
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_rounded),
|
||||
label: 'Home',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Badge( // Example badge
|
||||
label: const Text('3'),
|
||||
child: const Icon(Icons.bar_chart_rounded),
|
||||
),
|
||||
label: 'Reports',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.settings_rounded),
|
||||
label: 'Settings',
|
||||
),
|
||||
],
|
||||
),
|
||||
// Floating Action Button to add new transaction
|
||||
floatingActionButton: FloatingActionButton.extended( // Use extended FAB
|
||||
onPressed: _addSampleTransaction, // Add sample data on press
|
||||
tooltip: 'Add Transaction',
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add'),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton( // Uses FloatingActionButtonTheme
|
||||
onPressed: () {
|
||||
// TODO: Add new transaction logic
|
||||
},
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder( // Consistent shape
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, // Standard location
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/category.dart'; // Import the Category model for return types
|
||||
|
||||
// Helper class to manage category details (icon, color) based on name
|
||||
class CategoryUtils {
|
||||
// Private map storing details for each predefined category name.
|
||||
// Using a record `({IconData icon, Color color})` for concise structure.
|
||||
static final Map<String, ({IconData icon, Color color})> _categoryDetails = {
|
||||
'Groceries': (icon: Icons.shopping_cart_outlined, color: Colors.green.shade400),
|
||||
'Subscriptions': (icon: Icons.subscriptions_outlined, color: Colors.orange.shade400),
|
||||
'Restaurant': (icon: Icons.restaurant_menu_outlined, color: Colors.red.shade400),
|
||||
'Shopping': (icon: Icons.shopping_bag_outlined, color: Colors.blue.shade400),
|
||||
'Transport': (icon: Icons.directions_bus_filled_outlined, color: Colors.purple.shade400),
|
||||
'Travel': (icon: Icons.flight_takeoff_outlined, color: Colors.cyan.shade400),
|
||||
'Utilities': (icon: Icons.lightbulb_outline, color: Colors.yellow.shade700),
|
||||
'Health': (icon: Icons.local_hospital_outlined, color: Colors.pink.shade300),
|
||||
'Entertainment': (icon: Icons.movie_filter_outlined, color: Colors.teal.shade400),
|
||||
'Other': (icon: Icons.category_outlined, color: Colors.grey.shade500), // Default/fallback
|
||||
};
|
||||
|
||||
// Default details to return if a category name is not found in the map.
|
||||
static final _defaultDetails = (icon: Icons.category_outlined, color: Colors.grey.shade500);
|
||||
|
||||
/// Returns the IconData and Color associated with a given category name.
|
||||
///
|
||||
/// If the `categoryName` is found in the predefined map, its details are returned.
|
||||
/// Otherwise, default icon and color are returned.
|
||||
static ({IconData icon, Color color}) getCategoryDetails(String categoryName) {
|
||||
// Use the null-aware operator `??` to provide default values if the key doesn't exist.
|
||||
return _categoryDetails[categoryName] ?? _defaultDetails;
|
||||
}
|
||||
|
||||
/// Returns a `Category` object based on its name and amount.
|
||||
///
|
||||
/// This is useful when you have the name and amount (e.g., from database aggregation)
|
||||
/// and need to construct a full `Category` object including the icon and color.
|
||||
static Category getCategoryByName(String name, double amount) {
|
||||
// Retrieve the icon and color using the getCategoryDetails method.
|
||||
final details = getCategoryDetails(name);
|
||||
// Construct and return the Category object.
|
||||
return Category(name, amount, details.color, details.icon);
|
||||
}
|
||||
|
||||
/// Returns a list of all predefined category names.
|
||||
///
|
||||
/// Useful for generating UI elements like filter chips or dropdowns.
|
||||
static List<String> getAllCategoryNames() {
|
||||
// Return the keys from the details map as a list.
|
||||
return _categoryDetails.keys.toList();
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import '../models/category.dart';
|
||||
import 'package:intl/intl.dart'; // For number formatting
|
||||
|
||||
import '../models/category.dart'; // Keep using the Category model for UI structure
|
||||
|
||||
class SpendingPieChart extends StatelessWidget {
|
||||
final List<Category> categories;
|
||||
final List<Category> categories; // Expect List<Category> from database calculation
|
||||
final double totalExpenses;
|
||||
final int selectedPieIndex;
|
||||
final Function(int) onSelectPieCategory;
|
||||
final Animation<double> animation;
|
||||
final Function(int) onSelectPieCategory; // Callback when a slice is selected/deselected
|
||||
final Animation<double> animation; // For fade/scale animation
|
||||
|
||||
const SpendingPieChart({
|
||||
Key? key,
|
||||
@@ -21,112 +23,90 @@ class SpendingPieChart extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Handle the case where there are no categories to display
|
||||
if (categories.isEmpty) {
|
||||
return AnimatedBuilder( // Still use animation for consistency
|
||||
animation: animation,
|
||||
builder: (context, child) => Opacity(
|
||||
opacity: animation.value,
|
||||
child: Container(
|
||||
height: 230, // Maintain similar height to the chart version
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'No spending data for this period.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Use AnimatedBuilder to apply the fade/scale animation
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: animation.value,
|
||||
scale: animation.value, // Apply scale animation
|
||||
child: Opacity(
|
||||
opacity: animation.value,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
opacity: animation.value, // Apply fade animation
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0), // Reduced vertical padding
|
||||
height: 230, // Fixed height for the chart and legend area
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: _generatePieSections(context),
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
pieTouchResponse.touchedSection == null) {
|
||||
// If touch ends or no section is touched, reset selection
|
||||
// Используем FlTapUpEvent вместо FlPointerUpEvent
|
||||
if (event is FlTapUpEvent || event is FlPanEndEvent) {
|
||||
onSelectPieCategory(-1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSelectPieCategory(pieTouchResponse.touchedSection!.touchedSectionIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Центральный интерактивный элемент
|
||||
Positioned.fill(
|
||||
child: selectedPieIndex != -1
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${(categories[selectedPieIndex].amount / totalExpenses * 100).toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'\$${categories[selectedPieIndex].amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: isDark ? Colors.grey.shade300 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// --- Pie Chart ---
|
||||
Expanded(
|
||||
flex: 5, // Give more space to the chart itself
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
// Handle touch events on the pie chart
|
||||
pieTouchData: PieTouchData(
|
||||
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
||||
// Ignore events not related to interaction
|
||||
if (!event.isInterestedForInteractions ||
|
||||
pieTouchResponse == null ||
|
||||
pieTouchResponse.touchedSection == null) {
|
||||
// If touch ends outside a section, deselect
|
||||
if (event is FlPanEndEvent || event is FlTapUpEvent) {
|
||||
if (selectedPieIndex != -1) {
|
||||
onSelectPieCategory(-1); // Deselect
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Get the index of the touched section
|
||||
final touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
|
||||
// Call the callback, toggling selection if the same slice is touched again
|
||||
onSelectPieCategory(touchedIndex == selectedPieIndex ? -1 : touchedIndex);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Обернем легенду в SingleChildScrollView
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(categories.length, (i) {
|
||||
return _buildPieLegendItem(context, i);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
borderData: FlBorderData(show: false), // No border around the chart
|
||||
sectionsSpace: 2, // Space between slices
|
||||
centerSpaceRadius: 50, // Radius of the center hole
|
||||
sections: _generatePieSections(context), // Generate slices data
|
||||
startDegreeOffset: -90, // Start chart from the top (12 o'clock)
|
||||
),
|
||||
// Optional animation when data changes
|
||||
swapAnimationDuration: const Duration(milliseconds: 250),
|
||||
swapAnimationCurve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing between chart and legend
|
||||
|
||||
// Monthly insight text
|
||||
if (selectedPieIndex != -1)
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
'You spent ${categories[selectedPieIndex].amount.toStringAsFixed(2)} on ${categories[selectedPieIndex].name} this month',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
color: isDark ? Colors.grey.shade300 : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 16), // Placeholder to maintain layout consistency
|
||||
// --- Legend ---
|
||||
Expanded(
|
||||
flex: 4, // Allocate space for the legend
|
||||
// Use ListView for scrollable legend if many categories
|
||||
child: ListView.builder(
|
||||
itemCount: categories.length,
|
||||
padding: const EdgeInsets.only(right: 8), // Padding for legend items
|
||||
itemBuilder: (context, index) => _buildPieLegendItem(context, index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -136,52 +116,66 @@ class SpendingPieChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Builds a single item for the legend
|
||||
Widget _buildPieLegendItem(BuildContext context, int index) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final isSelected = index == selectedPieIndex;
|
||||
final theme = Theme.of(context);
|
||||
final isSelected = index == selectedPieIndex; // Check if this item is selected
|
||||
final category = categories[index];
|
||||
final fraction = totalExpenses > 0 ? (category.amount / totalExpenses * 100).toStringAsFixed(1) : '0.0';
|
||||
// Calculate percentage, handle totalExpenses being zero
|
||||
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onSelectPieCategory(isSelected ? -1 : index), // Toggle selection
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
// Use InkWell for tap feedback and GestureDetector for tap logic
|
||||
return InkWell(
|
||||
onTap: () => onSelectPieCategory(isSelected ? -1 : index), // Toggle selection on tap
|
||||
borderRadius: BorderRadius.circular(8), // Match border radius
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200), // Animation for selection change
|
||||
margin: const EdgeInsets.symmetric(vertical: 3.0), // Spacing between legend items
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), // Padding inside item
|
||||
decoration: BoxDecoration(
|
||||
// Highlight background if selected
|
||||
color: isSelected
|
||||
? (isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade200.withOpacity(0.7))
|
||||
? category.color.withOpacity(isDark ? 0.3 : 0.15)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
// Add border if selected
|
||||
border: Border.all(
|
||||
color: isSelected ? category.color.withOpacity(0.8) : Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Color indicator dot
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: category.color.withOpacity(isDark ? 0.8 : 1.0),
|
||||
shape: BoxShape.circle,
|
||||
color: category.color.withOpacity(isDark ? 0.9 : 1.0), // Use category color
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
category.name,
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white : Colors.grey.shade800,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing
|
||||
// Category name
|
||||
Expanded(
|
||||
child: Text(
|
||||
category.name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
||||
color: theme.textTheme.bodyLarge?.color, // Use default body text color
|
||||
),
|
||||
Text(
|
||||
'$fraction%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
overflow: TextOverflow.ellipsis, // Prevent long names from wrapping
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8), // Spacing
|
||||
// Percentage text
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(1)}%', // Format percentage
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, // Bold if selected
|
||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.7), // Slightly faded color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -189,27 +183,38 @@ class SpendingPieChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Generates the data for each slice (PieChartSectionData) of the pie chart
|
||||
List<PieChartSectionData> _generatePieSections(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return List.generate(categories.length, (i) {
|
||||
final isTouched = i == selectedPieIndex;
|
||||
final double radius = isTouched ? 70 : 60;
|
||||
final isTouched = i == selectedPieIndex; // Check if this slice is selected
|
||||
// Make selected slice slightly larger
|
||||
final double radius = isTouched ? 65 : 55;
|
||||
// Make title font slightly larger when selected
|
||||
final double titleFontSize = isTouched ? 14 : 12;
|
||||
final category = categories[i];
|
||||
// Calculate percentage for the title
|
||||
final percentage = totalExpenses > 0 ? (category.amount / totalExpenses * 100) : 0;
|
||||
|
||||
return PieChartSectionData(
|
||||
color: isDark ? category.color.withOpacity(0.8) : category.color,
|
||||
value: category.amount,
|
||||
title: '', // Removed title from pie segments for cleaner look
|
||||
radius: radius,
|
||||
badgeWidget: isTouched
|
||||
? Icon(
|
||||
category.icon,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
badgePositionPercentageOffset: 0.98,
|
||||
color: category.color.withOpacity(isDark ? 0.85 : 1.0), // Use category color
|
||||
value: category.amount, // Value determines the slice size
|
||||
title: '${percentage.toStringAsFixed(0)}%', // Display percentage as title
|
||||
radius: radius, // Apply radius (larger if touched)
|
||||
titleStyle: TextStyle(
|
||||
fontSize: titleFontSize, // Apply font size (larger if touched)
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white.withOpacity(0.9), // White text for contrast on colored slices
|
||||
shadows: const [Shadow(color: Colors.black38, blurRadius: 2)], // Subtle shadow for readability
|
||||
),
|
||||
// Add border to selected slice for emphasis
|
||||
borderSide: isTouched
|
||||
? BorderSide(color: isDark ? Colors.white60 : Colors.black54, width: 2)
|
||||
: BorderSide(color: category.color.withOpacity(0.5), width: 1),
|
||||
// Optional: Add badge (icon) to the selected slice
|
||||
// badgeWidget: isTouched ? Icon(category.icon, color: Colors.white, size: 16) : null,
|
||||
// badgePositionPercentageOffset: .98,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/transaction.dart';
|
||||
import '../database/database.dart' as db; // Import database with prefix 'db'
|
||||
import '../utils/category_utils.dart'; // Import category utils
|
||||
|
||||
class TransactionListItem extends StatelessWidget {
|
||||
final Transaction transaction;
|
||||
final Animation<double> animation;
|
||||
final db.Transaction transaction; // Use the Drift-generated Transaction class
|
||||
final Animation<double> animation; // Keep animation for potential future use
|
||||
|
||||
const TransactionListItem({
|
||||
Key? key,
|
||||
@@ -15,158 +16,104 @@ class TransactionListItem extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Format the date
|
||||
final dateFormatter = DateFormat.MMMd();
|
||||
String formattedDate = dateFormatter.format(transaction.date);
|
||||
// Get category details (icon, color) using the utility
|
||||
final categoryDetails = CategoryUtils.getCategoryDetails(transaction.categoryName);
|
||||
|
||||
// Format the time
|
||||
final timeFormatter = DateFormat.jm(); // Formats time like "3:30 PM"
|
||||
String formattedTime = timeFormatter.format(transaction.date);
|
||||
// Format the date and time using intl package
|
||||
final dateFormatter = DateFormat.MMMd(); // e.g., Sep 10
|
||||
final timeFormatter = DateFormat.jm(); // e.g., 5:08 PM
|
||||
|
||||
// Check if transaction is from today or yesterday
|
||||
// Determine if the date is today, yesterday, or another day
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = DateTime(now.year, now.month, now.day - 1);
|
||||
final transactionDate = DateTime(transaction.date.year, transaction.date.month, transaction.date.day);
|
||||
final transactionDay = DateTime(transaction.date.year, transaction.date.month, transaction.date.day);
|
||||
|
||||
if (transactionDate == today) {
|
||||
formattedDate = 'Today';
|
||||
} else if (transactionDate == yesterday) {
|
||||
formattedDate = 'Yesterday';
|
||||
String displayDate;
|
||||
if (transactionDay == today) {
|
||||
displayDate = 'Today, ${timeFormatter.format(transaction.date)}';
|
||||
} else if (transactionDay == yesterday) {
|
||||
displayDate = 'Yesterday, ${timeFormatter.format(transaction.date)}';
|
||||
} else {
|
||||
// Format for other dates (e.g., "Sep 10, 5:08 PM")
|
||||
displayDate = '${dateFormatter.format(transaction.date)}, ${timeFormatter.format(transaction.date)}';
|
||||
}
|
||||
|
||||
// Ultra-compact transaction item with slide transition
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(-1, 0),
|
||||
end: const Offset(0, 0),
|
||||
).animate(CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOut,
|
||||
)),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 6, top: 2), // Even smaller margins
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 65, // Fixed height to ensure consistency
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
onTap: () {
|
||||
// Handle tap, e.g., navigate to transaction details
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), // Smaller padding
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon
|
||||
Container(
|
||||
width: 36, // Smaller icon container
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: transaction.color.withOpacity(isDark ? 0.2 : 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
transaction.icon,
|
||||
color: transaction.color.withOpacity(isDark ? 0.9 : 1.0),
|
||||
size: 16, // Smaller icon
|
||||
),
|
||||
),
|
||||
// Determine text colors based on theme
|
||||
Color primaryTextColor = theme.textTheme.bodyLarge?.color ?? (isDark ? Colors.white : Colors.black87);
|
||||
Color secondaryTextColor = theme.textTheme.bodyMedium?.color ?? (isDark ? Colors.white70 : Colors.grey.shade600);
|
||||
Color amountColor = isDark ? Colors.red.shade200 : Colors.red.shade700; // Expense color
|
||||
|
||||
// Content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Title and merchant on same line
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
transaction.category,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'·', // Bullet separator
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
transaction.merchant,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Date
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 10,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formattedDate,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Amount and Time
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'- \$${transaction.amount.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.redAccent.shade100 : Colors.red.shade700,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
// Use FadeTransition for item appearance (works with ListView.builder)
|
||||
return FadeTransition(
|
||||
opacity: animation, // Apply fade animation
|
||||
child: InkWell( // Make the item tappable
|
||||
onTap: () {
|
||||
// TODO: Implement navigation to transaction details screen or edit action
|
||||
print('Tapped transaction: ${transaction.id}');
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), // Consistent padding
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon container with category color/icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: categoryDetails.color.withOpacity(isDark ? 0.25 : 0.15), // Use category color with opacity
|
||||
borderRadius: BorderRadius.circular(12), // Rounded corners
|
||||
),
|
||||
child: Icon(
|
||||
categoryDetails.icon, // Use category icon
|
||||
color: categoryDetails.color, // Use category color for icon
|
||||
size: 20, // Icon size
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12), // Spacing
|
||||
|
||||
// Transaction details (Merchant/Category and Date/Time)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display Merchant if available, otherwise Category Name
|
||||
Text(
|
||||
transaction.merchant.isNotEmpty ? transaction.merchant : transaction.categoryName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500, // Medium weight for primary text
|
||||
color: primaryTextColor,
|
||||
),
|
||||
maxLines: 1, // Prevent wrapping
|
||||
overflow: TextOverflow.ellipsis, // Handle long text
|
||||
),
|
||||
const SizedBox(height: 4), // Spacing between lines
|
||||
// Display formatted date/time
|
||||
Text(
|
||||
displayDate,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: secondaryTextColor, // Lighter color for secondary text
|
||||
fontSize: 12, // Smaller font size
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12), // Spacing before amount
|
||||
|
||||
// Transaction Amount
|
||||
Text(
|
||||
// Format amount as currency (negative for expense)
|
||||
NumberFormat.currency(symbol: '-\$', decimalDigits: 2).format(transaction.amount),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600, // Bold weight for amount
|
||||
color: amountColor, // Use expense color
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user