diff --git a/assets/images/img_no_analysis.png b/assets/images/img_no_analysis.png new file mode 100644 index 0000000..9cd5fc7 Binary files /dev/null and b/assets/images/img_no_analysis.png differ diff --git a/lib/core/l10n/app_ar.arb b/lib/core/l10n/app_ar.arb index a0ff716..c605350 100644 --- a/lib/core/l10n/app_ar.arb +++ b/lib/core/l10n/app_ar.arb @@ -143,5 +143,8 @@ "continueButton": "متابعة", "categoriesFilterTitle": "تصفية الفئات", "categoriesFilterClear": "مسح", - "categoriesFilterApply": "تطبيق" + "categoriesFilterApply": "تطبيق", + "categoriesBreakdown": "تفاصيل الفئات", + "no_monthly_breakdown": "لا يوجد نفقات لتحليلها بعد", + "totalSpend": "إجمالي الإنفاق" } diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 5658786..81c8cc1 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -160,5 +160,8 @@ "continueButton": "Continue", "categoriesFilterTitle": "Categories filter", "categoriesFilterClear": "Clear", - "categoriesFilterApply": "Apply" + "categoriesFilterApply": "Apply", + "categoriesBreakdown": "Categories Breakdown", + "no_monthly_breakdown": "No expenses to analyze yet", + "totalSpend": "Total Spend" } diff --git a/lib/data/repository/fake_statistics_repository.dart b/lib/data/repository/fake_statistics_repository.dart deleted file mode 100644 index 6904411..0000000 --- a/lib/data/repository/fake_statistics_repository.dart +++ /dev/null @@ -1,41 +0,0 @@ -import '../../domain/entity/monthly_overview.dart'; -import '../../domain/repository/statistics_repository.dart'; - -class FakeStatisticsRepository implements StatisticsRepository { - final bool shouldFail; - final bool shouldReturnEmpty; - - FakeStatisticsRepository({ - this.shouldFail = false, - this.shouldReturnEmpty = false, - }); - - @override - Future getMonthlyOverview({required DateTime month}) async { - // Simulate network delay - await Future.delayed(const Duration(seconds: 1)); - - if (shouldFail) { - throw Exception('Simulated network error'); - } - - if (shouldReturnEmpty) { - return const MonthlyOverview( - income: 0, - expenses: 0, - currency: 'IQD', - maxValue: 100000, - scaleLabels: ['0', '12.5K', '25K', '37.5K', '50K', '62.5K', '75K', '87.5K', '100K'], - ); - } - - // Simulate successful response matching your UI design - return const MonthlyOverview( - income: 1500000, - expenses: 850000, - currency: 'IQD', - maxValue: 2000000, - scaleLabels: ['0', '250K', '500K', '750K', '1M', '1.25M', '1.5M', '1.75M', '2M'], - ); - } -} \ No newline at end of file diff --git a/lib/data/repository/statistics_repository_impl.dart b/lib/data/repository/statistics_repository_impl.dart index 2ea33b3..41c2bf9 100644 --- a/lib/data/repository/statistics_repository_impl.dart +++ b/lib/data/repository/statistics_repository_impl.dart @@ -1,4 +1,7 @@ +import '../../core/errors/error_model.dart'; +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/repository/statistics_repository.dart'; @@ -6,35 +9,35 @@ class StatisticsRepositoryImpl implements StatisticsRepository { final SupabaseService _supabaseService; StatisticsRepositoryImpl({required SupabaseService supabaseService}) - : _supabaseService = supabaseService; + : _supabaseService = supabaseService; @override - Future getMonthlyOverview({required DateTime month}) async { + Future> getMonthlyOverview({ + required DateTime month, + }) async { try { final client = await _supabaseService.getClient(); final userId = client.auth.currentUser?.id; if (userId == null) { - return null; + return Result.error(ErrorModel('User not logged in')); } // Call RPC function final response = await client.rpc( 'get_monthly_overview', - params: { - 'in_year': month.year, - 'in_month': month.month, - }, + params: {'in_year': month.year, 'in_month': month.month}, ); - if (response == null) { - return _createEmptyOverview(); + return Result.success(_createEmptyOverview()); } - return _mapResponseToOverview(response as Map); + return Result.success( + _mapResponseToOverview(response as Map), + ); } catch (e) { print('Error fetching monthly overview: $e'); - rethrow; + return Result.error(ErrorModel(e.toString())); } } @@ -112,4 +115,25 @@ class StatisticsRepositoryImpl implements StatisticsRepository { } return value.toStringAsFixed(0); } -} \ No newline at end of file + + @override + Future> getCategoriesBreakDown({ + required DateTime date, + }) async { + try { + final client = await _supabaseService.getClient(); + final data = await client.rpc( + 'get_expenses_categories_breakdown', + params: {'in_year': date.year, 'in_month': date.month}, + ); + if (data == null) { + return Result.success( + CategoriesBreakdown(categories: [], totalSpend: 0.0), + ); + } + return Result.success(CategoriesBreakdown.fromJson(data)); + } catch (e) { + return Result.error(ErrorModel(e.toString())); + } + } +} diff --git a/lib/design_system/assets/app_assets.dart b/lib/design_system/assets/app_assets.dart index a264b63..2617f89 100644 --- a/lib/design_system/assets/app_assets.dart +++ b/lib/design_system/assets/app_assets.dart @@ -69,6 +69,7 @@ class AppAssets { static const String icEmptyTransactionImage = '$_images/empty_transaction_image.png'; static const String icEmptyTransactionPattern = '$_icons/empty_transaction_pattern.svg'; static const String icFilter = "$_icons/ic_filter.svg"; + static const String imgNoAnalysis = "$_images/img_no_analysis.png"; static const String icAddAmount = "$_icons/ic_add_transaction_income.svg"; static const String icAddExpense = "$_icons/ic_add_transaction_expense.svg"; } diff --git a/lib/design_system/widgets/app_empty_view.dart b/lib/design_system/widgets/app_empty_view.dart index f6ff288..85ff9a8 100644 --- a/lib/design_system/widgets/app_empty_view.dart +++ b/lib/design_system/widgets/app_empty_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:moneyplus/design_system/assets/app_assets.dart'; import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +import 'buttons/button/default_button.dart'; + class AppEmptyView extends StatelessWidget { final String title; final String subtitle; @@ -25,11 +27,9 @@ class AppEmptyView extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Image.asset( - // todo : AppAssets.imgStatisticsEmpty, - AppAssets.logo, - color: Colors.red, - width: 120, - height: 120, + AppAssets.imgNoAnalysis, + width: 105, + height: 96, ), const SizedBox(height: 24), Text( @@ -50,18 +50,9 @@ class AppEmptyView extends StatelessWidget { if (buttonText != null && onButtonPressed != null) ...[ const SizedBox(height: 24), SizedBox( - width: double.infinity, - child: ElevatedButton( + child: DefaultButton( onPressed: onButtonPressed, - style: ElevatedButton.styleFrom( - backgroundColor: context.colors.primary, - foregroundColor: context.colors.onPrimary, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - ), - child: Text(buttonText!), + text: buttonText!, ), ), ], @@ -70,4 +61,4 @@ class AppEmptyView extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/domain/entity/categories_breakdown.dart b/lib/domain/entity/categories_breakdown.dart new file mode 100644 index 0000000..0f3f3b8 --- /dev/null +++ b/lib/domain/entity/categories_breakdown.dart @@ -0,0 +1,36 @@ + +class CategoriesBreakdown { + final List categories; + final double totalSpend; + + CategoriesBreakdown({ + required this.categories, + required this.totalSpend, + }); + + factory CategoriesBreakdown.fromJson(Map json) => CategoriesBreakdown( + categories: List.from(json["categories"].map((category) => BreakDownCategory.fromJson(category))), + totalSpend: json["total_spend"]?.toDouble(), + ); +} + +class BreakDownCategory { + final int? id; + final String name; + final double spend; + final double percentage; + + BreakDownCategory({ + this.id, + required this.name, + required this.spend, + required this.percentage, + }); + + factory BreakDownCategory.fromJson(Map json) => BreakDownCategory( + id: json["id"], + name: json["name"], + spend: json["spend"]?.toDouble(), + percentage: json["percentage"]?.toDouble(), + ); +} diff --git a/lib/domain/repository/statistics_repository.dart b/lib/domain/repository/statistics_repository.dart index b51c302..16935f5 100644 --- a/lib/domain/repository/statistics_repository.dart +++ b/lib/domain/repository/statistics_repository.dart @@ -1,5 +1,10 @@ import '../entity/monthly_overview.dart'; +import 'package:moneyplus/domain/entity/categories_breakdown.dart'; + +import '../../core/errors/result.dart'; + abstract class StatisticsRepository { - Future getMonthlyOverview({required DateTime month}); -} \ No newline at end of file + Future> getMonthlyOverview({required DateTime month}); + Future> getCategoriesBreakDown({required DateTime date}); +} diff --git a/lib/presentation/main_container/screen/main_screen.dart b/lib/presentation/main_container/screen/main_screen.dart index 7edff59..1adea93 100644 --- a/lib/presentation/main_container/screen/main_screen.dart +++ b/lib/presentation/main_container/screen/main_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:moneyplus/design_system/theme/money_extension_context.dart'; import 'package:moneyplus/presentation/main_container/cubit/main_state.dart'; +import 'package:moneyplus/presentation/statistics/statistics_screen.dart'; import 'package:moneyplus/presentation/transactions/screen/transactions_screen.dart'; import '../../../design_system/widgets/nav_bar.dart'; diff --git a/lib/presentation/statistics/cubit/statistics_cubit.dart b/lib/presentation/statistics/cubit/statistics_cubit.dart index d8c91cf..fe96cdb 100644 --- a/lib/presentation/statistics/cubit/statistics_cubit.dart +++ b/lib/presentation/statistics/cubit/statistics_cubit.dart @@ -14,20 +14,33 @@ class StatisticsCubit extends Cubit { emit(const StatisticsLoading()); - try { - final monthlyOverview = await _repository.getMonthlyOverview( - month: selectedMonth, - ); + final monthlyOverviewResult = await _repository.getMonthlyOverview( + month: selectedMonth, + ); + monthlyOverviewResult.when( + onSuccess: (monthlyOverview) async { + final categoriesBreakdownResult = + await _repository.getCategoriesBreakDown(date:selectedMonth); - emit( - StatisticsSuccess( - monthlyOverview: monthlyOverview, - selectedMonth: selectedMonth, - ), - ); - } catch (e) { - emit(StatisticsFailure(e.toString())); - } + categoriesBreakdownResult.when( + onSuccess: (categoriesBreakdown) { + emit( + StatisticsSuccess( + monthlyOverview: monthlyOverview, + selectedMonth: selectedMonth, + categoriesBreakdown: categoriesBreakdown, + ), + ); + }, + onError: (error) { + emit(StatisticsFailure(error.message)); + }, + ); + }, + onError: (error) { + emit(StatisticsFailure(error.message)); + }, + ); } void changeMonth(DateTime month) { diff --git a/lib/presentation/statistics/cubit/statistics_state.dart b/lib/presentation/statistics/cubit/statistics_state.dart index 5387a97..a8869d9 100644 --- a/lib/presentation/statistics/cubit/statistics_state.dart +++ b/lib/presentation/statistics/cubit/statistics_state.dart @@ -1,3 +1,5 @@ +import 'package:moneyplus/domain/entity/categories_breakdown.dart'; + import '../../../domain/entity/monthly_overview.dart'; sealed class StatisticsState { @@ -13,19 +15,21 @@ class StatisticsLoading extends StatisticsState { } class StatisticsSuccess extends StatisticsState { - final MonthlyOverview? monthlyOverview; + final MonthlyOverview monthlyOverview; final DateTime selectedMonth; + final CategoriesBreakdown categoriesBreakdown; const StatisticsSuccess({ required this.monthlyOverview, required this.selectedMonth, + required this.categoriesBreakdown, }); - bool get hasNoData => monthlyOverview == null; + bool get hasNoData => monthlyOverview.isEmpty && categoriesBreakdown.categories.isEmpty; } class StatisticsFailure extends StatisticsState { final String message; const StatisticsFailure(this.message); -} \ No newline at end of file +} diff --git a/lib/presentation/statistics/statistics_screen.dart b/lib/presentation/statistics/statistics_screen.dart index 847e268..2d14421 100644 --- a/lib/presentation/statistics/statistics_screen.dart +++ b/lib/presentation/statistics/statistics_screen.dart @@ -1,29 +1,39 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:moneyplus/core/di/injection.dart'; import 'package:moneyplus/core/l10n/app_localizations.dart'; import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +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/widgets/CategoryBreakdown.dart'; -import '../../core/di/injection.dart'; +import '../widgets/drop_down_date_dialog.dart'; import 'cubit/statistics_cubit.dart'; import 'cubit/statistics_state.dart'; import 'widgets/monthly_overview/monthly_overview_section.dart'; -class StatisticsScreen extends StatefulWidget { +class StatisticsScreen extends StatelessWidget { const StatisticsScreen({super.key}); @override - State createState() => _StatisticsScreenState(); + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => getIt()..loadStatistics(), + child: const StatisticsView(), + ); + } } -class _StatisticsScreenState extends State { +class StatisticsView extends StatefulWidget { + const StatisticsView({super.key}); + @override - void initState() { - super.initState(); - } + State createState() => _StatisticsViewState(); +} +class _StatisticsViewState extends State { void _onAddTransaction() { // Navigate to add transaction } @@ -34,25 +44,22 @@ class _StatisticsScreenState extends State { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => getIt()..loadStatistics(), - child: BlocBuilder( - builder: (context, state) { - return Scaffold( - backgroundColor: context.colors.surface, - body: SafeArea( - child: switch (state) { - StatisticsIdle() => const SizedBox.shrink(), - StatisticsLoading() => const AppLoadingIndicator(), - StatisticsSuccess() => _buildSuccess(context, state), - StatisticsFailure(:final message) => AppErrorView( - message: message, - onRetry: _onRetry, - ), - }, - ), - ); - }, + return Scaffold( + backgroundColor: context.colors.surface, + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + return switch (state) { + StatisticsIdle() => const SizedBox.shrink(), + StatisticsLoading() => const AppLoadingIndicator(), + StatisticsSuccess() => _buildSuccess(context, state), + StatisticsFailure(:final message) => AppErrorView( + message: message, + onRetry: _onRetry, + ), + }; + }, + ), ), ); } @@ -73,9 +80,20 @@ class _StatisticsScreenState extends State { padding: const EdgeInsets.all(16), child: Column( children: [ - if (state.monthlyOverview != null) - MonthlyOverviewSection(overview: state.monthlyOverview!), - // TODO: Add other sections here + CustomAppBar( + title: l10n.statistics, + trailing: DropDownDateDialog( + onDatePick: (date) => { + context.read().changeMonth(date), + }, + year: state.selectedMonth.year, + month: state.selectedMonth.month, + ), + ), + MonthlyOverviewSection(overview: state.monthlyOverview), + CategoryBreakdownWidget( + categoriesBreakdown: state.categoriesBreakdown, + ), ], ), ); diff --git a/lib/presentation/statistics/widgets/CategoryBreakdown.dart b/lib/presentation/statistics/widgets/CategoryBreakdown.dart new file mode 100644 index 0000000..6a7d6eb --- /dev/null +++ b/lib/presentation/statistics/widgets/CategoryBreakdown.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:moneyplus/domain/entity/categories_breakdown.dart'; +import 'package:moneyplus/presentation/statistics/widgets/section_empty_view.dart'; + +import '../../../core/l10n/app_localizations.dart'; +import '../../../design_system/theme/money_extension_context.dart'; + +const List _colorPaletteBase = [ + Color(0xffE04967), + Color(0xffff7792), + Color(0xffffa7b9), + Color(0xffffcfd8), +]; + +class CategoryBreakdownWidget extends StatelessWidget { + final CategoriesBreakdown categoriesBreakdown; + + const CategoryBreakdownWidget({super.key, required this.categoriesBreakdown}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final typography = context.typography; + final localizations = AppLocalizations.of(context)!; + final numberFormat = NumberFormat.decimalPattern(); + + final colorPalette = [colors.primary, ..._colorPaletteBase]; + if (categoriesBreakdown.categories.isEmpty) { + return SectionEmptyView( + title: localizations.categoriesBreakdown, + message: localizations.no_monthly_breakdown, + ); + } + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceLow, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Text( + localizations.categoriesBreakdown, + style: typography.label.medium.copyWith(color: colors.title), + ), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: categoriesBreakdown.categories.asMap().entries.map(( + entry, + ) { + final index = entry.key; + final category = entry.value; + return Expanded( + flex: (category.percentage * 1000).toInt(), + child: Container( + height: 24, + margin: EdgeInsets.only( + right: index == categoriesBreakdown.categories.length - 1 + ? 0 + : 2, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(2), + color: colorPalette[index % colorPalette.length], + ), + ), + ); + }).toList(), + ), + ), + Row( + spacing: 4, + children: [ + Text( + numberFormat.format(categoriesBreakdown.totalSpend), + style: typography.label.medium.copyWith(color: colors.title), + ), + Text( + localizations.totalSpend, + style: typography.label.xSmall!.copyWith(color: colors.body), + ), + ], + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: categoriesBreakdown.categories.length, + itemBuilder: (context, index) { + final category = categoriesBreakdown.categories[index]; + final color = colorPalette[index % colorPalette.length]; + return _buildCategoryItem(context, category, color, numberFormat, localizations); + }, + separatorBuilder: (BuildContext context, int index) { + return Divider(color: colors.stroke, thickness: .5); + }, + ), + ], + ), + ); + } + + Widget _buildCategoryItem( + BuildContext context, + BreakDownCategory category, + Color color, + NumberFormat numberFormat, + AppLocalizations localizations, + ) { + final colors = context.colors; + final typography = context.typography; + return Row( + spacing: 4, + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + Text( + "${(category.percentage).toStringAsFixed(0)}%", + textAlign: TextAlign.end, + style: typography.label.xSmall!.copyWith(color: colors.title), + ), + Expanded( + child: Text( + category.name, + style: typography.label.xSmall!.copyWith(color: colors.body), + ), + ), + Text(localizations.moneyAmount( + numberFormat.format(category.spend), + localizations.currencyCode, + ) + ,style: typography.label.small.copyWith(color: colors.title), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/presentation/statistics/widgets/section_empty_view.dart b/lib/presentation/statistics/widgets/section_empty_view.dart index a799f27..7caa660 100644 --- a/lib/presentation/statistics/widgets/section_empty_view.dart +++ b/lib/presentation/statistics/widgets/section_empty_view.dart @@ -34,8 +34,7 @@ class SectionEmptyView extends StatelessWidget { ), const SizedBox(height: 24), Image.asset( - // todo : AppAssets.imgStatisticsEmpty, - AppAssets.logo, + AppAssets.imgNoAnalysis, width: 80, height: 80, ),