diff --git a/lib/data/repository/statistics_repository_impl.dart b/lib/data/repository/statistics_repository_impl.dart index 01212ac1..6f645fc3 100644 --- a/lib/data/repository/statistics_repository_impl.dart +++ b/lib/data/repository/statistics_repository_impl.dart @@ -3,6 +3,7 @@ import '../../core/errors/result.dart'; import '../../core/service/supabase_service.dart'; import '../../domain/entity/categories_breakdown.dart'; import '../../domain/entity/monthly_overview.dart'; +import '../../domain/entity/spending_trend_point.dart'; import '../../domain/repository/statistics_repository.dart'; class StatisticsRepositoryImpl implements StatisticsRepository { @@ -135,4 +136,35 @@ class StatisticsRepositoryImpl implements StatisticsRepository { return Result.error(ErrorModel(e.toString())); } } + + @override + Future> getSpendingTrend({required DateTime month}) async { + try { + final client = await _supabaseService.getClient(); + + final data = await client.rpc( + 'get_spending_trend', + params: {'in_year': month.year, 'in_month': month.month}, + ); + + if (data == null || (data as List).isEmpty) { + return Result.success(SpendingTrend(points: [], currency: 'IQD')); + } + + final points = (data as List).map((item) { + final map = item as Map; + return SpendingTrendPoint( + date: DateTime.parse(map['spend_date'] as String), + amount: (map['total_amount'] as num).toDouble(), + ); + }).toList(); + + final currency = (data.first as Map)['currency'] as String? ?? 'IQD'; + + return Result.success(SpendingTrend(points: points, currency: currency)); + } catch (e) { + return Result.error(ErrorModel(e.toString())); + } + } + } diff --git a/lib/domain/entity/spending_trend_point.dart b/lib/domain/entity/spending_trend_point.dart new file mode 100644 index 00000000..d029534d --- /dev/null +++ b/lib/domain/entity/spending_trend_point.dart @@ -0,0 +1,15 @@ +class SpendingTrendPoint { + final DateTime date; + final double amount; + + const SpendingTrendPoint({required this.date, required this.amount}); +} + +class SpendingTrend { + final List points; + final String currency; + + const SpendingTrend({required this.points, required this.currency}); + + bool get isEmpty => points.isEmpty; +} \ No newline at end of file diff --git a/lib/domain/repository/statistics_repository.dart b/lib/domain/repository/statistics_repository.dart index 16935f57..3d716f34 100644 --- a/lib/domain/repository/statistics_repository.dart +++ b/lib/domain/repository/statistics_repository.dart @@ -3,8 +3,10 @@ import '../entity/monthly_overview.dart'; import 'package:moneyplus/domain/entity/categories_breakdown.dart'; import '../../core/errors/result.dart'; +import '../entity/spending_trend_point.dart'; abstract class StatisticsRepository { Future> getMonthlyOverview({required DateTime month}); Future> getCategoriesBreakDown({required DateTime date}); + Future> getSpendingTrend({required DateTime month}); } diff --git a/lib/presentation/statistics/cubit/statistics_cubit.dart b/lib/presentation/statistics/cubit/statistics_cubit.dart index fe96cdb6..aaaae362 100644 --- a/lib/presentation/statistics/cubit/statistics_cubit.dart +++ b/lib/presentation/statistics/cubit/statistics_cubit.dart @@ -1,4 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../domain/entity/spending_trend_point.dart'; import '../../../domain/repository/statistics_repository.dart'; import 'statistics_state.dart'; @@ -11,35 +13,41 @@ class StatisticsCubit extends Cubit { Future loadStatistics({DateTime? month}) async { final selectedMonth = month ?? DateTime(2026, 2, 1); - emit(const StatisticsLoading()); final monthlyOverviewResult = await _repository.getMonthlyOverview( month: selectedMonth, ); + monthlyOverviewResult.when( onSuccess: (monthlyOverview) async { - final categoriesBreakdownResult = - await _repository.getCategoriesBreakDown(date:selectedMonth); + final categoriesResult = await _repository.getCategoriesBreakDown( + date: selectedMonth, + ); + final trendResult = await _repository.getSpendingTrend( + month: selectedMonth, + ); - categoriesBreakdownResult.when( + categoriesResult.when( onSuccess: (categoriesBreakdown) { + final spendingTrend = trendResult.when( + onSuccess: (trend) => trend, + onError: (_) => SpendingTrend(points: [], currency: 'IQD'), + ); + emit( StatisticsSuccess( monthlyOverview: monthlyOverview, selectedMonth: selectedMonth, categoriesBreakdown: categoriesBreakdown, + spendingTrend: spendingTrend, ), ); }, - onError: (error) { - emit(StatisticsFailure(error.message)); - }, + onError: (error) => emit(StatisticsFailure(error.message)), ); }, - onError: (error) { - emit(StatisticsFailure(error.message)); - }, + onError: (error) => emit(StatisticsFailure(error.message)), ); } diff --git a/lib/presentation/statistics/cubit/statistics_state.dart b/lib/presentation/statistics/cubit/statistics_state.dart index a8869d9f..58d784ae 100644 --- a/lib/presentation/statistics/cubit/statistics_state.dart +++ b/lib/presentation/statistics/cubit/statistics_state.dart @@ -1,6 +1,7 @@ import 'package:moneyplus/domain/entity/categories_breakdown.dart'; import '../../../domain/entity/monthly_overview.dart'; +import '../../../domain/entity/spending_trend_point.dart'; sealed class StatisticsState { const StatisticsState(); @@ -18,11 +19,13 @@ class StatisticsSuccess extends StatisticsState { final MonthlyOverview monthlyOverview; final DateTime selectedMonth; final CategoriesBreakdown categoriesBreakdown; + final SpendingTrend spendingTrend; const StatisticsSuccess({ required this.monthlyOverview, required this.selectedMonth, required this.categoriesBreakdown, + required this.spendingTrend, }); bool get hasNoData => monthlyOverview.isEmpty && categoriesBreakdown.categories.isEmpty; diff --git a/lib/presentation/statistics/statistics_screen.dart b/lib/presentation/statistics/statistics_screen.dart index 15442b63..66fe7ece 100644 --- a/lib/presentation/statistics/statistics_screen.dart +++ b/lib/presentation/statistics/statistics_screen.dart @@ -7,9 +7,12 @@ import 'package:moneyplus/design_system/widgets/app_bar.dart'; import 'package:moneyplus/design_system/widgets/app_empty_view.dart'; import 'package:moneyplus/design_system/widgets/app_error_view.dart'; import 'package:moneyplus/design_system/widgets/app_loading_indicator.dart'; +import 'package:moneyplus/presentation/statistics/utils.dart'; import 'package:moneyplus/presentation/statistics/widgets/CategoryBreakdown.dart'; +import 'package:moneyplus/presentation/statistics/widgets/highest_spending_banner.dart'; import 'package:moneyplus/presentation/transactions/screen/transactions_screen.dart'; +import '../../design_system/chart/spending_trend_graph.dart'; import '../transactions/widget/add_transaction_bottom_sheet.dart'; import '../widgets/drop_down_date_dialog.dart'; import 'cubit/statistics_cubit.dart'; @@ -88,6 +91,8 @@ class _StatisticsViewState extends State { ); } + final trendDataPoints = state.spendingTrend.toDataPoints(); + return SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16.0), @@ -98,6 +103,15 @@ class _StatisticsViewState extends State { CategoryBreakdownWidget( categoriesBreakdown: state.categoriesBreakdown, ), + const SizedBox(height: 16), + + SpendingTrendGraph( + data: trendDataPoints, + currency: state.spendingTrend.currency, + ), + const SizedBox(height: 8), + HighestSpendingBanner(trend: state.spendingTrend), + const SizedBox(height: 16), ], ), ), diff --git a/lib/presentation/statistics/utils.dart b/lib/presentation/statistics/utils.dart index 4725c0e1..179e6ada 100644 --- a/lib/presentation/statistics/utils.dart +++ b/lib/presentation/statistics/utils.dart @@ -1,3 +1,8 @@ + +import 'package:moneyplus/design_system/chart/models/data_point.dart'; + +import '../../domain/entity/spending_trend_point.dart'; + String formatNumber(double value) { if (value >= 1000000) { return '${(value / 1000000).toStringAsFixed(1)}M'; @@ -6,3 +11,11 @@ String formatNumber(double value) { } return value.toStringAsFixed(0); } + +extension SpendingTrendMapper on SpendingTrend { + List toDataPoints() { + return points + .map((p) => DataPoint(date: p.date, amount: p.amount)) + .toList(); + } +} \ No newline at end of file diff --git a/lib/presentation/statistics/widgets/highest_spending_banner.dart b/lib/presentation/statistics/widgets/highest_spending_banner.dart new file mode 100644 index 00000000..09ec9a20 --- /dev/null +++ b/lib/presentation/statistics/widgets/highest_spending_banner.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/core/l10n/app_localizations.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +import '../../../domain/entity/spending_trend_point.dart'; + +class HighestSpendingBanner extends StatelessWidget { + final SpendingTrend trend; + + const HighestSpendingBanner({super.key, required this.trend}); + + @override + Widget build(BuildContext context) { + if (trend.isEmpty) return const SizedBox.shrink(); + + final l10n = AppLocalizations.of(context)!; + final peak = trend.points.reduce((a, b) => a.amount >= b.amount ? a : b); + final day = peak.date.day; + final monthAbbr = _monthAbbr(peak.date.month); + + return Container( + height: DesignConstants.savingsBannerHeight, + padding: const EdgeInsets.symmetric( + horizontal: DesignConstants.spacingSmall, + ), + decoration: BoxDecoration( + color: context.colors.redVariant, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(DesignConstants.radiusMedium), + bottomRight: Radius.circular(DesignConstants.radiusMedium), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.warning_amber_rounded, + size: 12, + color: context.colors.primary, + ), + const SizedBox(width: DesignConstants.spacingXSmall), + Text( + l10n.highest_spending_message('$day $monthAbbr'), + style: context.typography.label.xSmall?.copyWith( + color: context.colors.red, + ), + ), + ], + ), + ); + } + + String _monthAbbr(int month) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return months[month - 1]; + } +}