This commit is contained in:
2026-03-20 09:26:14 +03:00
parent 3dcbb6164e
commit 99d985ca45
11 changed files with 1065 additions and 114 deletions
+26
View File
@@ -12,3 +12,29 @@ final categoryExpenseProvider = Provider<Map<String, double>>((ref) {
}
return map;
});
// Monthly breakdown for last 6 months
final monthlyBreakdownProvider = Provider<List<MonthlyData>>((ref) {
final txs = ref.watch(transactionsProvider)
.where((t) => t.type == TransactionType.expense);
final now = DateTime.now();
final months = <MonthlyData>[];
for (var i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1);
final total = txs
.where((t) => t.date.year == month.year && t.date.month == month.month)
.fold(0.0, (sum, t) => sum + t.amount);
months.add(MonthlyData(month: month, amount: total));
}
return months;
});
class MonthlyData {
final DateTime month;
final double amount;
MonthlyData({required this.month, required this.amount});
}
+278 -40
View File
@@ -7,6 +7,8 @@ import 'provider.dart';
final _currencyFmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2);
enum ChartType { pie, bar }
class CategoriesScreen extends ConsumerStatefulWidget {
const CategoriesScreen({super.key});
@@ -16,12 +18,18 @@ class CategoriesScreen extends ConsumerStatefulWidget {
class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
int _touchedIndex = -1;
ChartType _chartType = ChartType.pie;
@override
Widget build(BuildContext context) {
final data = ref.watch(categoryExpenseProvider);
final monthlyData = ref.watch(monthlyBreakdownProvider);
final total = data.values.fold(0.0, (a, b) => a + b);
// Sort categories by amount descending
final sortedEntries = data.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Scaffold(
backgroundColor: AppColors.background,
body: SafeArea(
@@ -30,18 +38,34 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Categories',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Expense breakdown',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Categories',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
Text(
'Expense breakdown',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
),
_ChartToggle(
selected: _chartType,
onChanged: (t) => setState(() => _chartType = t),
),
],
),
const SizedBox(height: 24),
if (data.isEmpty)
@@ -50,21 +74,38 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
Expanded(
child: ListView(
children: [
_PieChartCard(
data: data,
total: total,
touchedIndex: _touchedIndex,
onTouch: (i) => setState(() => _touchedIndex = i),
),
if (_chartType == ChartType.pie)
_PieChartCard(
data: data,
total: total,
touchedIndex: _touchedIndex,
onTouch: (i) => setState(() => _touchedIndex = i),
)
else
_BarChartCard(monthlyData: monthlyData),
const SizedBox(height: 20),
...data.entries.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CategoryRow(
category: e.key,
amount: e.value,
total: total,
Text(
'Ranked by Amount',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)),
),
const SizedBox(height: 12),
...sortedEntries.asMap().entries.map((entry) {
final rank = entry.key + 1;
final cat = entry.value.key;
final amount = entry.value.value;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _CategoryRow(
rank: rank,
category: cat,
amount: amount,
total: total,
),
);
}),
const SizedBox(height: 80),
],
),
@@ -77,6 +118,68 @@ class _CategoriesScreenState extends ConsumerState<CategoriesScreen> {
}
}
class _ChartToggle extends StatelessWidget {
final ChartType selected;
final ValueChanged<ChartType> onChanged;
const _ChartToggle({required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.divider),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_ToggleButton(
icon: Icons.pie_chart_rounded,
isSelected: selected == ChartType.pie,
onTap: () => onChanged(ChartType.pie),
),
_ToggleButton(
icon: Icons.bar_chart_rounded,
isSelected: selected == ChartType.bar,
onTap: () => onChanged(ChartType.bar),
),
],
),
);
}
}
class _ToggleButton extends StatelessWidget {
final IconData icon;
final bool isSelected;
final VoidCallback onTap;
const _ToggleButton({
required this.icon,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isSelected ? AppColors.accent.withOpacity(0.15) : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: isSelected ? AppColors.accent : AppColors.textSecondary,
size: 20,
),
),
);
}
}
class _PieChartCard extends StatelessWidget {
final Map<String, double> data;
final double total;
@@ -171,11 +274,128 @@ class _PieChartCard extends StatelessWidget {
}
}
class _BarChartCard extends StatelessWidget {
final List<MonthlyData> monthlyData;
const _BarChartCard({required this.monthlyData});
@override
Widget build(BuildContext context) {
final maxY = monthlyData.map((e) => e.amount).reduce((a, b) => a > b ? a : b);
final adjustedMaxY = maxY * 1.2;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.divider),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Last 6 Months',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: adjustedMaxY > 0 ? adjustedMaxY : 100,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
_currencyFmt.format(rod.toY),
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 12,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() >= 0 && value.toInt() < monthlyData.length) {
final month = monthlyData[value.toInt()].month;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
DateFormat('MMM').format(month),
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 11,
),
),
);
}
return const Text('');
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: adjustedMaxY > 0 ? adjustedMaxY / 4 : 25,
getDrawingHorizontalLine: (value) => FlLine(
color: AppColors.divider,
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
barGroups: List.generate(
monthlyData.length,
(i) => BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: monthlyData[i].amount,
color: AppColors.accent,
width: 24,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
],
),
),
),
),
),
],
),
);
}
}
class _CategoryRow extends StatelessWidget {
final int rank;
final String category;
final double amount;
final double total;
const _CategoryRow({
required this.rank,
required this.category,
required this.amount,
required this.total,
@@ -198,6 +418,23 @@ class _CategoryRow extends StatelessWidget {
children: [
Row(
children: [
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: rank <= 3 ? color.withOpacity(0.2) : AppColors.divider,
shape: BoxShape.circle,
),
child: Text(
'$rank',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: rank <= 3 ? color : AppColors.textSecondary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -216,12 +453,23 @@ class _CategoryRow extends StatelessWidget {
),
),
),
Text(
_currencyFmt.format(amount),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.expense,
fontWeight: FontWeight.w700,
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_currencyFmt.format(amount),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.expense,
fontWeight: FontWeight.w700,
),
),
Text(
'${(pct * 100).toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
],
),
],
),
@@ -235,16 +483,6 @@ class _CategoryRow extends StatelessWidget {
minHeight: 6,
),
),
const SizedBox(height: 4),
Align(
alignment: Alignment.centerRight,
child: Text(
'${(pct * 100).toStringAsFixed(1)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.textSecondary,
),
),
),
],
),
);