mirror of
https://github.com/koloideal/Casha.git
synced 2026-06-10 10:25:28 +03:00
update
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user