diff --git a/.gitignore b/.gitignore index b815a037..faa53133 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +# ============================== # Miscellaneous +# ============================== *.class *.log *.pyc @@ -15,39 +17,49 @@ migrate_working_dir/ *.secret *.properties *.lock -# IntelliJ related + +# ============================== +# IDE +# ============================== *.iml *.ipr *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ +# Uncomment if you DON'T want VS Code settings in git +# .vscode/ -# Flutter/Dart/Pub related +# ============================== +# Flutter / Dart / Pub +# ============================== **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins-dependencies .pub-cache/ .pub/ -/build/ -/coverage/ -pubspec.lock +build/ +coverage/ -# Symbolication related -app.*.symbols +# ⚠️ If this is a PACKAGE keep this ignored +# ⚠️ If this is an APP you may want to track it +pubspec.lock -# Obfuscation related -app.*.map.json +# Generated files +*.g.dart +*.freezed.dart +*.gen.dart -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +# ============================== +# iOS +# ============================== +ios/Flutter/Debug.xcconfig +ios/Flutter/Release.xcconfig +ios/Podfile -# Flutter generated localization files -**/l10n/app_localizations.dart -**/l10n/app_localizations_*.dart \ No newline at end of file +# =========================== +/lib/presentation/navigation/routes.g.dart +/lib/core/l10n/app_localizations.dart +/lib/core/l10n/app_localizations_ar.dart +/lib/core/l10n/app_localizations_en.dart +/lib/utils/CMakeLists.txt diff --git a/assets/icons/ic_money_remove.svg b/assets/icons/ic_money_remove.svg new file mode 100644 index 00000000..c83fd56b --- /dev/null +++ b/assets/icons/ic_money_remove.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/assets/icons/ic_wallet_add.svg b/assets/icons/ic_wallet_add.svg new file mode 100644 index 00000000..bc236eb4 --- /dev/null +++ b/assets/icons/ic_wallet_add.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/ios/.gitignore b/ios/.gitignore index 7a7f9873..0e7682ca 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -32,3 +32,5 @@ Runner/GeneratedPluginRegistrant.* !default.mode2v3 !default.pbxuser !default.perspectivev3 +/Flutter/Debug.xcconfig +/Flutter/Release.xcconfig diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee8..00000000 --- a/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/lib/di/injection.dart b/lib/core/di/injection.dart similarity index 52% rename from lib/di/injection.dart rename to lib/core/di/injection.dart index cef2fab7..80de8b42 100644 --- a/lib/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,21 +1,25 @@ import 'package:get_it/get_it.dart'; import 'package:moneyplus/domain/repository/transaction_repository.dart'; import 'package:moneyplus/presentation/account_setup/cubit/account_setup_cubit.dart'; -import 'package:moneyplus/presentation/transactions/cubit/transaction_cubit.dart'; - -import '../data/repository/account_repository.dart'; -import '../data/repository/authentication_repository.dart'; -import '../data/repository/user_money_repository.dart'; -import '../data/repository/transaction_repository_stub.dart'; -import '../data/service/app_secrets_provider.dart'; -import '../data/service/supabase_service.dart'; -import '../domain/repository/account_repository.dart'; -import '../domain/repository/authentication_repository.dart'; -import '../domain/repository/user_money_repository.dart'; -import '../domain/validator/authentication_validator.dart'; -import '../presentation/home/cubit/home_cubit.dart'; -import '../presentation/income/cubit/add_income_cubit.dart'; -import '../presentation/login/cubit/login_cubit.dart'; +import '../../data/repository/account_repository.dart'; +import '../../data/repository/authentication_repository.dart'; +import '../../data/repository/statistics_repository_impl.dart'; +import '../../data/repository/transaction_repository_stub.dart'; +import '../../data/repository/user_money_repository.dart'; +import '../../data/repository/fake_statistics_repository.dart'; +import '../../data/service/app_secrets_provider.dart'; +import '../../data/service/supabase_service.dart'; +import '../../domain/repository/account_repository.dart'; +import '../../domain/repository/authentication_repository.dart'; +import '../../domain/repository/statistics_repository.dart'; +import '../../domain/repository/user_money_repository.dart'; +import '../../domain/validator/authentication_validator.dart'; +import '../../domain/repository/transaction_repository.dart'; +import '../../presentation/home/cubit/home_cubit.dart'; +import '../../presentation/income/cubit/add_income_cubit.dart'; +import '../../presentation/login/cubit/login_cubit.dart'; +import '../../presentation/statistics/cubit/statistics_cubit.dart'; +import '../../presentation/transactions/cubit/transaction_cubit.dart'; final getIt = GetIt.instance; @@ -69,4 +73,22 @@ void initDI() { () => TransactionCubit(transactionRepository: getIt()), ); + + // Statistics + // TODO: Remove this when done testing UI + getIt.registerLazySingleton( + () => FakeStatisticsRepository(), + ); + + // getIt.registerLazySingleton( + // () => StatisticsRepositoryImpl( + // supabaseService: getIt(), + // ), + // ); + + getIt.registerFactory( + () => StatisticsCubit( + repository: getIt(), + ), + ); } diff --git a/lib/core/l10n/app_ar.arb b/lib/core/l10n/app_ar.arb index 4e968d56..dfc85f71 100644 --- a/lib/core/l10n/app_ar.arb +++ b/lib/core/l10n/app_ar.arb @@ -12,24 +12,23 @@ "moneyAmount": "{amount} {currency} ", "date": "التاريخ", "setUpYourAccount": "لنقم بإعداد حسابك", - "setSalary": "تحديد الراتب", - "currency": "العملة", - "salaryDay": "يوم الراتب", - "fromEachMonth": "من كل شهر", - "salary": "الراتب", - "accountSetup": "إعداد الحساب", - "search": "...بحث", - "select": "اختيار", - "next": "التالي", - "finishSetup": "إنهاء الإعداد", - "stepOf": "الخطوة {current} من {total}", - "stepOfTotal": "الخطوة {current} من {total}", + "setSalary": "تحديد الراتب", + "currency": "العملة", + "salaryDay": "يوم الراتب", + "fromEachMonth": "من كل شهر", + "salary": "الراتب", + "accountSetup": "إعداد الحساب", + "search": "...بحث", + "select": "اختيار", + "next": "التالي", + "finishSetup": "إنهاء الإعداد", + "stepOf": "الخطوة {current} من {total}", + "stepOfTotal": "الخطوة {current} من {total}", "forgetPasswordAppBarTitle": "نسيت كلمة السر", "forgetPasswordTitle": "نسيت كلمة المرور الخاصة بك", "forgetPasswordSubtitle": "أدخل بريدك الإلكتروني وسنرسل لك رابطًا لإعادة التعيين", "forgetPasswordEmailHint": "البريد الإلكتروني", "forgetPasswordButton": "إرسال رابط إعادة التعيين", - "updatePasswordAppBarTitle": "تحديث كلمة المرور", "updatePasswordTitle": "أدخل كلمة مرور جديدة لـ", "updatePasswordPasswordHint": "كلمة المرور الجديدة", @@ -38,9 +37,9 @@ "updatePasswordSuccessMessage": "تم تحديث كلمة المرور بنجاح", "updatePasswordErrorMessage": "خطأ في تحديث كلمة المرور", "error": "خطأ", - "success" : "نجاح", - "loading" : "تحميل", - "login_successfully" : "تم تسجيل الدخول بنجاح", + "success": "نجاح", + "loading": "تحميل", + "login_successfully": "تم تسجيل الدخول بنجاح", "login_welcome_title": "أهلاً بك مجدداً!", "login_welcome_subtitle": "أدخل بياناتك الخاصة للوصول إلى حسابك", "login_email_hint": "البريد الإلكتروني", @@ -74,6 +73,28 @@ "currencyCode": "دينار", "spendingTrend": "اتجاه الإنفاق", "noDataAvailable": "لا توجد بيانات متاحة", + "statistics": "الإحصائيات", + "monthly_overview": "نظرة عامة شهرية", + "savings_message": "لقد وفرت {amount} {currency} هذا الشهر", + "@savings_message": { + "placeholders": { + "amount": {}, + "currency": {} + } + }, + "highest_spending_message": "أعلى إنفاق لك كان في {date}", + "@highest_spending_message": { + "placeholders": { + "date": {} + } + }, + "no_statistics_title": "لا توجد بيانات كافية بعد", + "no_statistics_subtitle": "أضف المزيد من المعاملات لرؤية الإحصائيات", + "add_transaction": "إضافة معاملة", + "no_monthly_overview": "لم يتم تسجيل أي دخل أو مصروفات لهذه الفترة", + "error_loading_statistics": "فشل تحميل الإحصائيات", + "retry": "إعادة المحاولة", + "noDataAvailable": "لا توجد بيانات متاحة", "no_spending_categories": "لا يوجد معاملات متاحة", "month_january": "يناير", "month_february": "فبراير", @@ -96,9 +117,9 @@ "transaction": "المعاملات", "transaction_error_title": "خطأ في المعاملات", "transaction_error_content": "فشل في تحميل المعاملات", - "manage_categories" : "ادارةالتصنيفات", + "manage_categories": "ادارةالتصنيفات", "add_new_category": "اضافة تصنيف جديد", - "add_custom_category" : "اضافة تصنيف مخصص", - "category_name" : "اسم التصنيف", - "add" : "اضافة" + "add_custom_category": "اضافة تصنيف مخصص", + "category_name": "اسم التصنيف", + "add": "اضافة" } diff --git a/lib/core/l10n/app_en.arb b/lib/core/l10n/app_en.arb index 01cd477d..0ef30aa9 100644 --- a/lib/core/l10n/app_en.arb +++ b/lib/core/l10n/app_en.arb @@ -38,19 +38,18 @@ "next": "Next", "finishSetup": "Finish setup", "stepOf": "Step {current} of {total}", - "stepOfTotal": "Step {current} of {total}", - "@stepOfTotal": { - "placeholders": { - "current": {}, - "total": {} - } - }, + "stepOfTotal": "Step {current} of {total}", + "@stepOfTotal": { + "placeholders": { + "current": {}, + "total": {} + } + }, "forgetPasswordAppBarTitle": "Forget Password", "forgetPasswordTitle": "Forget your password", "forgetPasswordSubtitle": "Enter your email and we’ll send you a reset link", "forgetPasswordEmailHint": "Email", "forgetPasswordButton": "Send Reset Link", - "updatePasswordAppBarTitle": "Update Password", "updatePasswordTitle": "Enter a new password for", "updatePasswordPasswordHint": "New Password", @@ -58,11 +57,10 @@ "updatePasswordButton": "Update Password", "updatePasswordSuccessMessage": "Password updated successfully", "updatePasswordErrorMessage": "Error updating password", - "error": "Error", - "success" : "Success", - "loading" : "Loading", - "login_successfully" : "Login Successfully", + "success": "Success", + "loading": "Loading", + "login_successfully": "Login Successfully", "login_welcome_title": "Welcome again!", "login_welcome_subtitle": "Enter your credentials to access your account", "login_email_hint": "Email", @@ -93,7 +91,6 @@ "auth_error_not_implemented": "This feature is not available on the server", "auth_error_default": "An unexpected error occurred. Please try again later", "no_internet_connection": "No internet connection", - "no_transaction_record_title": "No transaction records", "no_transaction_record_content": "Add your first one to get started", "add_transaction": "Add transaction", @@ -103,7 +100,6 @@ "transaction": "Transaction", "transaction_error_title": "transactions error", "transaction_error_content": "failed to load transaction", - "currencyCode": "IQD", "@stepOf": { "placeholders": { @@ -115,13 +111,7 @@ } } }, - "stepOfTotal": "Step {current} of {total}", - "@stepOfTotal": { - "placeholders": { - "current": {}, - "total": {} - } - }, + "monthly_overview": "Monthly Overview", "no_spending_categories": "No spending categories available", "month_january": "January", "month_february": "February", @@ -135,9 +125,36 @@ "month_october": "October", "month_november": "November", "month_december": "December", - "manage_categories" : "Manage categories", + "manage_categories": "Manage categories", "add_new_category": "Add new category", - "add_custom_category" : "Add custom category", - "category_name" : "Category name", - "add" : "Add" + "add_custom_category": "Add custom category", + "category_name": "Category name", + "stepOfTotal": "Step {current} of {total}", + "@stepOfTotal": { + "placeholders": { + "current": {}, + "total": {} + } + }, + "statistics": "Statistics", + "monthly_overview": "Monthly Overview", + "savings_message": "You saved {amount} {currency} this month", + "@savings_message": { + "placeholders": { + "amount": {}, + "currency": {} + } + }, + "highest_spending_message": "Your highest spending was on {date}", + "@highest_spending_message": { + "placeholders": { + "date": {} + } + }, + "no_statistics_title": "Not enough data yet", + "no_statistics_subtitle": "Add more transactions to see insights", + "add_transaction": "Add transaction", + "no_monthly_overview": "No income or expenses recorded for this period", + "error_loading_statistics": "Failed to load statistics", + "retry": "Retry" } \ No newline at end of file diff --git a/lib/core/utils/number_formatter.dart b/lib/core/utils/number_formatter.dart new file mode 100644 index 00000000..f4fc68e1 --- /dev/null +++ b/lib/core/utils/number_formatter.dart @@ -0,0 +1,15 @@ +class NumberFormatter { + NumberFormatter._(); + + static String formatWithCommas(double value) { + final intValue = value.toInt(); + return intValue.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]},', + ); + } + + static String formatWithCurrency(double value, String currency) { + return '${formatWithCommas(value)} $currency'; + } +} diff --git a/lib/data/repository/fake_statistics_repository.dart b/lib/data/repository/fake_statistics_repository.dart new file mode 100644 index 00000000..6724f49d --- /dev/null +++ b/lib/data/repository/fake_statistics_repository.dart @@ -0,0 +1,18 @@ +import '../../domain/entity/monthly_overview.dart'; +import '../../domain/repository/statistics_repository.dart'; + +class FakeStatisticsRepository implements StatisticsRepository { + @override + Future getMonthlyOverview({required DateTime month}) async { + // Simulate network delay + await Future.delayed(const Duration(seconds: 1)); + + return const MonthlyOverview( + income: 5000.0, + expenses: 3500.0, + currency: "AED", + maxValue: 6000.0, + scaleLabels: ["0", "1k", "2k", "3k", "4k", "5k", "6k"], + ); + } +} diff --git a/lib/data/repository/statistics_repository_impl.dart b/lib/data/repository/statistics_repository_impl.dart new file mode 100644 index 00000000..e89bde74 --- /dev/null +++ b/lib/data/repository/statistics_repository_impl.dart @@ -0,0 +1,133 @@ + +import '../../data/service/supabase_service.dart'; +import '../../domain/entity/monthly_overview.dart'; +import '../../domain/repository/statistics_repository.dart'; + +class StatisticsRepositoryImpl implements StatisticsRepository { + final SupabaseService _supabaseService; + + StatisticsRepositoryImpl({required SupabaseService supabaseService}) + : _supabaseService = supabaseService; + + @override + Future getMonthlyOverview({required DateTime month}) async { + final client = await _supabaseService.getClient(); + // final userId = client.auth.currentUser?.id; + + final userId = "cdc4f388-4c4a-4ab4-84b1-5076fbe759c9"; + if (userId == null) { + return null; + } + + + // Get first and last day of the month + final startOfMonth = DateTime(month.year, month.month, 1); + final endOfMonth = DateTime(month.year, month.month + 1, 0, 23, 59, 59); + + // Query transactions for the month + final response = await client + .from('transactions') + .select(''' + amount, + transaction_type, + currency_id, + currencies!inner(abbreviation, abbreviation_ar) + ''') + .eq('user_id', userId) + .limit(100); + + /* .gte('created_at', startOfMonth.toIso8601String()) + .lte('created_at', endOfMonth.toIso8601String());*/ + + final transactions = response as List; + + print('Total transactions found: ${transactions.length}'); + + if (transactions.isEmpty) { + return null; + } + + // Calculate income and expenses + // Assuming transaction_type: 1 = income, 2 = expense (adjust based on your schema) + double totalIncome = 0; + double totalExpenses = 0; + String currency = 'IQD'; + + for (final transaction in transactions) { + final amount = (transaction['amount'] as num).toDouble(); + final type = transaction['transaction_type'] as int; + + // Get currency from first transaction + if (transaction['currencies'] != null) { + currency = transaction['currencies']['abbreviation'] ?? 'IQD'; + } + + if (type == 1) { + // Income + totalIncome += amount; + } else { + // Expense + totalExpenses += amount; + } + } + + // Calculate max value and scale labels dynamically + final maxAmount = totalIncome > totalExpenses ? totalIncome : totalExpenses; + final maxValue = _calculateMaxValue(maxAmount); + final scaleLabels = _generateScaleLabels(maxValue); + + return MonthlyOverview( + income: totalIncome, + expenses: totalExpenses, + currency: currency, + maxValue: maxValue, + scaleLabels: scaleLabels, + ); + } + + double _calculateMaxValue(double maxAmount) { + if (maxAmount <= 0) return 100000; + + // Round up to nearest significant value + final magnitude = maxAmount.toString().length - 1; + final base = _pow(10, magnitude).toDouble(); + return ((maxAmount / base).ceil() * base).toDouble(); + } + + int _pow(int base, int exponent) { + int result = 1; + for (int i = 0; i < exponent; i++) { + result *= base; + } + return result; + } + + List _generateScaleLabels(double maxValue) { + final step = maxValue / 8; + final labels = []; + + for (int i = 0; i <= 8; i++) { + final value = step * i; + labels.add(_formatScaleValue(value)); + } + + return labels; + } + + String _formatScaleValue(double value) { + if (value == 0) return '0'; + if (value >= 1000000) { + final millions = value / 1000000; + return millions == millions.truncate() + ? '${millions.truncate()}M' + : '${millions.toStringAsFixed(1)}M'; + } + if (value >= 1000) { + final thousands = value / 1000; + return thousands == thousands.truncate() + ? '${thousands.truncate()}K' + : '${thousands.toStringAsFixed(0)}K'; + } + return value.toStringAsFixed(0); + } +} \ No newline at end of file diff --git a/lib/design_system/assets/app_assets.dart b/lib/design_system/assets/app_assets.dart index 5aefe23a..6b374ff8 100644 --- a/lib/design_system/assets/app_assets.dart +++ b/lib/design_system/assets/app_assets.dart @@ -6,7 +6,7 @@ class AppAssets { static const icAppLogo = '${_icons}ic_app_logo.svg'; static const icArrowDown = '$_icons/ic_arrow_down.svg'; static const icArrowUp = '$_icons/ic_arrow_up.svg'; - static const icArrowRight ='$_icons/ic_arrow_right.svg'; + static const icArrowRight = '$_icons/ic_arrow_right.svg'; static const String iconCancel = "$_icons/ic_cancel.svg"; static const String iconError = "$_icons/ic_error.svg"; static const String iconSuccess = "$_icons/ic_success.svg"; @@ -19,7 +19,8 @@ class AppAssets { static const String icAccountGray = '$_icons/ic_account_gray.svg'; static const String icStatisticsPrimary = '$_icons/ic_statistics_primary.svg'; static const String icStatisticsGray = '$_icons/ic_statistics_gray.svg'; - static const String icTransactionPrimary = '$_icons/ic_transaction_primary.svg'; + static const String icTransactionPrimary = + '$_icons/ic_transaction_primary.svg'; static const String icTransactionGray = '$_icons/ic_transaction_gray.svg'; static const String icArrowDownRound = '$_icons/ic_arrow_down_round.svg'; static const String icCalender = '$_icons/ic_calendar.svg'; @@ -56,6 +57,8 @@ class AppAssets { static const String icLoading = "$_icons/ic_loading.svg"; static const String icAmountPrimary = '$_icons/ic_amount_primary.svg'; static const String icAmountGray = '$_icons/ic_amount_gray.svg'; + static const String icWalletAdd = '$_icons/ic_wallet_add.svg'; + static const String icMoneyRemove = '$_icons/ic_money_remove.svg'; static const String lineSeparator = '$_images/line-separator.png'; static const String transactionCoinStack = '$_images/transaction-coin-stack.png'; static const String transactionDetailsBackground = '$_images/transaction-details-background.png'; diff --git a/lib/design_system/constants/design_constants.dart b/lib/design_system/constants/design_constants.dart new file mode 100644 index 00000000..97806913 --- /dev/null +++ b/lib/design_system/constants/design_constants.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +class DesignConstants { + DesignConstants._(); + + // Border Radius + static const double radiusSmall = 8.0; + static const double radiusMedium = 12.0; + static const double radiusLarge = 20.0; + static const double radiusXLarge = 30.0; + + // Spacing + static const double spacingXSmall = 4.0; + static const double spacingSmall = 8.0; + static const double spacingMedium = 12.0; + static const double spacingLarge = 16.0; + static const double spacingXLarge = 24.0; + + // Icon Sizes + static const double iconSizeSmall = 14.0; + static const double iconSizeMedium = 16.0; + static const double iconSizeLarge = 28.0; + + // Component Sizes + static const double summaryIconContainerSize = 28.0; + static const double progressBarHeight = 20.0; + static const double savingsBannerHeight = 22.0; + static const double indicatorCircleSize = 6.0; + + // Progress Bar + static const double progressBarBorderWidth = 0.5; + static const double progressBarBackgroundBorderWidth = 1.0; + static const double progressBarMinWidth = 20.0; + + // Shadow + static const Offset progressBarShadowOffset = Offset(0, 4); + static const double progressBarShadowBlur = 8.0; + static const double progressBarShadowOpacity = 0.12; + static const int strokeAlpha = 10; +} + +class GradientColors { + GradientColors._(); + + static const Color incomeStart = Color(0xFF0496AD); + static const Color incomeEnd = Color(0xFF097C8E); + + static const Color expenseStart = Color(0xFFDC143C); + static const Color expenseEnd = Color(0xFFA01A35); + + static List get incomeGradient => [incomeStart, incomeEnd]; + + static List get expenseGradient => [expenseStart, expenseEnd]; +} diff --git a/lib/design_system/widgets/app_bar.dart b/lib/design_system/widgets/app_bar.dart index aa894fcb..75835e6e 100644 --- a/lib/design_system/widgets/app_bar.dart +++ b/lib/design_system/widgets/app_bar.dart @@ -1,29 +1,29 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:moneyplus/design_system/assets/app_assets.dart'; -import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + import 'package:flutter/material.dart'; + import 'package:flutter_svg/flutter_svg.dart'; + import 'package:moneyplus/design_system/assets/app_assets.dart'; + import 'package:moneyplus/design_system/theme/money_extension_context.dart'; -class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { - final String? title; - final Widget? leading; - final Widget? trailing; - final double? leadingWidth; - final Color? backgroundColor; + class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { + final String? title; + final Widget? leading; + final Widget? trailing; + final double? leadingWidth; + final Color? backgroundColor; - const CustomAppBar({ - super.key, - this.title, - this.leading, - this.trailing, - this.leadingWidth, - this.backgroundColor, - }); + const CustomAppBar({ + super.key, + this.title, + this.leading, + this.trailing, + this.leadingWidth, + this.backgroundColor, + }); - @override - Widget build(BuildContext context) { - final typo = context.typography; - final colors = context.colors; - final contentColor = colors.title; + @override + Widget build(BuildContext context) { + final typo = context.typography; + final colors = context.colors; + final contentColor = colors.title; return AppBar( backgroundColor: backgroundColor, @@ -32,81 +32,81 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { leadingWidth: leadingWidth, automaticallyImplyLeading: false, - title: title != null - ? Text(title!, style: typo.title.small.copyWith(color: contentColor)) - : null, + title: title != null + ? Text(title!, style: typo.title.small.copyWith(color: contentColor)) + : null, - leading: leading != null - ? Padding( - padding: const EdgeInsetsDirectional.only(start: 16.0), - child: leading!, - ) - : null, + leading: leading != null + ? Padding( + padding: const EdgeInsetsDirectional.only(start: 16.0), + child: leading!, + ) + : null, - actions: [ - if (trailing != null) - Padding( - padding: const EdgeInsetsDirectional.only(end: 16), - child: trailing!, - ), - ], - ); - } + actions: [ + if (trailing != null) + Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: trailing!, + ), + ], + ); + } - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + } -class AppBarCircleButton extends StatelessWidget { - final String assetPath; - final VoidCallback? onTap; + class AppBarCircleButton extends StatelessWidget { + final String assetPath; + final VoidCallback? onTap; - const AppBarCircleButton({super.key, required this.assetPath, this.onTap}); + const AppBarCircleButton({super.key, required this.assetPath, this.onTap}); - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: context.colors.surfaceHigh, - shape: BoxShape.circle, + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: context.colors.surfaceHigh, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: SvgPicture.asset(assetPath, width: 20, height: 20), ), - alignment: Alignment.center, - child: SvgPicture.asset(assetPath, width: 20, height: 20), - ), - ); + ); + } } -} -class AppBarCalendar extends StatelessWidget { - final VoidCallback onTap; - final String date; + class AppBarCalendar extends StatelessWidget { + final VoidCallback onTap; + final String date; - const AppBarCalendar({super.key, required this.onTap, required this.date}); + const AppBarCalendar({super.key, required this.onTap, required this.date}); - @override - Widget build(BuildContext context) { - final typo = context.typography; - final colors = context.colors; - final contentColor = colors.title; + @override + Widget build(BuildContext context) { + final typo = context.typography; + final colors = context.colors; + final contentColor = colors.title; - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Text(date, style: typo.label.small.copyWith(color: contentColor)), - SvgPicture.asset(AppAssets.icNormalArrowDown, width: 20, height: 20), - ], + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + Text(date, style: typo.label.small.copyWith(color: contentColor)), + SvgPicture.asset(AppAssets.icNormalArrowDown, width: 20, height: 20), + ], + ), ), - ), - ); + ); + } } -} diff --git a/lib/design_system/widgets/app_empty_view.dart b/lib/design_system/widgets/app_empty_view.dart new file mode 100644 index 00000000..f6ff2885 --- /dev/null +++ b/lib/design_system/widgets/app_empty_view.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class AppEmptyView extends StatelessWidget { + final String title; + final String subtitle; + final String? buttonText; + final VoidCallback? onButtonPressed; + + const AppEmptyView({ + super.key, + required this.title, + required this.subtitle, + this.buttonText, + this.onButtonPressed, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + // todo : AppAssets.imgStatisticsEmpty, + AppAssets.logo, + color: Colors.red, + width: 120, + height: 120, + ), + const SizedBox(height: 24), + Text( + title, + style: context.typography.title.medium.copyWith( + color: context.colors.title, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: context.typography.body.small.copyWith( + color: context.colors.body, + ), + textAlign: TextAlign.center, + ), + if (buttonText != null && onButtonPressed != null) ...[ + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + 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!), + ), + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/design_system/widgets/app_error_view.dart b/lib/design_system/widgets/app_error_view.dart new file mode 100644 index 00000000..7112a9ee --- /dev/null +++ b/lib/design_system/widgets/app_error_view.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/core/l10n/app_localizations.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class AppErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const AppErrorView({ + super.key, + required this.message, + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: context.colors.primary, + ), + const SizedBox(height: 16), + Text( + l10n.error_loading_statistics, + style: context.typography.title.small.copyWith( + color: context.colors.title, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + message, + style: context.typography.body.small.copyWith( + color: context.colors.body, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onRetry, + child: Text(l10n.retry), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/design_system/widgets/app_loading_indicator.dart b/lib/design_system/widgets/app_loading_indicator.dart new file mode 100644 index 00000000..4662ec15 --- /dev/null +++ b/lib/design_system/widgets/app_loading_indicator.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: CircularProgressIndicator( + color: context.colors.primary, + ), + ); + } +} \ No newline at end of file diff --git a/lib/domain/entity/monthly_overview.dart b/lib/domain/entity/monthly_overview.dart new file mode 100644 index 00000000..79d827c4 --- /dev/null +++ b/lib/domain/entity/monthly_overview.dart @@ -0,0 +1,29 @@ +class MonthlyOverview { + final double income; + final double expenses; + final String currency; + final double maxValue; + final List scaleLabels; + + const MonthlyOverview({ + required this.income, + required this.expenses, + required this.currency, + required this.maxValue, + required this.scaleLabels, + }); + + double get savings => income - expenses; + + bool get hasSavings => savings > 0; + + bool get isEmpty => income == 0 && expenses == 0; + + double get incomePercentage => maxValue > 0 + ? (income / maxValue).clamp(0.0, 1.0) + : 0.0; + + double get expensePercentage => maxValue > 0 + ? (expenses / maxValue).clamp(0.0, 1.0) + : 0.0; +} \ No newline at end of file diff --git a/lib/domain/repository/statistics_repository.dart b/lib/domain/repository/statistics_repository.dart new file mode 100644 index 00000000..b51c3028 --- /dev/null +++ b/lib/domain/repository/statistics_repository.dart @@ -0,0 +1,5 @@ +import '../entity/monthly_overview.dart'; + +abstract class StatisticsRepository { + Future getMonthlyOverview({required DateTime month}); +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 613afd71..fc7f6a58 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; -import 'package:moneyplus/di/injection.dart'; import 'package:moneyplus/money_app.dart'; +import 'core/di/injection.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/money_app.dart b/lib/money_app.dart index dcff974c..3adb9961 100644 --- a/lib/money_app.dart +++ b/lib/money_app.dart @@ -7,7 +7,8 @@ import 'package:moneyplus/presentation/navigation/routes.dart'; import 'core/l10n/app_localizations.dart'; final _router = GoRouter( - routes: $appRoutes, + routes: $appRoutes, + initialLocation: '/statistics' ); diff --git a/lib/presentation/account_setup/screen/account_setup_screen.dart b/lib/presentation/account_setup/screen/account_setup_screen.dart index a461d3d6..3c462347 100644 --- a/lib/presentation/account_setup/screen/account_setup_screen.dart +++ b/lib/presentation/account_setup/screen/account_setup_screen.dart @@ -4,11 +4,10 @@ import 'package:flutter_svg/svg.dart'; import 'package:moneyplus/design_system/assets/app_assets.dart'; import 'package:moneyplus/design_system/widgets/app_bar.dart'; import 'package:moneyplus/presentation/account_setup/screen/page1.dart'; - +import '../../../core/di/injection.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../design_system/theme/money_extension_context.dart'; import '../../../design_system/widgets/buttons/button/default_button.dart'; -import '../../../di/injection.dart'; import '../cubit/account_setup_cubit.dart'; import '../cubit/account_setup_state.dart'; diff --git a/lib/presentation/forget_password/screen/forget_password_screen.dart b/lib/presentation/forget_password/screen/forget_password_screen.dart index fcce2451..48f891cb 100644 --- a/lib/presentation/forget_password/screen/forget_password_screen.dart +++ b/lib/presentation/forget_password/screen/forget_password_screen.dart @@ -5,12 +5,12 @@ import 'package:moneyplus/design_system/assets/app_assets.dart'; import 'package:moneyplus/design_system/widgets/app_bar.dart'; import 'package:moneyplus/design_system/widgets/app_logo.dart'; import 'package:moneyplus/design_system/widgets/text_field.dart'; -import 'package:moneyplus/di/injection.dart'; import 'package:moneyplus/domain/repository/authentication_repository.dart'; import 'package:moneyplus/presentation/forget_password/cubit/forget_password_cubit.dart'; import 'package:moneyplus/presentation/update_password/screen/update_password_screen.dart'; import 'package:svg_flutter/svg.dart'; +import '../../../core/di/injection.dart'; import '../../../design_system/theme/money_extension_context.dart'; import '../../../design_system/widgets/buttons/button/default_button.dart'; import '../cubit/forget_password_state.dart'; diff --git a/lib/presentation/home/screen/home_screen.dart b/lib/presentation/home/screen/home_screen.dart index d4acf7ad..fb660f1e 100644 --- a/lib/presentation/home/screen/home_screen.dart +++ b/lib/presentation/home/screen/home_screen.dart @@ -6,11 +6,10 @@ import 'package:moneyplus/design_system/theme/money_colors.dart'; import 'package:moneyplus/design_system/theme/money_extension_context.dart'; import 'package:moneyplus/design_system/widgets/income_expense.dart'; import 'package:moneyplus/design_system/widgets/top_spending_card.dart'; -import 'package:moneyplus/di/injection.dart'; import 'package:moneyplus/presentation/home/cubit/home_cubit.dart'; import 'package:moneyplus/presentation/home/cubit/home_state.dart'; import 'package:moneyplus/presentation/home/widget/current_balance.dart'; - +import '../../../core/di/injection.dart'; import '../../../design_system/widgets/buttons/button/varient_button.dart'; import '../../../design_system/widgets/buttons/secondary/sm_secondary_button.dart'; import '../utils/StringFormattingHelpers.dart'; diff --git a/lib/presentation/income/screen/income_screen.dart b/lib/presentation/income/screen/income_screen.dart index 0f2e8469..3d0da5c3 100644 --- a/lib/presentation/income/screen/income_screen.dart +++ b/lib/presentation/income/screen/income_screen.dart @@ -3,7 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; import 'package:moneyplus/design_system/assets/app_assets.dart'; import 'package:moneyplus/domain/model/form_status.dart'; - +import '../../../core/di/injection.dart'; import '../../../core/l10n/app_localizations.dart'; import '../../../design_system/theme/money_extension_context.dart'; import '../../../design_system/widgets/app_bar.dart'; @@ -12,7 +12,6 @@ import '../../../design_system/widgets/chip.dart'; import '../../../design_system/widgets/snack_bar.dart'; import '../../../design_system/widgets/text_field.dart'; import '../../../design_system/widgets/text_field_date_Picker.dart'; -import '../../../di/injection.dart'; import '../cubit/add_income_cubit.dart'; import '../cubit/add_income_state.dart'; diff --git a/lib/presentation/navigation/routes.dart b/lib/presentation/navigation/routes.dart index 5a2cb58a..793eafea 100644 --- a/lib/presentation/navigation/routes.dart +++ b/lib/presentation/navigation/routes.dart @@ -1,11 +1,11 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:moneyplus/presentation/login/screen/login_screen.dart'; - -import '../../di/injection.dart'; +import '../../core/di/injection.dart'; import '../login/cubit/login_cubit.dart'; +import '../statistics/cubit/statistics_cubit.dart'; +import '../statistics/statistics_screen.dart'; import '../main_container/screen/main_screen.dart'; part 'routes.g.dart'; @@ -55,3 +55,17 @@ class MainRoute extends GoRouteData with $MainRoute { return const MainScreen(); } } + +@TypedGoRoute(path: '/statistics') +@immutable +class StatisticsRoute extends GoRouteData with $StatisticsRoute { + const StatisticsRoute(); + + @override + Widget build(BuildContext context, GoRouterState state) { + return BlocProvider( + create: (_) => getIt(), + child: const StatisticsScreen(), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/navigation/routes.g.dart b/lib/presentation/navigation/routes.g.dart index ebd1694f..1096931f 100644 --- a/lib/presentation/navigation/routes.g.dart +++ b/lib/presentation/navigation/routes.g.dart @@ -6,7 +6,12 @@ part of 'routes.dart'; // GoRouterGenerator // ************************************************************************** -List get $appRoutes => [$onBoardingRoute, $loginRoute, $mainRoute]; +List get $appRoutes => [ + $onBoardingRoute, + $loginRoute, + $mainRoute, + $statisticsRoute, +]; RouteBase get $onBoardingRoute => GoRouteData.$route(path: '/', factory: $OnBoardingRoute._fromState); @@ -77,3 +82,29 @@ mixin $MainRoute on GoRouteData { @override void replace(BuildContext context) => context.replace(location); } + +RouteBase get $statisticsRoute => GoRouteData.$route( + path: '/statistics', + factory: $StatisticsRoute._fromState, +); + +mixin $StatisticsRoute on GoRouteData { + static StatisticsRoute _fromState(GoRouterState state) => + const StatisticsRoute(); + + @override + String get location => GoRouteData.$location('/statistics'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} diff --git a/lib/presentation/statistics/cubit/statistics_cubit.dart b/lib/presentation/statistics/cubit/statistics_cubit.dart new file mode 100644 index 00000000..80f41634 --- /dev/null +++ b/lib/presentation/statistics/cubit/statistics_cubit.dart @@ -0,0 +1,36 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repository/statistics_repository.dart'; +import 'statistics_state.dart'; + +class StatisticsCubit extends Cubit { + final StatisticsRepository _repository; + + StatisticsCubit({required StatisticsRepository repository}) + : _repository = repository, + super(const StatisticsIdle()); + + Future loadStatistics({DateTime? month}) async { + final selectedMonth = month ?? DateTime.now(); + + emit(const StatisticsLoading()); + + try { + final monthlyOverview = await _repository.getMonthlyOverview( + month: selectedMonth, + ); + + emit( + StatisticsSuccess( + monthlyOverview: monthlyOverview, + selectedMonth: selectedMonth, + ), + ); + } catch (e) { + emit(StatisticsFailure(e.toString())); + } + } + + void changeMonth(DateTime month) { + loadStatistics(month: month); + } +} diff --git a/lib/presentation/statistics/cubit/statistics_state.dart b/lib/presentation/statistics/cubit/statistics_state.dart new file mode 100644 index 00000000..5387a977 --- /dev/null +++ b/lib/presentation/statistics/cubit/statistics_state.dart @@ -0,0 +1,31 @@ +import '../../../domain/entity/monthly_overview.dart'; + +sealed class StatisticsState { + const StatisticsState(); +} + +class StatisticsIdle extends StatisticsState { + const StatisticsIdle(); +} + +class StatisticsLoading extends StatisticsState { + const StatisticsLoading(); +} + +class StatisticsSuccess extends StatisticsState { + final MonthlyOverview? monthlyOverview; + final DateTime selectedMonth; + + const StatisticsSuccess({ + required this.monthlyOverview, + required this.selectedMonth, + }); + + bool get hasNoData => monthlyOverview == null; +} + +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 new file mode 100644 index 00000000..ebdecc6b --- /dev/null +++ b/lib/presentation/statistics/statistics_screen.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.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_empty_view.dart'; +import 'package:moneyplus/design_system/widgets/app_error_view.dart'; +import 'package:moneyplus/design_system/widgets/app_loading_indicator.dart'; +import 'cubit/statistics_cubit.dart'; +import 'cubit/statistics_state.dart'; +import 'widgets/monthly_overview/monthly_overview_section.dart'; + +class StatisticsScreen extends StatefulWidget { + const StatisticsScreen({super.key}); + + @override + State createState() => _StatisticsScreenState(); +} + +class _StatisticsScreenState extends State { + @override + void initState() { + super.initState(); + context.read().loadStatistics(); + } + + void _onAddTransaction() { + // Navigate to add transaction + } + + void _onRetry() { + context.read().loadStatistics(); + } + + @override + Widget build(BuildContext context) { + 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, + ), + }; + }, + ), + ), + ); + } + + Widget _buildSuccess(BuildContext context, StatisticsSuccess state) { + final l10n = AppLocalizations.of(context)!; + + if (state.hasNoData) { + return AppEmptyView( + title: l10n.no_statistics_title, + subtitle: l10n.no_statistics_subtitle, + buttonText: l10n.add_transaction, + onButtonPressed: _onAddTransaction, + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (state.monthlyOverview != null) + MonthlyOverviewSection(overview: state.monthlyOverview!), + // TODO: Add other sections here + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/statistics/utils.dart b/lib/presentation/statistics/utils.dart new file mode 100644 index 00000000..4725c0e1 --- /dev/null +++ b/lib/presentation/statistics/utils.dart @@ -0,0 +1,8 @@ +String formatNumber(double value) { + if (value >= 1000000) { + return '${(value / 1000000).toStringAsFixed(1)}M'; + } else if (value >= 1000) { + return '${(value / 1000).toStringAsFixed(0)},${(value % 1000).toStringAsFixed(0).padLeft(3, '0')}'; + } + return value.toStringAsFixed(0); +} diff --git a/lib/presentation/statistics/widgets/monthly_overview/monthly_overview_section.dart b/lib/presentation/statistics/widgets/monthly_overview/monthly_overview_section.dart new file mode 100644 index 00000000..9e8c1229 --- /dev/null +++ b/lib/presentation/statistics/widgets/monthly_overview/monthly_overview_section.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:moneyplus/core/l10n/app_localizations.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +import '../../../../domain/entity/monthly_overview.dart'; +import '../section_empty_view.dart'; +import 'overview_progress_bar.dart'; +import 'overview_savings_banner.dart'; +import 'overview_scale_labels.dart'; +import 'summary_item.dart'; + +class MonthlyOverviewSection extends StatelessWidget { + final MonthlyOverview overview; + + const MonthlyOverviewSection({super.key, required this.overview}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + if (overview.isEmpty) { + return SectionEmptyView( + title: l10n.monthly_overview, + message: l10n.no_monthly_overview, + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Content(overview: overview), + if (overview.hasSavings) + OverviewSavingsBanner( + savings: overview.savings, + currency: overview.currency, + ), + ], + ); + } +} + +class _Content extends StatelessWidget { + final MonthlyOverview overview; + + const _Content({required this.overview}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Container( + padding: const EdgeInsets.all(DesignConstants.spacingMedium), + decoration: BoxDecoration( + color: context.colors.surfaceLow, + borderRadius: const BorderRadius.all( + Radius.circular(DesignConstants.radiusMedium), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.monthly_overview, + style: context.typography.label.medium.copyWith( + color: context.colors.title, + ), + ), + const SizedBox(height: DesignConstants.spacingMedium), + Row( + children: [ + Expanded( + child: SummaryItem( + icon: SvgPicture.asset( + AppAssets.icWalletAdd, + width: DesignConstants.iconSizeMedium, + height: DesignConstants.iconSizeMedium, + ), + label: l10n.income, + value: overview.income, + currency: overview.currency, + isIncome: true, + ), + ), + const SizedBox(width: DesignConstants.spacingXLarge), + Expanded( + child: SummaryItem( + icon: SvgPicture.asset( + AppAssets.icMoneyRemove, + width: DesignConstants.iconSizeMedium, + height: DesignConstants.iconSizeMedium, + ), + label: l10n.expense, + value: overview.expenses, + currency: overview.currency, + isIncome: false, + ), + ), + ], + ), + const SizedBox(height: DesignConstants.spacingXLarge), + OverviewProgressBar(overview: overview), + const SizedBox(height: DesignConstants.spacingMedium), + OverviewScaleLabels(labels: overview.scaleLabels), + ], + ), + ); + } +} diff --git a/lib/presentation/statistics/widgets/monthly_overview/overview_progress_bar.dart b/lib/presentation/statistics/widgets/monthly_overview/overview_progress_bar.dart new file mode 100644 index 00000000..2cd74547 --- /dev/null +++ b/lib/presentation/statistics/widgets/monthly_overview/overview_progress_bar.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; +import '../../../../domain/entity/monthly_overview.dart'; + +class OverviewProgressBar extends StatelessWidget { + final MonthlyOverview overview; + + const OverviewProgressBar({super.key, required this.overview}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _ProgressBar( + percentage: overview.incomePercentage, + gradientColors: GradientColors.incomeGradient, + shadowColor: GradientColors.incomeStart, + ), + const SizedBox(height: DesignConstants.spacingSmall), + _ProgressBar( + percentage: overview.expensePercentage, + gradientColors: GradientColors.expenseGradient, + shadowColor: GradientColors.expenseStart, + ), + ], + ); + } +} + +class _ProgressBar extends StatelessWidget { + final double percentage; + final List gradientColors; + final Color shadowColor; + + const _ProgressBar({ + required this.percentage, + required this.gradientColors, + required this.shadowColor, + }); + + @override + Widget build(BuildContext context) { + final strokeColor = context.colors.stroke; + + return SizedBox( + height: DesignConstants.progressBarHeight, + child: LayoutBuilder( + builder: (context, constraints) { + final totalWidth = constraints.maxWidth; + final progressWidth = totalWidth * percentage; + + return Stack( + children: [ + + Container( + height: DesignConstants.progressBarHeight, + decoration: BoxDecoration( + color: strokeColor.withAlpha(DesignConstants.strokeAlpha), + borderRadius: BorderRadius.circular( + DesignConstants.radiusXLarge, + ), + border: Border.all( + color: strokeColor.withAlpha(DesignConstants.strokeAlpha), + width: DesignConstants.progressBarBackgroundBorderWidth, + ), + ), + ), + + if (progressWidth > 0) + Container( + width: progressWidth.clamp( + DesignConstants.progressBarMinWidth, + totalWidth, + ), + height: DesignConstants.progressBarHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + DesignConstants.radiusXLarge, + ), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: gradientColors, + ), + border: Border.all( + color: strokeColor.withAlpha(DesignConstants.strokeAlpha), + width: DesignConstants.progressBarBorderWidth, + ), + boxShadow: [ + BoxShadow( + color: shadowColor.withOpacity( + DesignConstants.progressBarShadowOpacity, + ), + offset: DesignConstants.progressBarShadowOffset, + blurRadius: DesignConstants.progressBarShadowBlur, + ), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/presentation/statistics/widgets/monthly_overview/overview_savings_banner.dart b/lib/presentation/statistics/widgets/monthly_overview/overview_savings_banner.dart new file mode 100644 index 00000000..80adaa8a --- /dev/null +++ b/lib/presentation/statistics/widgets/monthly_overview/overview_savings_banner.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:moneyplus/core/l10n/app_localizations.dart'; +import 'package:moneyplus/core/utils/number_formatter.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class OverviewSavingsBanner extends StatelessWidget { + final double savings; + final String currency; + + const OverviewSavingsBanner({ + super.key, + required this.savings, + required this.currency, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Container( + height: DesignConstants.savingsBannerHeight, + padding: const EdgeInsets.symmetric( + horizontal: DesignConstants.spacingSmall, + ), + decoration: BoxDecoration( + color: context.colors.secondaryVariant, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(DesignConstants.radiusMedium), + bottomRight: Radius.circular(DesignConstants.radiusMedium), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + AppAssets.icWalletAdd, + width: DesignConstants.iconSizeSmall, + height: DesignConstants.iconSizeSmall, + colorFilter: ColorFilter.mode( + context.colors.secondary, + BlendMode.srcIn, + ), + ), + const SizedBox(width: DesignConstants.spacingXSmall), + Text( + l10n.savings_message( + NumberFormatter.formatWithCommas(savings), + currency, + ), + style: context.typography.label.xSmall?.copyWith( + color: context.colors.secondary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/statistics/widgets/monthly_overview/overview_scale_labels.dart b/lib/presentation/statistics/widgets/monthly_overview/overview_scale_labels.dart new file mode 100644 index 00000000..a3935925 --- /dev/null +++ b/lib/presentation/statistics/widgets/monthly_overview/overview_scale_labels.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class OverviewScaleLabels extends StatelessWidget { + final List labels; + + const OverviewScaleLabels({super.key, required this.labels}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: labels + .map( + (label) => Text( + label, + style: context.typography.label.xSmall?.copyWith( + color: context.colors.body, + ), + ), + ) + .toList(), + ); + } +} diff --git a/lib/presentation/statistics/widgets/monthly_overview/summary_item.dart b/lib/presentation/statistics/widgets/monthly_overview/summary_item.dart new file mode 100644 index 00000000..cbbaf6db --- /dev/null +++ b/lib/presentation/statistics/widgets/monthly_overview/summary_item.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/core/utils/number_formatter.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class SummaryItem extends StatelessWidget { + final Widget icon; + final String label; + final double value; + final String currency; + final bool isIncome; + + const SummaryItem({ + super.key, + required this.icon, + required this.label, + required this.value, + required this.currency, + required this.isIncome, + }); + + @override + Widget build(BuildContext context) { + final gradientColors = isIncome + ? GradientColors.incomeGradient + : GradientColors.expenseGradient; + + return Row( + children: [ + Container( + width: DesignConstants.summaryIconContainerSize, + height: DesignConstants.summaryIconContainerSize, + decoration: BoxDecoration( + color: isIncome + ? context.colors.secondaryVariant + : context.colors.primaryVariant, + borderRadius: BorderRadius.circular(DesignConstants.radiusSmall), + ), + alignment: Alignment.center, + child: icon, + ), + const SizedBox(width: DesignConstants.spacingSmall), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Container( + width: DesignConstants.indicatorCircleSize, + height: DesignConstants.indicatorCircleSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: gradientColors, + ), + ), + ), + const SizedBox(width: DesignConstants.spacingXSmall), + Text( + label, + style: context.typography.label.xSmall?.copyWith( + color: isIncome + ? context.colors.secondary + : context.colors.primary, + ), + ), + ], + ), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: isIncome ? '+' : '-', + style: context.typography.label.medium.copyWith( + color: isIncome + ? context.colors.green + : context.colors.primary, + ), + ), + TextSpan( + text: NumberFormatter.formatWithCurrency(value, currency), + style: context.typography.label.medium.copyWith( + color: context.colors.title, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/presentation/statistics/widgets/section_empty_view.dart b/lib/presentation/statistics/widgets/section_empty_view.dart new file mode 100644 index 00000000..a799f273 --- /dev/null +++ b/lib/presentation/statistics/widgets/section_empty_view.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:moneyplus/design_system/assets/app_assets.dart'; +import 'package:moneyplus/design_system/constants/design_constants.dart'; +import 'package:moneyplus/design_system/theme/money_extension_context.dart'; + +class SectionEmptyView extends StatelessWidget { + final String title; + final String message; + + const SectionEmptyView({ + super.key, + required this.title, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(DesignConstants.spacingMedium), + decoration: BoxDecoration( + color: context.colors.surfaceLow, + borderRadius: BorderRadius.circular(DesignConstants.radiusMedium), + ), + child: Column( + children: [ + Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + title, + style: context.typography.label.medium.copyWith( + color: context.colors.title, + ), + ), + ), + const SizedBox(height: 24), + Image.asset( + // todo : AppAssets.imgStatisticsEmpty, + AppAssets.logo, + width: 80, + height: 80, + ), + const SizedBox(height: 16), + Text( + message, + style: context.typography.body.small.copyWith( + color: context.colors.body, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/transactions/screen/transactions_screen.dart b/lib/presentation/transactions/screen/transactions_screen.dart index d2d943e4..b4e97335 100644 --- a/lib/presentation/transactions/screen/transactions_screen.dart +++ b/lib/presentation/transactions/screen/transactions_screen.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.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/snack_bar.dart'; -import 'package:moneyplus/di/injection.dart'; import 'package:moneyplus/presentation/transactions/cubit/transaction_cubit.dart'; import 'package:moneyplus/presentation/transactions/cubit/transaction_state.dart'; import 'package:moneyplus/presentation/transactions/widget/empty_transactions.dart'; @@ -11,6 +10,7 @@ import 'package:moneyplus/presentation/transactions/widget/loading_view.dart'; import 'package:moneyplus/presentation/transactions/widget/tabs_row.dart'; import 'package:moneyplus/presentation/transactions/widget/transaction_app_bar.dart'; import 'package:moneyplus/presentation/transactions/widget/transactions_list.dart'; +import '../../../core/di/injection.dart'; class TransactionsScreen extends StatelessWidget { const TransactionsScreen({super.key}); diff --git a/lib/presentation/update_password/screen/update_password_screen.dart b/lib/presentation/update_password/screen/update_password_screen.dart index 8c512bcb..a6b7386c 100644 --- a/lib/presentation/update_password/screen/update_password_screen.dart +++ b/lib/presentation/update_password/screen/update_password_screen.dart @@ -7,10 +7,9 @@ import 'package:moneyplus/design_system/widgets/app_bar.dart'; import 'package:moneyplus/design_system/widgets/app_logo.dart'; import 'package:moneyplus/design_system/widgets/snack_bar.dart'; import 'package:moneyplus/design_system/widgets/text_field.dart'; -import 'package:moneyplus/di/injection.dart'; import 'package:moneyplus/domain/repository/authentication_repository.dart'; import 'package:moneyplus/money_app.dart'; - +import '../../../core/di/injection.dart'; import '../../../design_system/widgets/buttons/button/default_button.dart'; import '../cubit/update_password_cubit.dart'; import '../cubit/update_password_state.dart';