refactor: split main.dart into structured directories

This commit is contained in:
2025-05-03 14:46:08 +03:00
parent b37510f489
commit 6bae5b1b50
12 changed files with 1286 additions and 1210 deletions
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'screens/expenses_screen.dart';
import 'theme/app_theme.dart';
class MyApp extends StatefulWidget {
const MyApp({Key? key}) : super(key: key);
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _isDarkMode = false;
void toggleTheme() {
setState(() {
_isDarkMode = !_isDarkMode;
});
}
@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),
);
}
}
+2 -1210
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class Category {
final String name;
final double amount;
final Color color;
final IconData icon;
Category(this.name, this.amount, this.color, this.icon);
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class Transaction {
final String category;
final double amount;
final IconData icon;
final Color color;
final DateTime date;
final String merchant;
Transaction(this.category, this.amount, this.icon, this.color, this.date, this.merchant);
}
@@ -0,0 +1,467 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import 'dart:async';
import '../models/transaction.dart';
import '../models/category.dart';
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';
class ExpensesScreen extends StatefulWidget {
final Function toggleTheme;
final bool isDarkMode;
const ExpensesScreen({
Key? key,
required this.toggleTheme,
required this.isDarkMode,
}) : super(key: key);
@override
State<ExpensesScreen> createState() => _ExpensesScreenState();
}
class _ExpensesScreenState extends State<ExpensesScreen> with TickerProviderStateMixin {
late AnimationController _listAnimationController;
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),
];
@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
_pieChartAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800), // Slower fade/scale in
);
_pieChartAnimation = CurvedAnimation(
parent: _pieChartAnimationController,
curve: Curves.easeInOut,
);
// Controller for pie chart expand/collapse animation
_pieChartExpandController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: 1.0, // Start expanded
);
_pieChartHeightFactor = CurvedAnimation(
parent: _pieChartExpandController,
curve: Curves.easeInOut,
);
// Start animations
_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();
_pieChartAnimationController.dispose();
_pieChartExpandController.dispose();
super.dispose();
}
double get totalExpenses => _categories.fold(0, (sum, item) => sum + item.amount);
void _togglePieChartVisibility() {
setState(() {
_isPieChartExpanded = !_isPieChartExpanded;
if (_isPieChartExpanded) {
_pieChartExpandController.forward();
} else {
_pieChartExpandController.reverse();
}
});
}
void _toggleFilterVisibility() {
setState(() {
_isFilterVisible = !_isFilterVisible;
});
}
void _selectPieCategory(int index) {
setState(() {
_selectedPieIndex = index; // No need to toggle off here, handled by PieTouchData
});
}
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.
});
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final theme = Theme.of(context); // Get theme for easier access
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.account_balance_wallet,
color: isDark ? Colors.greenAccent : Colors.green.shade700,
size: 24,
),
const SizedBox(width: 8),
const Text('Finances'), // Title uses AppBarTheme's textStyle
],
),
centerTitle: true,
leading: IconButton(
icon: Icon(
widget.isDarkMode ? Icons.wb_sunny_outlined : Icons.nightlight_round,
color: widget.isDarkMode ? Colors.yellow : Colors.blue.shade700,
),
onPressed: () => widget.toggleTheme(),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
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
),
);
},
),
),
),
),
),
],
// 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,
children: [
Text(
'\$',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.green,
),
),
Text(
totalExpenses.toStringAsFixed(2),
style: TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.green,
),
),
],
),
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,
),
),
// 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,
),
),
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')),
],
),
),
),
// 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: 12), // Extra bottom padding
],
),
),
),
],
),
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',
),
],
),
),
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,
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Hero(
tag: 'profileAvatar', // Tag must match the one in ExpensesScreen
child: Material( // Wrap with Material for Hero animation
type: MaterialType.transparency,
child: CircleAvatar(
radius: 50,
backgroundColor: isDark ? Colors.green.shade800 : Colors.green.shade100,
child: const Icon(
Icons.person,
size: 50,
color: Colors.green,
),
),
),
),
const SizedBox(height: 24),
Text(
'John Doe',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black,
),
),
const SizedBox(height: 8),
Text(
'john.doe@example.com',
style: TextStyle(
fontSize: 16,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: isDark ? Colors.green.shade700 : Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: const Text('Edit Profile'),
),
],
),
),
);
}
}
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
class AppTheme {
static ThemeData get lightTheme {
return ThemeData(
brightness: Brightness.light,
primaryColor: Colors.green,
scaffoldBackgroundColor: Colors.grey.shade50,
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.light,
seedColor: Colors.green,
primary: Colors.green,
secondary: Colors.green.shade300,
surface: Colors.white,
background: Colors.grey.shade50,
),
cardTheme: CardTheme(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
color: Colors.white,
),
appBarTheme: AppBarTheme(
backgroundColor: Colors.white,
elevation: 0,
iconTheme: IconThemeData(color: Colors.green.shade700),
titleTextStyle: const TextStyle(
color: Colors.black87,
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat', // Ensure font family is applied here too
),
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: Colors.white,
selectedItemColor: Colors.green,
unselectedItemColor: Colors.grey.shade600,
),
fontFamily: 'Montserrat',
);
}
static ThemeData get darkTheme {
return ThemeData(
brightness: Brightness.dark,
primaryColor: Colors.green.shade400,
scaffoldBackgroundColor: const Color(0xFF121212),
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: Colors.green,
primary: Colors.green.shade400,
secondary: Colors.green.shade200,
surface: const Color(0xFF222222), // Used for Card background in dark theme
background: const Color(0xFF121212),
onSurface: Colors.white, // Default text color on surface
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0),
),
color: const Color(0xFF1E1E1E), // Darker card color
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1A1A1A), // Slightly different dark background
elevation: 0,
iconTheme: IconThemeData(color: Colors.greenAccent),
titleTextStyle: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Montserrat', // Ensure font family is applied here too
),
),
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: Colors.green.shade400,
foregroundColor: Colors.black87, // Better contrast on light green
),
bottomNavigationBarTheme: BottomNavigationBarThemeData(
backgroundColor: const Color(0xFF1A1A1A),
selectedItemColor: Colors.green.shade300,
unselectedItemColor: Colors.grey.shade500,
),
textTheme: const TextTheme(
bodyLarge: TextStyle(color: Colors.white), // Default text style
bodyMedium: TextStyle(color: Colors.white70), // Secondary text style
// Define other text styles if needed
).apply( // Ensure font family is applied globally for text
fontFamily: 'Montserrat',
),
fontFamily: 'Montserrat',
);
}
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
class ExpandableSection extends StatelessWidget {
final String title;
final IconData icon;
final bool isExpanded;
final VoidCallback onTap;
final Widget child;
final Animation<double> heightFactor;
const ExpandableSection({
Key? key,
required this.title,
required this.icon,
required this.isExpanded,
required this.onTap,
required this.heightFactor,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
decoration: BoxDecoration(
color: isDark ? Colors.grey.shade800 : Colors.green.shade100,
borderRadius: BorderRadius.vertical(
top: const Radius.circular(16),
bottom: Radius.circular(isExpanded ? 0 : 16),
),
),
child: Row(
children: [
Icon(
icon,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
),
),
const Spacer(),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0,
duration: const Duration(milliseconds: 300),
child: Icon(
Icons.keyboard_arrow_down,
color: isDark ? Colors.green.shade300 : Colors.green.shade800,
),
),
],
),
),
),
AnimatedBuilder(
animation: heightFactor,
builder: (context, innerChild) {
return ClipRect(
child: Align(
heightFactor: heightFactor.value,
child: Container(
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(16),
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withOpacity(0.3)
: Colors.green.withOpacity(0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: innerChild,
),
),
);
},
child: child,
),
],
);
}
}
@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
class FilterChipWidget extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const FilterChipWidget({
Key? key,
required this.label,
required this.isSelected,
required this.onTap,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return GestureDetector(
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), // Smaller padding
decoration: BoxDecoration(
color: isSelected
? (isDark ? Colors.green.shade700 : Colors.green.shade100)
: (isDark ? Colors.grey.shade800 : Colors.grey.shade200),
borderRadius: BorderRadius.circular(14), // Smaller radius
border: isSelected
? Border.all(
color: isDark ? Colors.green.shade300 : Colors.green,
width: 1,
)
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12, // Smaller text
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected
? (isDark ? Colors.white : Colors.green.shade700)
: (isDark ? Colors.white70 : Colors.grey.shade700),
),
),
),
);
}
}
@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../models/category.dart';
class SpendingPieChart extends StatelessWidget {
final List<Category> categories;
final double totalExpenses;
final int selectedPieIndex;
final Function(int) onSelectPieCategory;
final Animation<double> animation;
const SpendingPieChart({
Key? key,
required this.categories,
required this.totalExpenses,
required this.selectedPieIndex,
required this.onSelectPieCategory,
required this.animation,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Transform.scale(
scale: animation.value,
child: Opacity(
opacity: animation.value,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
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
if (event is FlPointerUpEvent || 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(),
),
],
),
),
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);
}),
),
),
),
],
),
),
// 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
],
),
),
),
);
},
);
}
Widget _buildPieLegendItem(BuildContext context, int index) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isSelected = index == selectedPieIndex;
final category = categories[index];
final fraction = totalExpenses > 0 ? (category.amount / totalExpenses * 100).toStringAsFixed(1) : '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),
decoration: BoxDecoration(
color: isSelected
? (isDark ? Colors.grey.shade800.withOpacity(0.5) : Colors.grey.shade200.withOpacity(0.7))
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: category.color.withOpacity(isDark ? 0.8 : 1.0),
shape: BoxShape.circle,
),
),
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,
),
),
Text(
'$fraction%',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
),
],
),
],
),
),
);
}
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 category = categories[i];
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,
);
});
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class SummaryItem extends StatelessWidget {
final IconData icon;
final String title;
final String amount;
final Color color;
const SummaryItem({
Key? key,
required this.icon,
required this.title,
required this.amount,
required this.color,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
Row(
children: [
Icon(
icon,
size: 16,
color: isDark ? color.withOpacity(0.8) : color,
),
const SizedBox(width: 4),
Text(
title,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade700,
),
),
],
),
const SizedBox(height: 4),
Text(
amount,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
],
);
}
}
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/transaction.dart';
class TransactionListItem extends StatelessWidget {
final Transaction transaction;
final Animation<double> animation;
const TransactionListItem({
Key? key,
required this.transaction,
required this.animation,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Format the date
final dateFormatter = DateFormat.MMMd();
String formattedDate = dateFormatter.format(transaction.date);
// Format the time
final timeFormatter = DateFormat.jm(); // Formats time like "3:30 PM"
String formattedTime = timeFormatter.format(transaction.date);
// Check if transaction is from today or yesterday
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);
if (transactionDate == today) {
formattedDate = 'Today';
} else if (transactionDate == yesterday) {
formattedDate = 'Yesterday';
}
// 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
),
),
// 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,
),
),
],
),
],
),
),
),
),
),
);
}
}