feat: Implement Drift database for transaction management

This commit is contained in:
2025-05-03 16:13:53 +03:00
parent 8089604139
commit 1724d28baf
7 changed files with 856 additions and 628 deletions
+16 -7
View File
@@ -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, // Передаем текущее состояние темы
),
);
}
}
+96 -9
View File
@@ -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);
});
}
+14 -2
View File
@@ -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));
}
+440 -323
View File
@@ -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
);
}
}
+51
View File
@@ -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();
}
}
+148 -143
View File
@@ -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
),
),
],
),
),
),