Done Category, Product Catalogue, Customer, Vendor, Purchase Invoice Module

This commit is contained in:
2026-08-28 11:30:18 +05:30
parent 0d13833679
commit 189f49ed07
118 changed files with 12624 additions and 4004 deletions

579
analyze_output.txt Normal file
View File

@@ -0,0 +1,579 @@
Analyzing Kifi...
warning • Unused import: 'dart:convert' • kifi-api/scratch.dart:1:8 • unused_import
error • Target of URI doesn't exist: 'package:http/http.dart' • kifi-api/scratch.dart:2:8 • uri_does_not_exist
warning • The value of the local variable 'token' isn't used • kifi-api/scratch.dart:5:9 • unused_local_variable
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/core/theme/app_theme.dart:42:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/core/theme/app_theme.dart:46:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/core/theme/app_theme.dart:98:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/core/theme/app_theme.dart:102:53 • deprecated_member_use
warning • Unused import: 'package:shared_preferences/shared_preferences.dart' • kifi-app/lib/core/theme/theme_provider.dart:3:8 • unused_import
warning • The value of the field '_themePrefKey' isn't used • kifi-app/lib/core/theme/theme_provider.dart:6:16 • unused_field
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/core/widgets/smart_search_dropdown.dart:196:81 • deprecated_member_use
warning • Dead code • kifi-app/lib/core/widgets/smart_search_dropdown.dart:228:65 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/core/widgets/smart_search_dropdown.dart:228:68 • dead_null_aware_expression
info • Don't invoke 'print' in production code • kifi-app/lib/features/auth/presentation/auth_screen.dart:46:11 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/auth/presentation/auth_screen.dart:48:11 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/auth/presentation/auth_screen.dart:61:11 • avoid_print
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/auth/presentation/auth_screen.dart:63:13 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/auth_screen.dart:187:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/auth_screen.dart:223:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/forgot_password_screen.dart:111:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/otp_screen.dart:76:63 • deprecated_member_use
info • The import of 'package:cross_file/cross_file.dart' is unnecessary because all of the used elements are also provided by the import of 'package:share_plus/share_plus.dart' • kifi-app/lib/features/auth/presentation/profile_screen.dart:9:8 • unnecessary_import
info • The imported package 'cross_file' isn't a dependency of the importing package • kifi-app/lib/features/auth/presentation/profile_screen.dart:9:8 • depend_on_referenced_packages
warning • Unused import: '../providers/auth_provider.dart' • kifi-app/lib/features/auth/presentation/profile_screen.dart:12:8 • unused_import
warning • Unused import: '../../projects/providers/project_mode_provider.dart' • kifi-app/lib/features/auth/presentation/profile_screen.dart:16:8 • unused_import
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/auth/presentation/profile_screen.dart:79:7 • curly_braces_in_flow_control_structures
info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • kifi-app/lib/features/auth/presentation/profile_screen.dart:108:13 • deprecated_member_use
info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • kifi-app/lib/features/auth/presentation/profile_screen.dart:108:19 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/auth/presentation/profile_screen.dart:113:9 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:142:64 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:158:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:170:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:199:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:200:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:207:55 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:259:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:262:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:333:67 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:423:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:478:28 • deprecated_member_use
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/auth/presentation/profile_screen.dart:519:9 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/profile_screen.dart:530:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/reset_password_screen.dart:143:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:199:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:257:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:270:41 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:407:11 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:497:37 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart:563:7 • curly_braces_in_flow_control_structures
info • The private field _isSearching could be 'final' • kifi-app/lib/features/budget/presentation/budget_screen.dart:18:8 • prefer_final_fields
warning • The value of the field '_isSearching' isn't used • kifi-app/lib/features/budget/presentation/budget_screen.dart:18:8 • unused_field
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/budget/presentation/budget_screen.dart:81:25 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/budget/presentation/budget_screen.dart:110:60 • use_build_context_synchronously
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/budget/presentation/budget_screen.dart:168:23 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/budget/presentation/budget_screen.dart:362:35 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/budget/presentation/budget_screen.dart:363:40 • curly_braces_in_flow_control_structures
warning • Unused import: '../../../inventory/presentation/quick_adjust_stock_screen.dart' • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:7:8 • unused_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:63:64 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:68:43 • deprecated_member_use
warning • Dead code • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:233:48 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:233:51 • dead_null_aware_expression
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:243:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:270:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:271:51 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:275:50 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:276:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:285:54 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:286:57 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:375:24 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:377:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart:396:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/business/presentation/reports/reports_screen.dart:82:34 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/business/presentation/widgets/business_profile_form_sheet.dart:161:29 • deprecated_member_use
info • The private field _isSearching could be 'final' • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:21:8 • prefer_final_fields
warning • The value of the field '_isSearching' isn't used • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:21:8 • unused_field
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:118:25 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:129:44 • curly_braces_in_flow_control_structures
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:148:27 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:205:33 • deprecated_member_use
warning • The value of the local variable 'newWallet' isn't used • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:312:39 • unused_local_variable
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:382:23 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:637:45 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:646:64 • curly_braces_in_flow_control_structures
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:717:47 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:766:53 • deprecated_member_use
info • Don't invoke 'print' in production code • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:818:53 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:819:53 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:820:53 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:821:53 • avoid_print
info • The private field _autoCompleteCtrl could be 'final' • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:955:25 • prefer_final_fields
warning • The receiver can't be null, so the null-aware operator '?.' is unnecessary • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:1068:38 • invalid_null_aware_operator
warning • The receiver can't be null, so the null-aware operator '?.' is unnecessary • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:1068:92 • invalid_null_aware_operator
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:1082:69 • deprecated_member_use
info • Unnecessary use of multiple underscores • kifi-app/lib/features/dashboard/presentation/accounts_screen.dart:1088:49 • unnecessary_underscores
warning • Unused import: '../../projects/presentation/project_hub_screen.dart' • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:3:8 • unused_import
warning • Unused import: '../../projects/presentation/projects_screen.dart' • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:4:8 • unused_import
warning • Unused import: '../../sales/domain/invoice.dart' • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:21:8 • unused_import
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:167:28 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:177:30 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:178:35 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:179:35 • curly_braces_in_flow_control_structures
warning • The value of the local variable 'totalIncome' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:291:17 • unused_local_variable
warning • The value of the local variable 'totalExpense' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:292:17 • unused_local_variable
warning • The value of the local variable 'totalInvestment' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:293:17 • unused_local_variable
warning • The value of the local variable 'totalPayablesPeriod' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:295:18 • unused_local_variable
warning • The value of the local variable 'totalReceivablesPeriod' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:296:18 • unused_local_variable
warning • Dead code • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:348:52 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:348:55 • dead_null_aware_expression
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:590:64 • deprecated_member_use
warning • The value of the local variable 'safeIndex' isn't used • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:666:15 • unused_local_variable
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:705:24 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/dashboard_screen.dart:707:43 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart:222:17 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart:274:43 • use_build_context_synchronously
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart:275:50 • use_build_context_synchronously
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/dashboard/presentation/maturity_dialog.dart:283:50 • use_build_context_synchronously
warning • Unused import: '../../../core/theme/nature_colors.dart' • kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart:6:8 • unused_import
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart:47:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart:49:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:32:9 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:36:9 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:40:9 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:114:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:126:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart:246:42 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:49:11 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:53:11 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:57:11 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:66:17 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:69:17 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:130:31 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:136:31 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart:138:31 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart:53:49 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart:65:42 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart:207:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart:257:65 • deprecated_member_use
warning • Unused import: 'package:lucide_icons/lucide_icons.dart' • kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart:2:8 • unused_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart:82:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart:86:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart:97:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart:161:37 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart:142:42 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart:148:40 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart:152:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart:157:41 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart:172:30 • deprecated_member_use
info • The import of 'package:flutter_image_compress/flutter_image_compress.dart' is unnecessary because all of the used elements are also provided by the import of 'package:image_picker/image_picker.dart' • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:6:8 • unnecessary_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:273:87 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:310:70 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:369:70 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:422:13 • deprecated_member_use
warning • Dead code • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:490:28 • dead_code
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:496:58 • deprecated_member_use
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:509:25 • deprecated_member_use
info • 'surfaceVariant' is deprecated and shouldn't be used. Use surfaceContainerHighest instead. This feature was deprecated after v3.18.0-0.1.pre • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:623:52 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:623:67 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:676:59 • use_build_context_synchronously
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:701:64 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:702:83 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:729:53 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:730:57 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/add_product_screen.dart:759:61 • deprecated_member_use
warning • Unused import: '../../../core/theme/nature_colors.dart' • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:4:8 • unused_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:88:63 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:110:50 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:113:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:113:77 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:118:39 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:132:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:164:56 • deprecated_member_use
warning • Dead code • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:282:50 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:282:53 • dead_null_aware_expression
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:284:35 • dead_null_aware_expression
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:338:41 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:350:36 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:391:25 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:423:61 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:430:58 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:430:99 • deprecated_member_use
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:451:27 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:464:25 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:513:29 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:540:29 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:554:29 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/category_management_screen.dart:587:54 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart:167:50 • deprecated_member_use
info • Unnecessary use of multiple underscores • kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart:299:55 • unnecessary_underscores
warning • Unused import: 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart' • kifi-app/lib/features/inventory/presentation/product_detail_screen.dart:8:8 • unused_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/product_list_screen.dart:157:50 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/product_list_screen.dart:207:68 • deprecated_member_use
info • Missing type annotation • kifi-app/lib/features/inventory/presentation/product_list_screen.dart:302:29 • strict_top_level_inference
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/product_list_screen.dart:308:30 • deprecated_member_use
info • Missing type annotation • kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart:175:29 • strict_top_level_inference
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart:181:30 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart:87:38 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart:88:36 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/widgets/adjust_stock_sheet.dart:100:13 • deprecated_member_use
warning • Unused import: '../../../../core/theme/nature_colors.dart' • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:3:8 • unused_import
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:79:36 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:90:40 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:112:36 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:114:55 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart:126:15 • deprecated_member_use
info • Don't invoke 'print' in production code • kifi-app/lib/features/inventory/providers/product_categories_provider.dart:128:7 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/inventory/providers/product_categories_provider.dart:160:9 • avoid_print
info • The import of 'package:image_picker/image_picker.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter_image_compress/flutter_image_compress.dart' • kifi-app/lib/features/inventory/providers/products_provider.dart:10:8 • unnecessary_import
warning • The stack trace variable 'stack' isn't used and can be removed • kifi-app/lib/features/inventory/providers/products_provider.dart:69:17 • unused_catch_stack
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:37:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:38:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:39:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:56:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:57:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:58:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:59:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:60:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:88:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:89:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:90:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:91:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:92:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:112:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:113:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:114:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:115:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:116:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:117:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:118:9 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/projects/data/project_repository.dart:119:9 • use_null_aware_elements
warning • Dead code • kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart:32:40 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart:32:43 • dead_null_aware_expression
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart:90:27 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart:117:72 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps • kifi-app/lib/features/projects/presentation/widgets/add_project_sheet.dart:189:28 • use_build_context_synchronously
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart:171:31 • curly_braces_in_flow_control_structures
info • Parameter 'key' could be a super parameter • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:18:9 • use_super_parameters
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:67:11 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:69:11 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:71:11 • curly_braces_in_flow_control_structures
info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:141:15 • deprecated_member_use
info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:141:21 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:175:19 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:250:49 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:264:49 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart:270:49 • curly_braces_in_flow_control_structures
warning • Unused import: '../../auth/providers/auth_provider.dart' • kifi-app/lib/features/projects/providers/project_provider.dart:3:8 • unused_import
warning • Unused import: 'dart:async' • kifi-app/lib/features/projects/providers/user_provider.dart:4:8 • unused_import
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:198:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:205:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:216:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:219:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:263:7 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/domain/invoice.dart:266:7 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart:183:36 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/customers_list_screen.dart:146:54 • deprecated_member_use
warning • Unused import: '../../inventory/domain/inventory_item.dart' • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:17:8 • unused_import
warning • The '!' will have no effect because the receiver can't be null • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:205:65 • unnecessary_non_null_assertion
warning • The '!' will have no effect because the receiver can't be null • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:206:63 • unnecessary_non_null_assertion
warning • The '!' will have no effect because the receiver can't be null • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:207:66 • unnecessary_non_null_assertion
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:395:32 • use_build_context_synchronously
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:399:32 • use_build_context_synchronously
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/sales/presentation/invoice_builder_screen.dart:539:43 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:115:44 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:144:51 • deprecated_member_use
warning • Dead code • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:413:41 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:413:44 • dead_null_aware_expression
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:431:46 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:661:44 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:747:23 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:752:52 • deprecated_member_use
info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:940:13 • deprecated_member_use
info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:940:19 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:945:9 • curly_braces_in_flow_control_structures
warning • Dead code • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1152:43 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1152:46 • dead_null_aware_expression
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1164:23 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1166:23 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1168:23 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1171:23 • curly_braces_in_flow_control_structures
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1174:23 • curly_braces_in_flow_control_structures
info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1387:13 • deprecated_member_use
info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1387:19 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/sales/presentation/invoice_details_screen.dart:1392:9 • curly_braces_in_flow_control_structures
warning • Dead code • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:151:59 • dead_code
warning • The left operand can't be null, so the right operand is never executed • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:151:62 • dead_null_aware_expression
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:333:50 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:392:45 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:485:62 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/sales/presentation/invoices_list_screen.dart:488:64 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart:173:29 • use_build_context_synchronously
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart:176:48 • use_build_context_synchronously
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart:185:29 • use_build_context_synchronously
warning • Unused import: 'dart:io' • kifi-app/lib/features/sales/providers/customers_provider.dart:2:8 • unused_import
info • The import of 'package:image_picker/image_picker.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter_image_compress/flutter_image_compress.dart' • kifi-app/lib/features/sales/providers/customers_provider.dart:5:8 • unnecessary_import
info • Don't invoke 'print' in production code • kifi-app/lib/features/sales/providers/customers_provider.dart:30:7 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/sales/providers/invoices_provider.dart:22:7 • avoid_print
info • Don't invoke 'print' in production code • kifi-app/lib/features/sales/providers/invoices_provider.dart:88:7 • avoid_print
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:35:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:36:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:37:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:38:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:39:7 • use_null_aware_elements
info • Don't invoke 'print' in production code • kifi-app/lib/features/transactions/data/repository.dart:106:7 • avoid_print
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:164:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:168:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:169:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:170:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:171:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:172:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:191:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:192:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:193:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:194:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:195:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:196:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:197:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:198:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:199:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:200:7 • use_null_aware_elements
info • Use the null-aware marker '?' rather than a null check via an 'if' • kifi-app/lib/features/transactions/data/repository.dart:201:7 • use_null_aware_elements
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart:297:25 • deprecated_member_use
info • Don't use 'BuildContext's across async gaps, guarded by an unrelated 'mounted' check • kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart:524:54 • use_build_context_synchronously
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart:836:21 • deprecated_member_use
info • 'activeColor' is deprecated and shouldn't be used. Use activeThumbColor instead. This feature was deprecated after v3.31.0-2.0.pre • kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart:1048:19 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart:1053:19 • deprecated_member_use
info • 'translate' is deprecated and shouldn't be used. Use translateByVector3, translateByVector4, or translateByDouble instead • kifi-app/lib/features/transactions/presentation/widgets/attachment_gallery_screen.dart:110:11 • deprecated_member_use
info • 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead • kifi-app/lib/features/transactions/presentation/widgets/attachment_gallery_screen.dart:111:11 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/transactions/presentation/widgets/transaction_filter_sheet.dart:90:17 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use initialValue instead. This will set the initial value for the form field. This feature was deprecated after v3.33.0-1.0.pre • kifi-app/lib/features/transactions/presentation/widgets/transaction_filter_sheet.dart:115:17 • deprecated_member_use
warning • Unused import: '../data/repository.dart' • kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart:3:8 • unused_import
info • Don't invoke 'print' in production code • kifi-app/lib/features/transactions/providers/paginated_transaction_provider.dart:103:7 • avoid_print
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart:210:58 • deprecated_member_use
warning • Unused import: 'dart:io' • kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart:5:8 • unused_import
warning • Unused import: '../domain/vendor.dart' • kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart:16:8 • unused_import
info • Don't use 'BuildContext's across async gaps • kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart:239:25 • use_build_context_synchronously
warning • Unused import: 'dart:typed_data' • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:2:8 • unused_import
info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:542:13 • deprecated_member_use
info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:542:19 • deprecated_member_use
info • Statements in an if should be enclosed in a block • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:547:9 • curly_braces_in_flow_control_structures
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:663:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart:692:49 • deprecated_member_use
warning • The value of the local variable 'darkTheme' isn't used • kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart:59:11 • unused_local_variable
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart:146:50 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/purchase_orders_list_screen.dart:201:45 • deprecated_member_use
warning • The value of the local variable 'darkTheme' isn't used • kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart:20:11 • unused_local_variable
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart:85:48 • deprecated_member_use
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss • kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart:105:66 • deprecated_member_use
warning • Unused import: 'package:dio/dio.dart' • kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart:1:8 • unused_import
warning • Unused import: 'package:dio/dio.dart' • kifi-app/lib/features/vendor/providers/vendors_provider.dart:1:8 • unused_import
warning • Unused import: 'core/network/dio_client.dart' • kifi-app/lib/main.dart:11:8 • unused_import
warning • The stack trace variable 'stacktrace' isn't used and can be removed • kifi-app/lib/main.dart:94:17 • unused_catch_stack
info • Don't invoke 'print' in production code • kifi-app/lib/test_dio.dart:5:3 • avoid_print
error • Target of URI doesn't exist: 'package:flutter/material.dart' • scratch/UpdateTaskDetailsSheet.dart:3:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:flutter_riverpod/flutter_riverpod.dart' • scratch/UpdateTaskDetailsSheet.dart:4:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:lucide_icons/lucide_icons.dart' • scratch/UpdateTaskDetailsSheet.dart:5:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:image_picker/image_picker.dart' • scratch/UpdateTaskDetailsSheet.dart:6:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:file_picker/file_picker.dart' • scratch/UpdateTaskDetailsSheet.dart:7:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:path_provider/path_provider.dart' • scratch/UpdateTaskDetailsSheet.dart:8:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:dio/dio.dart' • scratch/UpdateTaskDetailsSheet.dart:9:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:share_plus/share_plus.dart' • scratch/UpdateTaskDetailsSheet.dart:10:8 • uri_does_not_exist
error • Target of URI doesn't exist: 'package:flutter_secure_storage/flutter_secure_storage.dart' • scratch/UpdateTaskDetailsSheet.dart:11:8 • uri_does_not_exist
error • Target of URI doesn't exist: '../../domain/project_task.dart' • scratch/UpdateTaskDetailsSheet.dart:12:8 • uri_does_not_exist
error • Target of URI doesn't exist: '../../providers/project_provider.dart' • scratch/UpdateTaskDetailsSheet.dart:13:8 • uri_does_not_exist
error • Target of URI doesn't exist: '../../../transactions/presentation/widgets/attachment_gallery_screen.dart' • scratch/UpdateTaskDetailsSheet.dart:14:8 • uri_does_not_exist
error • Classes can only extend other classes • scratch/UpdateTaskDetailsSheet.dart:16:32 • extends_non_class
error • Undefined class 'ProjectTask' • scratch/UpdateTaskDetailsSheet.dart:17:9 • undefined_class
error • Undefined class 'Key' • scratch/UpdateTaskDetailsSheet.dart:18:27 • undefined_class
error • The named parameter 'key' isn't defined • scratch/UpdateTaskDetailsSheet.dart:18:66 • undefined_named_parameter
error • Undefined class 'ConsumerState' • scratch/UpdateTaskDetailsSheet.dart:21:3 • undefined_class
warning • The method doesn't override an inherited method • scratch/UpdateTaskDetailsSheet.dart:21:35 • override_on_non_overriding_member
error • Classes can only extend other classes • scratch/UpdateTaskDetailsSheet.dart:24:38 • extends_non_class
error • Undefined class 'TextEditingController' • scratch/UpdateTaskDetailsSheet.dart:25:9 • undefined_class
error • The method 'TextEditingController' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:25:52 • undefined_method
error • Undefined class 'ImagePicker' • scratch/UpdateTaskDetailsSheet.dart:26:9 • undefined_class
error • The method 'ImagePicker' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:26:31 • undefined_method
warning • The method doesn't override an inherited method • scratch/UpdateTaskDetailsSheet.dart:33:8 • override_on_non_overriding_member
error • The method 'initState' isn't defined in a superclass of '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:34:11 • undefined_super_member
error • Const variables must be initialized with a constant value • scratch/UpdateTaskDetailsSheet.dart:39:21 • const_initialized_with_non_constant_value
error • The method 'FlutterSecureStorage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:39:21 • undefined_method
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:41:9 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:41:18 • undefined_method
error • Undefined class 'XFile' • scratch/UpdateTaskDetailsSheet.dart:45:11 • undefined_class
error • Undefined name 'ImageSource' • scratch/UpdateTaskDetailsSheet.dart:45:58 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:48:7 • undefined_method
error • Undefined class 'FilePickerResult' • scratch/UpdateTaskDetailsSheet.dart:59:5 • undefined_class
error • Undefined name 'FilePicker' • scratch/UpdateTaskDetailsSheet.dart:59:38 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:69:7 • undefined_method
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:82:5 • undefined_method
error • Undefined name 'ref' • scratch/UpdateTaskDetailsSheet.dart:84:20 • undefined_identifier
error • Undefined name 'projectRepositoryProvider' • scratch/UpdateTaskDetailsSheet.dart:84:29 • undefined_identifier
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:86:17 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:91:7 • undefined_method
error • Undefined name 'ref' • scratch/UpdateTaskDetailsSheet.dart:94:7 • undefined_identifier
error • The method 'projectTaskCommentsProvider' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:94:22 • undefined_method
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:94:50 • undefined_identifier
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:96:11 • undefined_identifier
error • Undefined name 'ScaffoldMessenger' • scratch/UpdateTaskDetailsSheet.dart:97:9 • undefined_identifier
error • Undefined name 'context' • scratch/UpdateTaskDetailsSheet.dart:97:30 • undefined_identifier
error • The method 'SnackBar' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:97:52 • undefined_method
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:97:70 • undefined_method
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:100:11 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:100:20 • undefined_method
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:111:5 • undefined_method
error • The method 'getTemporaryDirectory' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:114:25 • undefined_method
error • The method 'Dio' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:116:19 • undefined_method
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:123:11 • undefined_identifier
error • Undefined name 'ScaffoldMessenger' • scratch/UpdateTaskDetailsSheet.dart:124:9 • undefined_identifier
error • Undefined name 'context' • scratch/UpdateTaskDetailsSheet.dart:124:30 • undefined_identifier
error • The method 'SnackBar' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:124:52 • undefined_method
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:124:70 • undefined_method
error • Undefined name 'Share' • scratch/UpdateTaskDetailsSheet.dart:125:15 • undefined_identifier
error • The method 'XFile' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:125:34 • undefined_method
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:128:11 • undefined_identifier
error • Undefined name 'ScaffoldMessenger' • scratch/UpdateTaskDetailsSheet.dart:129:9 • undefined_identifier
error • Undefined name 'context' • scratch/UpdateTaskDetailsSheet.dart:129:30 • undefined_identifier
error • The method 'SnackBar' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:129:52 • undefined_method
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:129:70 • undefined_method
error • Undefined name 'mounted' • scratch/UpdateTaskDetailsSheet.dart:132:11 • undefined_identifier
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:133:9 • undefined_method
error • Undefined class 'Widget' • scratch/UpdateTaskDetailsSheet.dart:139:3 • undefined_class
warning • The method doesn't override an inherited method • scratch/UpdateTaskDetailsSheet.dart:139:10 • override_on_non_overriding_member
error • Undefined class 'BuildContext' • scratch/UpdateTaskDetailsSheet.dart:139:16 • undefined_class
error • Undefined name 'ref' • scratch/UpdateTaskDetailsSheet.dart:140:27 • undefined_identifier
error • The method 'projectTaskCommentsProvider' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:140:37 • undefined_method
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:140:65 • undefined_identifier
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:142:12 • undefined_method
error • Undefined name 'MediaQuery' • scratch/UpdateTaskDetailsSheet.dart:143:15 • undefined_identifier
error • The name 'BoxDecoration' isn't a class • scratch/UpdateTaskDetailsSheet.dart:144:25 • creation_with_non_type
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:145:16 • undefined_identifier
error • Undefined name 'BorderRadius' • scratch/UpdateTaskDetailsSheet.dart:146:23 • undefined_identifier
error • Undefined name 'Radius' • scratch/UpdateTaskDetailsSheet.dart:146:50 • undefined_identifier
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:148:14 • undefined_method
error • The method 'Expanded' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:151:11 • undefined_method
error • The name 'Center' isn't a class • scratch/UpdateTaskDetailsSheet.dart:154:52 • creation_with_non_type
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:154:66 • undefined_method
error • Undefined name 'ListView' • scratch/UpdateTaskDetailsSheet.dart:155:24 • undefined_identifier
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:156:34 • undefined_identifier
error • The method 'Card' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:160:28 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:161:37 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:162:30 • undefined_identifier
error • The method 'Padding' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:164:30 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:165:40 • undefined_identifier
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:166:32 • undefined_method
error • Undefined name 'CrossAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:167:47 • undefined_identifier
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:169:29 • undefined_method
error • The name 'TextStyle' isn't a class • scratch/UpdateTaskDetailsSheet.dart:169:58 • creation_with_non_type
error • The name 'SizedBox' isn't a class • scratch/UpdateTaskDetailsSheet.dart:171:37 • creation_with_non_type
error • The method 'Wrap' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:172:31 • undefined_method
error • Undefined class 'ImageProvider' • scratch/UpdateTaskDetailsSheet.dart:191:37 • undefined_class
error • The method 'MemoryImage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:194:51 • undefined_method
error • The method 'NetworkImage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:198:45 • undefined_method
error • The name 'AssetImage' isn't a class • scratch/UpdateTaskDetailsSheet.dart:199:51 • creation_with_non_type
error • The name 'ImageProvider' isn't a type, so it can't be used in an 'as' expression • scratch/UpdateTaskDetailsSheet.dart:199:98 • cast_to_non_type
error • The method 'GestureDetector' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:202:44 • undefined_method
error • The method 'MemoryImage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:214:76 • undefined_method
error • The name 'ImageProvider' isn't a type, so it can't be used in an 'as' expression • scratch/UpdateTaskDetailsSheet.dart:214:108 • cast_to_non_type
error • The method 'MemoryImage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:216:86 • undefined_method
error • The name 'ImageProvider' isn't a type, so it can't be used in an 'as' expression • scratch/UpdateTaskDetailsSheet.dart:216:137 • cast_to_non_type
error • The method 'NetworkImage' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:218:52 • undefined_method
error • The name 'ImageProvider' isn't a type, so it can't be used in an 'as' expression • scratch/UpdateTaskDetailsSheet.dart:218:122 • cast_to_non_type
error • Undefined name 'Navigator' • scratch/UpdateTaskDetailsSheet.dart:235:41 • undefined_identifier
error • The method 'MaterialPageRoute' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:237:43 • undefined_method
error • The method 'AttachmentGalleryScreen' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:238:67 • undefined_method
error • The method 'ClipRRect' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:245:46 • undefined_method
error • Undefined name 'BorderRadius' • scratch/UpdateTaskDetailsSheet.dart:246:55 • undefined_identifier
error • The method 'Image' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:247:48 • undefined_method
error • Undefined name 'BoxFit' • scratch/UpdateTaskDetailsSheet.dart:251:48 • undefined_identifier
error • The method 'GestureDetector' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:261:44 • undefined_method
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:267:46 • undefined_method
error • The method 'BoxDecoration' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:270:53 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:271:50 • undefined_identifier
error • Undefined name 'BorderRadius' • scratch/UpdateTaskDetailsSheet.dart:272:57 • undefined_identifier
error • Undefined name 'Border' • scratch/UpdateTaskDetailsSheet.dart:273:51 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:273:69 • undefined_identifier
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:275:48 • undefined_method
error • Undefined name 'MainAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:276:62 • undefined_identifier
error • The name 'SizedBox' isn't a class • scratch/UpdateTaskDetailsSheet.dart:279:57 • creation_with_non_type
error • The method 'CircularProgressIndicator' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:279:96 • undefined_method
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:280:57 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:280:62 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:280:91 • undefined_identifier
error • The name 'SizedBox' isn't a class • scratch/UpdateTaskDetailsSheet.dart:281:51 • creation_with_non_type
error • The method 'Padding' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:282:45 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:283:62 • undefined_identifier
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:284:54 • undefined_method
error • The name 'TextStyle' isn't a class • scratch/UpdateTaskDetailsSheet.dart:286:62 • creation_with_non_type
error • Undefined name 'TextOverflow' • scratch/UpdateTaskDetailsSheet.dart:288:59 • undefined_identifier
error • The name 'Center' isn't a class • scratch/UpdateTaskDetailsSheet.dart:306:36 • creation_with_non_type
error • The method 'CircularProgressIndicator' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:306:50 • undefined_method
error • The method 'Center' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:307:38 • undefined_method
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:307:52 • undefined_method
error • Undefined class 'Widget' • scratch/UpdateTaskDetailsSheet.dart:316:3 • undefined_class
error • Undefined class 'BuildContext' • scratch/UpdateTaskDetailsSheet.dart:316:23 • undefined_class
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:317:12 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:318:22 • undefined_identifier
error • The method 'BoxDecoration' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:319:19 • undefined_method
error • The method 'Border' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:319:41 • undefined_method
error • The method 'BorderSide' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:319:56 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:319:74 • undefined_identifier
error • The method 'Row' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:320:14 • undefined_method
error • Undefined name 'MainAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:321:28 • undefined_identifier
error • The method 'Expanded' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:323:11 • undefined_method
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:324:20 • undefined_method
error • Undefined name 'CrossAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:325:35 • undefined_identifier
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:327:17 • undefined_method
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:327:22 • undefined_identifier
error • The name 'TextStyle' isn't a class • scratch/UpdateTaskDetailsSheet.dart:327:54 • creation_with_non_type
error • Undefined name 'FontWeight' • scratch/UpdateTaskDetailsSheet.dart:327:90 • undefined_identifier
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:328:21 • undefined_identifier
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:328:56 • undefined_identifier
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:329:19 • undefined_method
error • Undefined name 'widget' • scratch/UpdateTaskDetailsSheet.dart:329:24 • undefined_identifier
error • The name 'TextStyle' isn't a class • scratch/UpdateTaskDetailsSheet.dart:329:63 • creation_with_non_type
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:329:80 • undefined_identifier
error • The method 'IconButton' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:333:11 • undefined_method
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:333:34 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:333:39 • undefined_identifier
error • Undefined name 'Navigator' • scratch/UpdateTaskDetailsSheet.dart:333:72 • undefined_identifier
error • Undefined class 'Widget' • scratch/UpdateTaskDetailsSheet.dart:339:3 • undefined_class
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:340:12 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:341:16 • undefined_identifier
error • Undefined name 'MediaQuery' • scratch/UpdateTaskDetailsSheet.dart:342:17 • undefined_identifier
error • Undefined name 'context' • scratch/UpdateTaskDetailsSheet.dart:342:31 • undefined_identifier
error • The method 'BoxDecoration' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:347:19 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:348:16 • undefined_identifier
error • The method 'Border' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:349:17 • undefined_method
error • The method 'BorderSide' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:349:29 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:349:47 • undefined_identifier
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:351:14 • undefined_method
error • Undefined name 'CrossAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:352:29 • undefined_identifier
error • The method 'Padding' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:355:13 • undefined_method
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:356:30 • undefined_identifier
error • The method 'Wrap' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:357:22 • undefined_method
error • The method 'Stack' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:363:26 • undefined_method
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:365:23 • undefined_method
error • The method 'BoxDecoration' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:368:37 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:369:34 • undefined_identifier
error • Undefined name 'BorderRadius' • scratch/UpdateTaskDetailsSheet.dart:370:41 • undefined_identifier
error • The method 'ClipRRect' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:373:29 • undefined_method
error • Undefined name 'BorderRadius' • scratch/UpdateTaskDetailsSheet.dart:374:45 • undefined_identifier
error • Undefined name 'Image' • scratch/UpdateTaskDetailsSheet.dart:375:38 • undefined_identifier
error • Undefined name 'BoxFit' • scratch/UpdateTaskDetailsSheet.dart:377:38 • undefined_identifier
error • The method 'Column' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:380:29 • undefined_method
error • Undefined name 'MainAxisAlignment' • scratch/UpdateTaskDetailsSheet.dart:381:50 • undefined_identifier
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:383:39 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:383:44 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:383:83 • undefined_identifier
error • The method 'Text' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:384:33 • undefined_method
error • The name 'TextStyle' isn't a class • scratch/UpdateTaskDetailsSheet.dart:384:74 • creation_with_non_type
error • Undefined name 'TextOverflow' • scratch/UpdateTaskDetailsSheet.dart:384:121 • undefined_identifier
error • The method 'Positioned' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:388:23 • undefined_method
error • The method 'GestureDetector' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:391:32 • undefined_method
error • The method 'setState' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:393:29 • undefined_method
error • The method 'Container' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:397:34 • undefined_method
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:398:36 • undefined_identifier
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:399:42 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:399:47 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:399:79 • undefined_identifier
error • The method 'Row' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:408:11 • undefined_method
error • The method 'IconButton' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:410:15 • undefined_method
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:411:29 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:411:34 • undefined_identifier
error • The method 'IconButton' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:414:15 • undefined_method
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:415:29 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:415:34 • undefined_identifier
error • The method 'Expanded' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:418:15 • undefined_method
error • The method 'TextField' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:419:24 • undefined_method
error • The name 'InputDecoration' isn't a class • scratch/UpdateTaskDetailsSheet.dart:421:37 • creation_with_non_type
error • Undefined name 'InputBorder' • scratch/UpdateTaskDetailsSheet.dart:423:29 • undefined_identifier
error • The name 'Padding' isn't a class • scratch/UpdateTaskDetailsSheet.dart:428:27 • creation_with_non_type
error • Undefined name 'EdgeInsets' • scratch/UpdateTaskDetailsSheet.dart:428:44 • undefined_identifier
error • The method 'SizedBox' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:428:73 • undefined_method
error • The method 'CircularProgressIndicator' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:428:112 • undefined_method
error • The method 'IconButton' isn't defined for the type '_TaskDetailsSheetState' • scratch/UpdateTaskDetailsSheet.dart:429:21 • undefined_method
error • The name 'Icon' isn't a class • scratch/UpdateTaskDetailsSheet.dart:430:35 • creation_with_non_type
error • Undefined name 'LucideIcons' • scratch/UpdateTaskDetailsSheet.dart:430:40 • undefined_identifier
error • Undefined name 'Colors' • scratch/UpdateTaskDetailsSheet.dart:430:65 • undefined_identifier

17
kifi-api/TestJwt.java Normal file
View File

@@ -0,0 +1,17 @@
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.util.HashMap;
public class TestJwt {
public static void main(String[] args) {
try {
byte[] key = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".getBytes();
HashMap<String, Object> claims = new HashMap<>();
claims.put("userId", 1L);
String token = Jwts.builder().claims(claims).signWith(Keys.hmacShaKeyFor(key)).compact();
Long parsed = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(key)).build().parseSignedClaims(token).getPayload().get("userId", Long.class);
System.out.println("Parsed: " + parsed);
} catch(Exception e) {
e.printStackTrace();
}
}
}

View File

@@ -6,7 +6,9 @@ USERNAME="technobee_admin"
PASSWORD='M@tr!x#149@dm!N' PASSWORD='M@tr!x#149@dm!N'
IMAGE_NAME="kifi-api" IMAGE_NAME="kifi-api"
TAG="latest" TAG="latest"
TAG_ONE="kifi-v2"
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG" FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
FULL_IMAGE_NAME_ONE="$REGISTRY/$IMAGE_NAME:$TAG_ONE"
# Stop on any error # Stop on any error
set -e set -e
@@ -14,7 +16,7 @@ set -e
echo "Logging into Docker registry: $REGISTRY..." echo "Logging into Docker registry: $REGISTRY..."
echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin
echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME..." echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME and $FULL_IMAGE_NAME_ONE..."
docker buildx build --platform linux/amd64 -t "$FULL_IMAGE_NAME" --push . docker buildx build --no-cache --platform linux/amd64 -t "$FULL_IMAGE_NAME" -t "$FULL_IMAGE_NAME_ONE" --push .
echo "Done! Image built and pushed successfully." echo "Done! Image built and pushed successfully."

View File

@@ -45,10 +45,11 @@ public class SecurityConfig {
.securityContextRepository(securityContextRepository) .securityContextRepository(securityContextRepository)
.authorizeExchange(exchange -> exchange .authorizeExchange(exchange -> exchange
.pathMatchers(HttpMethod.OPTIONS).permitAll() .pathMatchers(HttpMethod.OPTIONS).permitAll()
.pathMatchers("/api/kifi-v2/auth/**").permitAll() .pathMatchers("/api/kifi-v2/auth/**", "/api/kifi-v2/categories/**", "/api/kifi-v2/purchase-orders/test/**").permitAll()
.pathMatchers("/api/kifi-v2/health/**").permitAll() .pathMatchers("/api/kifi-v2/health/**", "/api/kifi-v2/upload/view**").permitAll()
.pathMatchers("/api/kifi-v2/wallets/test/**").permitAll() .pathMatchers("/api/kifi-v2/wallets/test/**").permitAll()
.pathMatchers("/public/legal/**").permitAll() .pathMatchers("/public/legal/**").permitAll()
.pathMatchers("/test/**").permitAll()
.anyExchange().authenticated() .anyExchange().authenticated()
) )
.build(); .build();

View File

@@ -0,0 +1,62 @@
package com.kifi.api.controller;
import com.kifi.api.service.MinioServiceClient;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/kifi-v2/upload")
@RequiredArgsConstructor
public class UploadController {
private final MinioServiceClient minioServiceClient;
@PostMapping
public Mono<Map<String, String>> upload(@RequestPart("file") Mono<FilePart> filePartMono) {
return filePartMono.flatMap(filePart ->
DataBufferUtils.join(filePart.content())
.flatMap(dataBuffer -> {
byte[] bytes = new byte[dataBuffer.readableByteCount()];
dataBuffer.read(bytes);
DataBufferUtils.release(dataBuffer);
String base64 = Base64.getEncoder().encodeToString(bytes);
String fileName = UUID.randomUUID().toString() + "-" + filePart.filename();
return minioServiceClient.uploadFile(
"general",
filePart.headers().getContentType() != null ? filePart.headers().getContentType().toString() : "application/octet-stream",
fileName,
base64
).map(response -> Map.of("url", response.getFilePath()));
})
);
}
@GetMapping("/view")
public Mono<ResponseEntity<byte[]>> viewFile(@RequestParam("path") String path) {
String contentType = "image/jpeg";
if (path.endsWith(".png")) contentType = "image/png";
else if (path.endsWith(".pdf")) contentType = "application/pdf";
else if (path.endsWith(".webp")) contentType = "image/webp";
final String finalContentType = contentType;
return minioServiceClient.downloadFile(contentType, path)
.map(response -> {
byte[] decodedBytes = Base64.getDecoder().decode(response.getBase64Content());
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_TYPE, finalContentType)
.header(HttpHeaders.CACHE_CONTROL, "max-age=86400")
.body(decodedBytes);
});
}
}

View File

@@ -24,6 +24,12 @@ public class InventoryItemController {
return inventoryItemService.getItemsByUserId(userId); return inventoryItemService.getItemsByUserId(userId);
} }
@GetMapping("/product/{productId}")
public Flux<InventoryItem> getItemsByProductId(@PathVariable Long productId, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString());
return inventoryItemService.getItemsByProductId(userId, productId);
}
@GetMapping("/total-value") @GetMapping("/total-value")
public Mono<ResponseEntity<BigDecimal>> getTotalInventoryValue(Authentication authentication) { public Mono<ResponseEntity<BigDecimal>> getTotalInventoryValue(Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString()); Long userId = Long.valueOf(authentication.getDetails().toString());

View File

@@ -46,12 +46,12 @@ public class ProductCategoryController {
existing.setName(category.getName()); existing.setName(category.getName());
existing.setParentCategoryId(category.getParentCategoryId()); existing.setParentCategoryId(category.getParentCategoryId());
existing.setHasChild(category.getHasChild()); existing.setHasChild(category.getHasChild());
existing.setCommodityCode(category.getCommodityCode());
existing.setDefaultHsn(category.getDefaultHsn()); existing.setDefaultHsn(category.getDefaultHsn());
existing.setDefaultGst(category.getDefaultGst()); existing.setDefaultGst(category.getDefaultGst());
existing.setHuidRequired(category.getHuidRequired()); existing.setHuidRequired(category.getHuidRequired());
existing.setDefaultMakingCharge(category.getDefaultMakingCharge()); existing.setDefaultMakingCharge(category.getDefaultMakingCharge());
existing.setMakingChargeType(category.getMakingChargeType()); existing.setMakingChargeType(category.getMakingChargeType());
existing.setCalculationMethod(category.getCalculationMethod());
existing.setBaseUnit(category.getBaseUnit()); existing.setBaseUnit(category.getBaseUnit());
existing.setIsActive(category.getIsActive()); existing.setIsActive(category.getIsActive());
existing.setSortOrder(category.getSortOrder()); existing.setSortOrder(category.getSortOrder());

View File

@@ -24,6 +24,17 @@ public class PurchaseOrderController {
return Mono.just(ResponseEntity.ok(purchaseOrderService.getPurchaseOrders(userId))); return Mono.just(ResponseEntity.ok(purchaseOrderService.getPurchaseOrders(userId)));
} }
@GetMapping("/test/{id}")
public Mono<ResponseEntity<PurchaseOrder>> testGet(@PathVariable Long id) {
return purchaseOrderService.getPurchaseOrder(1L, id)
.flatMap(po -> purchaseOrderService.getPurchaseOrderItems(id)
.collectList()
.map(items -> {
po.setItems(items);
return ResponseEntity.ok(po);
}));
}
@GetMapping("/{id}") @GetMapping("/{id}")
public Mono<ResponseEntity<PurchaseOrder>> getPurchaseOrder(@PathVariable Long id, Authentication authentication) { public Mono<ResponseEntity<PurchaseOrder>> getPurchaseOrder(@PathVariable Long id, Authentication authentication) {
Long userId = Long.valueOf(authentication.getDetails().toString()); Long userId = Long.valueOf(authentication.getDetails().toString());
@@ -31,6 +42,7 @@ public class PurchaseOrderController {
.flatMap(po -> purchaseOrderService.getPurchaseOrderItems(id) .flatMap(po -> purchaseOrderService.getPurchaseOrderItems(id)
.collectList() .collectList()
.map(items -> { .map(items -> {
System.out.println("Fetched PO ID " + id + ", Items count: " + items.size());
po.setItems(items); po.setItems(items);
return ResponseEntity.ok(po); return ResponseEntity.ok(po);
})) }))
@@ -40,6 +52,8 @@ public class PurchaseOrderController {
@PostMapping @PostMapping
public Mono<ResponseEntity<PurchaseOrder>> createPurchaseOrder(Authentication authentication, @RequestBody PurchaseOrder po) { public Mono<ResponseEntity<PurchaseOrder>> createPurchaseOrder(Authentication authentication, @RequestBody PurchaseOrder po) {
Long userId = Long.valueOf(authentication.getDetails().toString()); Long userId = Long.valueOf(authentication.getDetails().toString());
System.out.println("---- CREATE PO RECEIVED ITEMS ----");
System.out.println(po.getItems());
return purchaseOrderService.createPurchaseOrder(userId, po, po.getItems()) return purchaseOrderService.createPurchaseOrder(userId, po, po.getItems())
.map(ResponseEntity::ok); .map(ResponseEntity::ok);
} }

View File

@@ -23,4 +23,8 @@ public class InventoryMovementItem {
private BigDecimal quantity; private BigDecimal quantity;
private BigDecimal unitPrice; private BigDecimal unitPrice;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private String huid;
private BigDecimal weight;
private String photoUrl;
private BigDecimal purchaseAmount;
} }

View File

@@ -28,11 +28,9 @@ public class Product {
private String barcode; private String barcode;
private String hsnCode; private String hsnCode;
private String description; private String description;
private BigDecimal purchasePrice;
private BigDecimal sellingPrice; private BigDecimal sellingPrice;
private BigDecimal gstRate; private BigDecimal gstRate;
private String dimensions; private String dimensions;
private BigDecimal weight;
private String color; private String color;
private String size; private String size;
private String priceCalcRule; private String priceCalcRule;
@@ -40,9 +38,6 @@ public class Product {
private Double makingCharges; private Double makingCharges;
private String makingChargesType; private String makingChargesType;
private Double wastagePercentage; private Double wastagePercentage;
private BigDecimal minStock;
private BigDecimal reorderLevel;
private Boolean trackInventory;
private Boolean isActive; private Boolean isActive;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;

View File

@@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id; import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table; import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -22,6 +23,7 @@ public class ProductCategory {
private Long parentCategoryId; private Long parentCategoryId;
@Builder.Default @Builder.Default
private Boolean hasChild = false; private Boolean hasChild = false;
private String commodityCode;
private String defaultHsn; private String defaultHsn;
private java.math.BigDecimal defaultGst; private java.math.BigDecimal defaultGst;
@Builder.Default @Builder.Default
@@ -29,7 +31,6 @@ public class ProductCategory {
private Double defaultMakingCharge; private Double defaultMakingCharge;
private String makingChargeType; private String makingChargeType;
private String calculationMethod;
private String baseUnit; private String baseUnit;
@Builder.Default @Builder.Default

View File

@@ -29,6 +29,9 @@ public class PurchaseOrder {
private LocalDate dueDate; private LocalDate dueDate;
private BigDecimal subtotal; private BigDecimal subtotal;
private BigDecimal taxTotal; private BigDecimal taxTotal;
private BigDecimal cgstTotal;
private BigDecimal sgstTotal;
private BigDecimal igstTotal;
private BigDecimal discountTotal; private BigDecimal discountTotal;
private BigDecimal totalAmount; private BigDecimal totalAmount;
private String status; // DRAFT, RECEIVED, CANCELLED private String status; // DRAFT, RECEIVED, CANCELLED
@@ -41,5 +44,16 @@ public class PurchaseOrder {
private String vendorInvoiceUrl; private String vendorInvoiceUrl;
@Transient @Transient
@com.fasterxml.jackson.annotation.JsonProperty(value = "items", access = com.fasterxml.jackson.annotation.JsonProperty.Access.READ_WRITE)
private List<PurchaseOrderItem> items; private List<PurchaseOrderItem> items;
@com.fasterxml.jackson.annotation.JsonProperty("items")
public List<PurchaseOrderItem> getItems() {
return items;
}
@com.fasterxml.jackson.annotation.JsonProperty("items")
public void setItems(List<PurchaseOrderItem> items) {
this.items = items;
}
} }

View File

@@ -22,10 +22,16 @@ public class PurchaseOrderItem {
private BigDecimal quantity; private BigDecimal quantity;
private BigDecimal unitPrice; private BigDecimal unitPrice;
private BigDecimal taxRate; private BigDecimal taxRate;
private BigDecimal cgstRate;
private BigDecimal sgstRate;
private BigDecimal igstRate;
private BigDecimal discount; private BigDecimal discount;
private BigDecimal total; private BigDecimal total;
private String description; private String description;
private BigDecimal makingCharge; private BigDecimal makingCharge;
private BigDecimal otherCharges; private BigDecimal otherCharges;
private String sku; private String sku;
private String huid;
private BigDecimal weight;
private String photoUrl;
} }

View File

@@ -7,6 +7,7 @@ import reactor.core.publisher.Mono;
public interface InventoryItemRepository extends ReactiveCrudRepository<InventoryItem, Long> { public interface InventoryItemRepository extends ReactiveCrudRepository<InventoryItem, Long> {
Flux<InventoryItem> findByUserId(Long userId); Flux<InventoryItem> findByUserId(Long userId);
Flux<InventoryItem> findByUserIdAndProductId(Long userId, Long productId);
Flux<InventoryItem> findByProductId(Long productId); Flux<InventoryItem> findByProductId(Long productId);
Mono<InventoryItem> findByTagNumber(String tagNumber); Mono<InventoryItem> findByTagNumber(String tagNumber);
Mono<InventoryItem> findByHuid(String huid); Mono<InventoryItem> findByHuid(String huid);

View File

@@ -25,7 +25,8 @@ public class AuthenticationManager implements ReactiveAuthenticationManager {
if (jwtUtil.validateToken(authToken)) { if (jwtUtil.validateToken(authToken)) {
Claims claims = jwtUtil.getAllClaimsFromToken(authToken); Claims claims = jwtUtil.getAllClaimsFromToken(authToken);
String email = claims.getSubject(); String email = claims.getSubject();
Long userId = claims.get("userId", Long.class); Number userIdNumber = claims.get("userId", Number.class);
Long userId = userIdNumber != null ? userIdNumber.longValue() : null;
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
email, email,

View File

@@ -4,6 +4,7 @@ import com.kifi.api.entity.business.BusinessProfile;
import com.kifi.api.entity.business.BusinessFeature; import com.kifi.api.entity.business.BusinessFeature;
import com.kifi.api.repository.business.BusinessProfileRepository; import com.kifi.api.repository.business.BusinessProfileRepository;
import com.kifi.api.repository.business.BusinessFeatureRepository; import com.kifi.api.repository.business.BusinessFeatureRepository;
import com.kifi.api.util.CryptoUtils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -15,9 +16,15 @@ import java.time.LocalDateTime;
public class BusinessService { public class BusinessService {
private final BusinessProfileRepository businessProfileRepository; private final BusinessProfileRepository businessProfileRepository;
private final BusinessFeatureRepository businessFeatureRepository; private final BusinessFeatureRepository businessFeatureRepository;
private final CryptoUtils cryptoUtils;
public Mono<BusinessProfile> getProfileByUserId(Long userId) { public Mono<BusinessProfile> getProfileByUserId(Long userId) {
return businessProfileRepository.findByUserId(userId); return businessProfileRepository.findByUserId(userId)
.map(profile -> {
profile.setPanNumber(cryptoUtils.decrypt(profile.getPanNumber()));
profile.setGstin(cryptoUtils.decrypt(profile.getGstin()));
return profile;
});
} }
public Mono<BusinessFeature> getFeaturesByUserId(Long userId) { public Mono<BusinessFeature> getFeaturesByUserId(Long userId) {
@@ -76,6 +83,8 @@ public class BusinessService {
profile.setUserId(userId); profile.setUserId(userId);
profile.setCreatedAt(LocalDateTime.now()); profile.setCreatedAt(LocalDateTime.now());
profile.setUpdatedAt(LocalDateTime.now()); profile.setUpdatedAt(LocalDateTime.now());
profile.setPanNumber(profile.getPanNumber());
profile.setGstin(profile.getGstin());
return businessProfileRepository.save(profile); return businessProfileRepository.save(profile);
})); }));
} }

View File

@@ -18,6 +18,10 @@ public class InventoryItemService {
return inventoryItemRepository.findByUserId(userId); return inventoryItemRepository.findByUserId(userId);
} }
public Flux<InventoryItem> getItemsByProductId(Long userId, Long productId) {
return inventoryItemRepository.findByUserIdAndProductId(userId, productId);
}
public Mono<InventoryItem> createItem(Long userId, InventoryItem item) { public Mono<InventoryItem> createItem(Long userId, InventoryItem item) {
item.setUserId(userId); item.setUserId(userId);
item.setCreatedAt(LocalDateTime.now()); item.setCreatedAt(LocalDateTime.now());

View File

@@ -67,7 +67,6 @@ public class ProductService {
product.setCreatedAt(LocalDateTime.now()); product.setCreatedAt(LocalDateTime.now());
product.setUpdatedAt(LocalDateTime.now()); product.setUpdatedAt(LocalDateTime.now());
if (product.getIsActive() == null) product.setIsActive(true); if (product.getIsActive() == null) product.setIsActive(true);
if (product.getTrackInventory() == null) product.setTrackInventory(true);
if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL"); if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL");
if (product.getPurityFactor() == null) product.setPurityFactor(1.0); if (product.getPurityFactor() == null) product.setPurityFactor(1.0);
return productRepository.save(product); return productRepository.save(product);
@@ -81,10 +80,9 @@ public class ProductService {
existingProduct.setSku(updatedProduct.getSku()); existingProduct.setSku(updatedProduct.getSku());
existingProduct.setCategoryId(updatedProduct.getCategoryId()); existingProduct.setCategoryId(updatedProduct.getCategoryId());
existingProduct.setUomId(updatedProduct.getUomId()); existingProduct.setUomId(updatedProduct.getUomId());
existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice());
existingProduct.setSellingPrice(updatedProduct.getSellingPrice()); existingProduct.setSellingPrice(updatedProduct.getSellingPrice());
existingProduct.setGstRate(updatedProduct.getGstRate()); existingProduct.setGstRate(updatedProduct.getGstRate());
existingProduct.setWeight(updatedProduct.getWeight()); existingProduct.setHsnCode(updatedProduct.getHsnCode());
existingProduct.setColor(updatedProduct.getColor()); existingProduct.setColor(updatedProduct.getColor());
existingProduct.setSize(updatedProduct.getSize()); existingProduct.setSize(updatedProduct.getSize());
existingProduct.setDimensions(updatedProduct.getDimensions()); existingProduct.setDimensions(updatedProduct.getDimensions());
@@ -93,9 +91,6 @@ public class ProductService {
existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingCharges(updatedProduct.getMakingCharges());
existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType());
existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage());
existingProduct.setMinStock(updatedProduct.getMinStock());
existingProduct.setReorderLevel(updatedProduct.getReorderLevel());
existingProduct.setTrackInventory(updatedProduct.getTrackInventory());
existingProduct.setIsActive(updatedProduct.getIsActive()); existingProduct.setIsActive(updatedProduct.getIsActive());
existingProduct.setUpdatedAt(LocalDateTime.now()); existingProduct.setUpdatedAt(LocalDateTime.now());
return productRepository.save(existingProduct); return productRepository.save(existingProduct);

View File

@@ -35,14 +35,27 @@ public class PurchaseOrderService {
private final LedgerService ledgerService; private final LedgerService ledgerService;
private final TransactionService transactionService; private final TransactionService transactionService;
private final VendorRepository vendorRepository; private final VendorRepository vendorRepository;
private final com.kifi.api.repository.inventory.InventoryBalanceRepository inventoryBalanceRepository;
public Flux<PurchaseOrder> getPurchaseOrders(Long userId) { public Flux<PurchaseOrder> getPurchaseOrders(Long userId) {
return purchaseOrderRepository.findByUserId(userId) return purchaseOrderRepository.findByUserId(userId)
.flatMap(po -> purchaseOrderItemRepository.findByPoId(po.getId())
.collectList()
.map(items -> {
po.setItems(items);
return po;
}))
.sort((a, b) -> b.getIssueDate().compareTo(a.getIssueDate())); .sort((a, b) -> b.getIssueDate().compareTo(a.getIssueDate()));
} }
public Mono<PurchaseOrder> getPurchaseOrder(Long userId, Long id) { public Mono<PurchaseOrder> getPurchaseOrder(Long userId, Long id) {
return purchaseOrderRepository.findByUserIdAndId(userId, id); return purchaseOrderRepository.findByUserIdAndId(userId, id)
.flatMap(po -> purchaseOrderItemRepository.findByPoId(po.getId())
.collectList()
.map(items -> {
po.setItems(items);
return po;
}));
} }
public Flux<PurchaseOrderItem> getPurchaseOrderItems(Long poId) { public Flux<PurchaseOrderItem> getPurchaseOrderItems(Long poId) {
@@ -64,13 +77,21 @@ public class PurchaseOrderService {
return purchaseOrderRepository.save(po) return purchaseOrderRepository.save(po)
.flatMap(savedPo -> { .flatMap(savedPo -> {
if (items == null || items.isEmpty()) {
savedPo.setItems(List.of());
return Mono.just(savedPo);
}
return Flux.fromIterable(items) return Flux.fromIterable(items)
.map(item -> { .map(item -> {
item.setPoId(savedPo.getId()); item.setPoId(savedPo.getId());
return item; return item;
}) })
.flatMap(purchaseOrderItemRepository::save) .flatMap(purchaseOrderItemRepository::save)
.then(Mono.just(savedPo)); .collectList()
.map(savedItems -> {
savedPo.setItems(savedItems);
return savedPo;
});
}); });
} }
@@ -78,6 +99,9 @@ public class PurchaseOrderService {
public Mono<PurchaseOrder> updatePurchaseOrder(Long userId, Long id, PurchaseOrder po, List<PurchaseOrderItem> items) { public Mono<PurchaseOrder> updatePurchaseOrder(Long userId, Long id, PurchaseOrder po, List<PurchaseOrderItem> items) {
return purchaseOrderRepository.findByUserIdAndId(userId, id) return purchaseOrderRepository.findByUserIdAndId(userId, id)
.flatMap(existingPo -> { .flatMap(existingPo -> {
if (items == null) {
return Mono.error(new IllegalArgumentException("Items cannot be null"));
}
existingPo.setVendorId(po.getVendorId()); existingPo.setVendorId(po.getVendorId());
existingPo.setPoNumber(po.getPoNumber()); existingPo.setPoNumber(po.getPoNumber());
existingPo.setIssueDate(po.getIssueDate()); existingPo.setIssueDate(po.getIssueDate());
@@ -94,11 +118,16 @@ public class PurchaseOrderService {
.flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) .flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id)
.thenMany(Flux.fromIterable(items) .thenMany(Flux.fromIterable(items)
.map(item -> { .map(item -> {
item.setId(null);
item.setPoId(id); item.setPoId(id);
return item; return item;
}) })
.flatMap(purchaseOrderItemRepository::save)) .flatMap(purchaseOrderItemRepository::save))
.then(Mono.just(savedPo))); .collectList()
.map(savedItems -> {
savedPo.setItems(savedItems);
return savedPo;
}));
}); });
} }
@@ -117,6 +146,7 @@ public class PurchaseOrderService {
.flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) .flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id)
.thenMany(Flux.fromIterable(receivedItems) .thenMany(Flux.fromIterable(receivedItems)
.map(item -> { .map(item -> {
item.setId(null);
item.setPoId(id); item.setPoId(id);
return item; return item;
}) })
@@ -126,21 +156,34 @@ public class PurchaseOrderService {
// Update inventory for received items // Update inventory for received items
return Flux.fromIterable(savedItems) return Flux.fromIterable(savedItems)
.flatMap(item -> { .flatMap(item -> {
if (item.getProductId() != null && item.getQuantity() != null && item.getQuantity().compareTo(BigDecimal.ZERO) > 0) { if (item.getProductId() != null) {
int quantity = item.getQuantity().intValue(); InventoryItem invItem = InventoryItem.builder()
return Flux.range(0, quantity) .userId(userId)
.flatMap(i -> { .productId(item.getProductId())
InventoryItem invItem = InventoryItem.builder() .vendorId(savedPo.getVendorId())
.userId(userId) .purchaseRef(savedPo.getPoNumber())
.productId(item.getProductId()) .purchaseCost(item.getUnitPrice())
.vendorId(savedPo.getVendorId()) .makingCharges(item.getMakingCharge())
.purchaseRef(savedPo.getPoNumber()) .sku(item.getSku())
.purchaseCost(item.getUnitPrice()) .huid(item.getHuid())
.makingCharges(item.getMakingCharge()) .grossWeight(item.getWeight())
.sku(item.getSku() != null ? item.getSku() + "-" + (i + 1) : null) .netWeight(item.getWeight())
.build(); .build();
return inventoryItemService.createItem(userId, invItem); return inventoryItemService.createItem(userId, invItem)
}) .then(inventoryBalanceRepository.findByProductIdAndLocationId(item.getProductId(), 2L) // Default location 2L
.flatMap(balance -> {
balance.setQuantity(balance.getQuantity().add(item.getWeight() != null ? item.getWeight() : BigDecimal.ONE));
balance.setLastUpdated(LocalDateTime.now());
return inventoryBalanceRepository.save(balance);
})
.switchIfEmpty(Mono.defer(() -> {
com.kifi.api.entity.inventory.InventoryBalance newBalance = new com.kifi.api.entity.inventory.InventoryBalance();
newBalance.setProductId(item.getProductId());
newBalance.setLocationId(2L);
newBalance.setQuantity(item.getWeight() != null ? item.getWeight() : BigDecimal.ONE);
newBalance.setLastUpdated(LocalDateTime.now());
return inventoryBalanceRepository.save(newBalance);
})))
.then(Mono.just(item)); .then(Mono.just(item));
} }
return Mono.just(item); return Mono.just(item);
@@ -162,7 +205,11 @@ public class PurchaseOrderService {
.build(); .build();
return transactionService.addTransaction(userId, tx); return transactionService.addTransaction(userId, tx);
}) })
.thenReturn(savedPo); .thenReturn(savedPo)
.map(poResult -> {
poResult.setItems(savedItems);
return poResult;
});
})); }));
}); });
} }

View File

@@ -202,8 +202,7 @@ CREATE TABLE IF NOT EXISTS product_categories (
huid_required BOOLEAN DEFAULT FALSE, huid_required BOOLEAN DEFAULT FALSE,
default_making_charge DECIMAL(15, 2), default_making_charge DECIMAL(15, 2),
making_charge_type VARCHAR(50), making_charge_type VARCHAR(50),
calculation_method VARCHAR(50) DEFAULT 'UNIT', base_unit VARCHAR(20) DEFAULT 'pcs',
base_unit VARCHAR(50) DEFAULT 'pcs',
is_active BOOLEAN DEFAULT TRUE, is_active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP

View File

@@ -0,0 +1,26 @@
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
public class RunSchema {
public static void main(String[] args) {
String url = "jdbc:postgresql://localhost:5432/kifi_db";
String user = "postgres";
String password = "postgres";
try (Connection conn = DriverManager.getConnection(url, user, password);
Statement stmt = conn.createStatement()) {
stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS cgst_total NUMERIC(10,2)");
stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS sgst_total NUMERIC(10,2)");
stmt.execute("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS igst_total NUMERIC(10,2)");
stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS cgst_rate NUMERIC(10,2)");
stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS sgst_rate NUMERIC(10,2)");
stmt.execute("ALTER TABLE purchase_order_items ADD COLUMN IF NOT EXISTS igst_rate NUMERIC(10,2)");
System.out.println("Schema updated successfully!");
} catch (Exception e) {
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,18 @@
package com.kifi.api;
import com.kifi.api.entity.vendor.PurchaseOrder;
import com.kifi.api.entity.vendor.PurchaseOrderItem;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import org.junit.jupiter.api.Test;
public class PurchaseOrderJacksonTest {
@Test
public void testJackson() throws Exception {
ObjectMapper mapper = new ObjectMapper();
String json = "{\"poNumber\": \"TEST-123\", \"items\": [{\"productId\": 1}]}";
PurchaseOrder po = mapper.readValue(json, PurchaseOrder.class);
System.out.println("Parsed items: " + po.getItems());
if (po.getItems() == null || po.getItems().isEmpty()) {
throw new RuntimeException("ITEMS NOT PARSED");
}
}
}

View File

@@ -0,0 +1,18 @@
package com.kifi.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kifi.api.entity.vendor.PurchaseOrder;
import com.kifi.api.entity.vendor.PurchaseOrderItem;
import java.util.List;
public class TestJackson {
public static void main(String[] args) throws Exception {
PurchaseOrder po = new PurchaseOrder();
po.setId(5L);
PurchaseOrderItem item = new PurchaseOrderItem();
item.setId(6L);
po.setItems(List.of(item));
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(po));
}
}

View File

@@ -0,0 +1,19 @@
package com.kifi.api;
import com.kifi.api.entity.vendor.PurchaseOrder;
import com.kifi.api.service.vendor.PurchaseOrderService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class TestService {
@Autowired
private PurchaseOrderService service;
@Test
public void testGetPO() {
PurchaseOrder po = service.getPurchaseOrder(6L, 5L).block();
System.out.println("ITEMS COUNT: " + po.getItems().size());
}
}

View File

@@ -0,0 +1,15 @@
package com.kifi.api;
import com.kifi.api.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public class TestUserMapping {
@Autowired
private UserRepository userRepository;
@Test
public void test() {
var user = userRepository.findById(6L).block();
System.out.println("STATUS_IS: " + user.getSetupStatus());
}
}

11
kifi-api/test_flux.java Normal file
View File

@@ -0,0 +1,11 @@
import reactor.core.publisher.Flux;
public class test_flux {
public static void main(String[] args) {
try {
Flux.fromIterable(null).subscribe();
System.out.println("Did not throw");
} catch(Exception e) {
System.out.println("Threw: " + e.getClass().getName());
}
}
}

BIN
kifi-api/test_flux2.class Normal file

Binary file not shown.

10
kifi-api/test_flux2.java Normal file
View File

@@ -0,0 +1,10 @@
public class test_flux2 {
public static void main(String[] args) {
try {
reactor.core.publisher.Flux.fromIterable(null).subscribe();
System.out.println("Did not throw");
} catch(Exception e) {
System.out.println("Threw: " + e.getClass().getName());
}
}
}

View File

@@ -8,11 +8,22 @@ class DioClient {
static final DioClient _instance = DioClient._internal(); static final DioClient _instance = DioClient._internal();
final Dio dio; final Dio dio;
final FlutterSecureStorage storage; final FlutterSecureStorage storage;
String? _cachedToken;
factory DioClient() { factory DioClient() {
return _instance; return _instance;
} }
Future<void> saveToken(String token) async {
_cachedToken = token;
await storage.write(key: 'jwt_token', value: token);
}
Future<void> clearToken() async {
_cachedToken = null;
await storage.delete(key: 'jwt_token');
}
DioClient._internal() DioClient._internal()
: dio = Dio(BaseOptions( : dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2',
@@ -23,18 +34,26 @@ class DioClient {
storage = const FlutterSecureStorage() { storage = const FlutterSecureStorage() {
dio.interceptors.add(InterceptorsWrapper( dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async { onRequest: (options, handler) async {
final token = await storage.read(key: 'jwt_token'); String? token = _cachedToken;
if (token == null) {
token = await storage.read(key: 'jwt_token');
_cachedToken = token;
}
if (token != null) { if (token != null) {
options.headers['Authorization'] = 'Bearer $token'; options.headers['Authorization'] = 'Bearer $token';
print('DioClient sending token: ${token.substring(0, 10)}...');
} else {
print('DioClient sending request WITHOUT token!');
} }
return handler.next(options); return handler.next(options);
}, },
onError: (DioException error, handler) async { onError: (DioException error, handler) async {
if (error.response?.statusCode == 401) { if (error.response?.statusCode == 401) {
// Token is invalid or expired // Token is invalid or expired
_cachedToken = null;
await storage.delete(key: 'jwt_token'); await storage.delete(key: 'jwt_token');
if (navigatorKey.currentContext != null) { if (navigatorKey.currentState != null) {
Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil( navigatorKey.currentState!.pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const AuthScreen()), MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false, (route) => false,
); );

View File

@@ -38,15 +38,15 @@ class AppTheme {
fillColor: Colors.white, fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 2), borderSide: const BorderSide(color: primaryColor, width: 2),
), ),
hintStyle: const TextStyle(color: textSecondaryColor), hintStyle: const TextStyle(color: textSecondaryColor),
@@ -94,15 +94,15 @@ class AppTheme {
fillColor: darkSurface, fillColor: darkSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none, borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 2), borderSide: const BorderSide(color: primaryColor, width: 2),
), ),
hintStyle: const TextStyle(color: darkTextSecondary), hintStyle: const TextStyle(color: darkTextSecondary),

View File

@@ -12,6 +12,7 @@ class PremiumTextField extends StatelessWidget {
final TextCapitalization textCapitalization; final TextCapitalization textCapitalization;
final int maxLines; final int maxLines;
final bool obscureText; final bool obscureText;
final bool readOnly;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final void Function(String)? onChanged; final void Function(String)? onChanged;
final void Function(String?)? onSaved; final void Function(String?)? onSaved;
@@ -30,6 +31,7 @@ class PremiumTextField extends StatelessWidget {
this.textCapitalization = TextCapitalization.none, this.textCapitalization = TextCapitalization.none,
this.maxLines = 1, this.maxLines = 1,
this.obscureText = false, this.obscureText = false,
this.readOnly = false,
this.validator, this.validator,
this.onChanged, this.onChanged,
this.onSaved, this.onSaved,
@@ -49,20 +51,12 @@ class PremiumTextField extends StatelessWidget {
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
suffixText: suffixText, suffixText: suffixText,
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey), suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
filled: true,
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
), ),
keyboardType: keyboardType, keyboardType: keyboardType,
textCapitalization: textCapitalization, textCapitalization: textCapitalization,
maxLines: maxLines, maxLines: maxLines,
obscureText: obscureText, obscureText: obscureText,
readOnly: readOnly,
onChanged: onChanged, onChanged: onChanged,
onSaved: onSaved, onSaved: onSaved,
validator: validator, validator: validator,

View File

@@ -10,6 +10,8 @@ class SmartSearchDropdown<T> extends StatefulWidget {
final bool Function(T, String)? filterFn; final bool Function(T, String)? filterFn;
final Color? fillColor; final Color? fillColor;
final String? labelText; final String? labelText;
final String? emptyActionText;
final VoidCallback? onEmptyActionPressed;
const SmartSearchDropdown({ const SmartSearchDropdown({
super.key, super.key,
@@ -22,6 +24,8 @@ class SmartSearchDropdown<T> extends StatefulWidget {
this.filterFn, this.filterFn,
this.fillColor, this.fillColor,
this.labelText, this.labelText,
this.emptyActionText,
this.onEmptyActionPressed,
}); });
@override @override
@@ -135,9 +139,24 @@ class _SmartSearchDropdownState<T> extends State<SmartSearchDropdown<T>> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (displayItems.isEmpty) if (displayItems.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text('No matches found'), child: Column(
children: [
const Text('No matches found'),
if (widget.emptyActionText != null && widget.onEmptyActionPressed != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: TextButton(
onPressed: () {
_focusNode.unfocus();
widget.onEmptyActionPressed!();
},
child: Text(widget.emptyActionText!),
),
),
],
),
) )
else else
Flexible( Flexible(

View File

@@ -15,10 +15,13 @@ class AuthRepository {
Future<void> signup(String email, String password) async { Future<void> signup(String email, String password) async {
try { try {
await _ensureCryptoReady(); await _ensureCryptoReady();
await dio.post('/auth/signup', data: { await dio.post(
'email': _crypto.encrypt(email), '/auth/signup',
'password': _crypto.encrypt(password), data: {
}); 'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
},
);
} on DioException catch (e) { } on DioException catch (e) {
final data = e.response?.data; final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data; final errorMsg = data is Map ? data['error'] : data;
@@ -30,7 +33,9 @@ class AuthRepository {
try { try {
return await _attemptLogin(email, password); return await _attemptLogin(email, password);
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 500 || e.response?.statusCode == 400 || e.response?.statusCode == 401) { if (e.response?.statusCode == 500 ||
e.response?.statusCode == 400 ||
e.response?.statusCode == 401) {
_crypto.clearKey(); _crypto.clearKey();
try { try {
return await _attemptLogin(email, password); return await _attemptLogin(email, password);
@@ -46,21 +51,27 @@ class AuthRepository {
} }
} }
Future<Map<String, dynamic>> _attemptLogin(String email, String password) async { Future<Map<String, dynamic>> _attemptLogin(
String email,
String password,
) async {
await _ensureCryptoReady(); await _ensureCryptoReady();
final response = await dio.post('/auth/login', data: { final response = await dio.post(
'email': _crypto.encrypt(email), '/auth/login',
'password': _crypto.encrypt(password), data: {
}); 'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
},
);
return response.data; return response.data;
} }
Future<Map<String, dynamic>> verifyOtp(String email, String otp) async { Future<Map<String, dynamic>> verifyOtp(String email, String otp) async {
try { try {
final response = await dio.post('/auth/verify-otp', data: { final response = await dio.post(
'email': email, '/auth/verify-otp',
'otp': otp, data: {'email': email, 'otp': otp},
}); );
return response.data; return response.data;
} on DioException catch (e) { } on DioException catch (e) {
final data = e.response?.data; final data = e.response?.data;
@@ -71,9 +82,7 @@ class AuthRepository {
Future<void> forgotPassword(String email) async { Future<void> forgotPassword(String email) async {
try { try {
await dio.post('/auth/forgot-password', data: { await dio.post('/auth/forgot-password', data: {'email': email});
'email': email,
});
} on DioException catch (e) { } on DioException catch (e) {
final data = e.response?.data; final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data; final errorMsg = data is Map ? data['error'] : data;
@@ -81,14 +90,21 @@ class AuthRepository {
} }
} }
Future<void> resetPassword(String email, String otp, String newPassword) async { Future<void> resetPassword(
String email,
String otp,
String newPassword,
) async {
try { try {
await _ensureCryptoReady(); await _ensureCryptoReady();
await dio.post('/auth/reset-password', data: { await dio.post(
'email': email, '/auth/reset-password',
'otp': otp, data: {
'newPassword': _crypto.encrypt(newPassword), 'email': email,
}); 'otp': otp,
'newPassword': _crypto.encrypt(newPassword),
},
);
} on DioException catch (e) { } on DioException catch (e) {
final data = e.response?.data; final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data; final errorMsg = data is Map ? data['error'] : data;

View File

@@ -16,9 +16,10 @@ class AuthScreen extends ConsumerStatefulWidget {
ConsumerState<AuthScreen> createState() => _AuthScreenState(); ConsumerState<AuthScreen> createState() => _AuthScreenState();
} }
class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProviderStateMixin { class _AuthScreenState extends ConsumerState<AuthScreen>
with SingleTickerProviderStateMixin {
bool isLogin = true; bool isLogin = true;
final emailController = TextEditingController(text: 'maddy23285@gmail.com'); final emailController = TextEditingController(text: 'nmadaswamy@gmail.com');
final passwordController = TextEditingController(text: 'Algorithm@123'); final passwordController = TextEditingController(text: 'Algorithm@123');
void toggleMode(bool login) { void toggleMode(bool login) {
@@ -35,25 +36,49 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
if (email.isEmpty || password.isEmpty) return; if (email.isEmpty || password.isEmpty) return;
if (isLogin) { if (isLogin) {
final success = await ref.read(authControllerProvider.notifier).login(email, password); final success = await ref
.read(authControllerProvider.notifier)
.login(email, password);
if (success && mounted) { if (success && mounted) {
try { try {
await Future.delayed(const Duration(milliseconds: 500)); // Allow secure storage to settle
final dio = DioClient().dio; final dio = DioClient().dio;
final res = await dio.get('/account/setup/status'); final res = await dio.get('/account/setup/status');
print('Setup status response: ${res.data}');
final status = res.data['status']; final status = res.data['status'];
print('Status is: $status');
if (status == 'COMPLETED' && mounted) { if (status == 'COMPLETED' && mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen())); Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
} else if (mounted) { } else if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const SetupWizardScreen())); Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const SetupWizardScreen()),
);
}
} catch (e, st) {
print('Error getting setup status: $e\n$st');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error fetching setup status: $e')));
// Fallback to Dashboard if login was successful but status check failed (e.g. network glitch)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
} }
} catch(e) {
if (mounted) Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const SetupWizardScreen()));
} }
} }
} else { } else {
final success = await ref.read(authControllerProvider.notifier).signup(email, password); final success = await ref
.read(authControllerProvider.notifier)
.signup(email, password);
if (success && mounted) { if (success && mounted) {
Navigator.push(context, MaterialPageRoute(builder: (_) => OtpScreen(email: email))); Navigator.push(
context,
MaterialPageRoute(builder: (_) => OtpScreen(email: email)),
);
} }
} }
} }
@@ -85,9 +110,9 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) { ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) { if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('${next.error}')), context,
); ).showSnackBar(SnackBar(content: Text('${next.error}')));
} }
}); });
@@ -107,23 +132,36 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
width: 80, height: 80, width: 80,
height: 80,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.transparent, color: Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center(child: Image.asset('assets/logo.png', width: 80, height: 80, fit: BoxFit.contain)), child: Center(
child: Image.asset(
'assets/logo.png',
width: 80,
height: 80,
fit: BoxFit.contain,
),
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Welcome to Kifi', 'Welcome to Kifi',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87), style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Manage your inventory and expenses securely.', 'Manage your inventory and expenses securely.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -143,11 +181,31 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isLogin ? Colors.white : Colors.transparent, color: isLogin
? Colors.white
: Colors.transparent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: isLogin ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : [], boxShadow: isLogin
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: [],
),
child: Center(
child: Text(
'Login',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isLogin
? Colors.black87
: Colors.grey.shade600,
),
),
), ),
child: Center(child: Text('Login', style: TextStyle(fontWeight: FontWeight.w600, color: isLogin ? Colors.black87 : Colors.grey.shade600))),
), ),
), ),
), ),
@@ -159,11 +217,31 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: !isLogin ? Colors.white : Colors.transparent, color: !isLogin
? Colors.white
: Colors.transparent,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: !isLogin ? [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 4, offset: const Offset(0, 2))] : [], boxShadow: !isLogin
? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: [],
),
child: Center(
child: Text(
'Sign Up',
style: TextStyle(
fontWeight: FontWeight.w600,
color: !isLogin
? Colors.black87
: Colors.grey.shade600,
),
),
), ),
child: Center(child: Text('Sign Up', style: TextStyle(fontWeight: FontWeight.w600, color: !isLogin ? Colors.black87 : Colors.grey.shade600))),
), ),
), ),
), ),
@@ -181,18 +259,30 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
TextField( TextField(
controller: emailController, controller: emailController,
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.black87,
),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
decoration: _buildInputDecoration(isLogin ? 'Email or Username' : 'Email Address', LucideIcons.mail), decoration: _buildInputDecoration(
isLogin ? 'Email or Username' : 'Email Address',
LucideIcons.mail,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: passwordController, controller: passwordController,
obscureText: true, obscureText: true,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), style: const TextStyle(
fontWeight: FontWeight.w500,
color: Colors.black87,
),
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) => submit(), onSubmitted: (_) => submit(),
decoration: _buildInputDecoration('Password', LucideIcons.lock), decoration: _buildInputDecoration(
'Password',
LucideIcons.lock,
),
), ),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -202,14 +292,32 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
child: TextButton( child: TextButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen())), onPressed: () => Navigator.push(
style: TextButton.styleFrom( context,
foregroundColor: Theme.of(context).primaryColor, MaterialPageRoute(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), builder: (_) =>
minimumSize: Size.zero, const ForgotPasswordScreen(),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, ),
),
style: TextButton.styleFrom(
foregroundColor: Theme.of(
context,
).primaryColor,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
child: const Text(
'Forgot Password?',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
), ),
child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
), ),
), ),
) )
@@ -227,12 +335,28 @@ class _AuthScreenState extends ConsumerState<AuthScreen> with SingleTickerProvid
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2, elevation: 2,
), ),
child: isLoading child: isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: Text(isLogin ? 'Log In' : 'Create Account', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)), height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
isLogin ? 'Log In' : 'Create Account',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
), ),
), ),
], ],

View File

@@ -8,7 +8,8 @@ class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({super.key}); const ForgotPasswordScreen({super.key});
@override @override
ConsumerState<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState(); ConsumerState<ForgotPasswordScreen> createState() =>
_ForgotPasswordScreenState();
} }
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> { class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
@@ -34,7 +35,9 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
_errorMessage = null; _errorMessage = null;
}); });
final success = await ref.read(authControllerProvider.notifier).forgotPassword(email); final success = await ref
.read(authControllerProvider.notifier)
.forgotPassword(email);
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
@@ -44,7 +47,10 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)), MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)),
); );
} else { } else {
setState(() => _errorMessage = 'Failed to send reset code. Please check your email.'); setState(
() => _errorMessage =
'Failed to send reset code. Please check your email.',
);
} }
} }
} }
@@ -99,24 +105,38 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
width: 80, height: 80, width: 80,
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1), color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center(child: Icon(LucideIcons.keyRound, size: 40, color: Theme.of(context).primaryColor)), child: Center(
child: Icon(
LucideIcons.keyRound,
size: 40,
color: Theme.of(context).primaryColor,
),
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Forgot Password?', 'Forgot Password?',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87), style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Enter your email address and we\'ll send you\na verification code to reset your password.', 'Enter your email address and we\'ll send you\na verification code to reset your password.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
height: 1.5,
),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
TextField( TextField(
@@ -125,7 +145,10 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) => _sendOtp(), onSubmitted: (_) => _sendOtp(),
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
decoration: _buildInputDecoration('Email Address', LucideIcons.mail), decoration: _buildInputDecoration(
'Email Address',
LucideIcons.mail,
),
), ),
AnimatedSize( AnimatedSize(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -135,17 +158,35 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
? Padding( ? Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.shade50, color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200), border: Border.all(
color: Colors.red.shade200,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20), Icon(
LucideIcons.alertCircle,
color: Colors.red.shade400,
size: 20,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 14, fontWeight: FontWeight.w500))), Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: Colors.red.shade700,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
], ],
), ),
), ),
@@ -161,12 +202,28 @@ class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2, elevation: 2,
), ),
child: _isLoading child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) ? const SizedBox(
: const Text('Send Reset Code', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)), width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Send Reset Code',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
), ),
), ),
], ],

View File

@@ -19,10 +19,15 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
final otp = otpController.text.trim(); final otp = otpController.text.trim();
if (otp.isEmpty) return; if (otp.isEmpty) return;
final success = await ref.read(authControllerProvider.notifier).verifyOtp(widget.email, otp); final success = await ref
.read(authControllerProvider.notifier)
.verifyOtp(widget.email, otp);
if (success && mounted) { if (success && mounted) {
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()), (route) => false); context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
(route) => false,
);
} }
} }
@@ -30,9 +35,9 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) { ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) { if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('${next.error}')), context,
); ).showSnackBar(SnackBar(content: Text('${next.error}')));
} }
}); });
@@ -65,23 +70,37 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
width: 80, height: 80, width: 80,
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1), color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center(child: Icon(LucideIcons.mailCheck, size: 40, color: Theme.of(context).primaryColor)), child: Center(
child: Icon(
LucideIcons.mailCheck,
size: 40,
color: Theme.of(context).primaryColor,
),
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Verify Email', 'Verify Email',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87), style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Enter the 6-digit OTP sent to\n${widget.email}', 'Enter the 6-digit OTP sent to\n${widget.email}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
height: 1.5,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
@@ -90,17 +109,39 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLength: 6, maxLength: 6,
style: const TextStyle(fontSize: 24, letterSpacing: 16, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 24,
letterSpacing: 16,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration( decoration: InputDecoration(
hintText: '000000', hintText: '000000',
hintStyle: TextStyle(letterSpacing: 16, color: Colors.grey.shade300), hintStyle: TextStyle(
letterSpacing: 16,
color: Colors.grey.shade300,
),
counterText: '', counterText: '',
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), contentPadding: const EdgeInsets.symmetric(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)), horizontal: 16,
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)), vertical: 20,
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)), ),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
width: 2,
),
),
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -112,12 +153,28 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2, elevation: 2,
), ),
child: isLoading child: isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: const Text('Verify Account', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)), height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Verify Account',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
), ),
), ),
], ],

View File

@@ -23,7 +23,8 @@ class ProfileScreen extends ConsumerStatefulWidget {
ConsumerState<ProfileScreen> createState() => _ProfileScreenState(); ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
} }
class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTickerProviderStateMixin { class _ProfileScreenState extends ConsumerState<ProfileScreen>
with SingleTickerProviderStateMixin {
bool _isExporting = false; bool _isExporting = false;
String? _profileType; String? _profileType;
String _userName = 'Loading...'; String _userName = 'Loading...';
@@ -34,8 +35,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800)); _animController = AnimationController(
_fadeAnim = CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic); vsync: this,
duration: const Duration(milliseconds: 800),
);
_fadeAnim = CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
);
_animController.forward(); _animController.forward();
_fetchProfileType(); _fetchProfileType();
} }
@@ -68,7 +75,8 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
} }
String _getInitials(String name) { String _getInitials(String name) {
if (name.isEmpty || name == 'Kifi User' || name == 'Loading...') return 'KU'; if (name.isEmpty || name == 'Kifi User' || name == 'Loading...')
return 'KU';
final parts = name.trim().split(' '); final parts = name.trim().split(' ');
if (parts.length > 1 && parts[1].isNotEmpty) { if (parts.length > 1 && parts[1].isNotEmpty) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
@@ -76,11 +84,9 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase(); return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase();
} }
Future<void> _logout(BuildContext context) async { Future<void> _logout() async {
const storage = FlutterSecureStorage(); await DioClient().clearToken();
await storage.delete(key: 'jwt_token'); if (mounted) {
if (context.mounted) {
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute(builder: (_) => const AuthScreen()), MaterialPageRoute(builder: (_) => const AuthScreen()),
@@ -97,9 +103,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
final file = File('${tempDir.path}/transactions_export.csv'); final file = File('${tempDir.path}/transactions_export.csv');
await file.writeAsBytes(bytes); await file.writeAsBytes(bytes);
final xFile = XFile(file.path); final xFile = XFile(file.path);
await Share.shareXFiles([xFile], text: 'Here is my Kifi transactions export.'); await Share.shareXFiles([
xFile,
], text: 'Here is my Kifi transactions export.');
} catch (e) { } catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Export failed: $e'))); if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
} finally { } finally {
if (mounted) setState(() => _isExporting = false); if (mounted) setState(() => _isExporting = false);
} }
@@ -115,14 +126,19 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
backgroundColor: Theme.of(context).scaffoldBackgroundColor, backgroundColor: Theme.of(context).scaffoldBackgroundColor,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: AppBar( appBar: AppBar(
title: const Text('Profile', style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2)), title: const Text(
'Profile',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 1.2),
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
centerTitle: true, centerTitle: true,
flexibleSpace: ClipRect( flexibleSpace: ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5)), child: Container(
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5),
),
), ),
), ),
), ),
@@ -130,9 +146,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
children: [ children: [
// Background Decorative Elements // Background Decorative Elements
Positioned( Positioned(
top: -50, right: -50, top: -50,
right: -50,
child: Container( child: Container(
width: 200, height: 200, width: 200,
height: 200,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: primaryColor.withOpacity(isDark ? 0.2 : 0.1), color: primaryColor.withOpacity(isDark ? 0.2 : 0.1),
@@ -140,9 +158,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
), ),
), ),
Positioned( Positioned(
bottom: -100, left: -50, bottom: -100,
left: -50,
child: Container( child: Container(
width: 300, height: 300, width: 300,
height: 300,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: Colors.purple.withOpacity(isDark ? 0.15 : 0.08), color: Colors.purple.withOpacity(isDark ? 0.15 : 0.08),
@@ -158,32 +178,45 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
constraints: const BoxConstraints(maxWidth: 600), constraints: const BoxConstraints(maxWidth: 600),
child: ListView( child: ListView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0), padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 24.0,
),
children: [ children: [
// Avatar Section // Avatar Section
Center( Center(
child: Hero( child: Hero(
tag: 'profile_avatar', tag: 'profile_avatar',
child: Container( child: Container(
width: 120, height: 120, width: 120,
height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
primaryColor.withOpacity(0.8), primaryColor.withOpacity(0.8),
primaryColor.withOpacity(0.5) primaryColor.withOpacity(0.5),
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
boxShadow: [ boxShadow: [
BoxShadow(color: primaryColor.withOpacity(0.3), blurRadius: 20, offset: const Offset(0, 10)), BoxShadow(
color: primaryColor.withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
], ],
), ),
child: Center( child: Center(
child: Text( child: Text(
_getInitials(_userName), _getInitials(_userName),
style: const TextStyle(fontSize: 40, fontWeight: FontWeight.bold, color: Colors.white, letterSpacing: 2), style: const TextStyle(
fontSize: 40,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 2,
),
), ),
), ),
), ),
@@ -192,14 +225,21 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
_userName, _userName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_userEmail.isNotEmpty && _userEmail != 'Loading...') ...[ if (_userEmail.isNotEmpty &&
_userEmail != 'Loading...') ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
_userEmail, _userEmail,
style: TextStyle(color: isDark ? Colors.grey.shade400 : Colors.grey.shade600, fontSize: 16), style: TextStyle(
color: isDark
? Colors.grey.shade400
: Colors.grey.shade600,
fontSize: 16,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -212,9 +252,16 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor.withOpacity(isDark ? 0.3 : 0.6), color: Theme.of(
context,
).cardColor.withOpacity(isDark ? 0.3 : 0.6),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.white.withOpacity(isDark ? 0.05 : 0.2), width: 1.5), border: Border.all(
color: Colors.white.withOpacity(
isDark ? 0.05 : 0.2,
),
width: 1.5,
),
), ),
child: Column( child: Column(
children: [ children: [
@@ -224,8 +271,18 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
iconColor: Colors.blue, iconColor: Colors.blue,
title: 'Export Data to CSV', title: 'Export Data to CSV',
trailing: _isExporting trailing: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(
: const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
LucideIcons.chevronRight,
size: 20,
color: Colors.grey,
),
onTap: _isExporting ? null : _exportData, onTap: _isExporting ? null : _exportData,
), ),
_buildDivider(context, isDark), _buildDivider(context, isDark),
@@ -236,31 +293,56 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
title: 'Appearance', title: 'Appearance',
trailing: SegmentedButton<ThemeMode>( trailing: SegmentedButton<ThemeMode>(
segments: const [ segments: const [
ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun, size: 16)), ButtonSegment(
ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor, size: 16)), value: ThemeMode.light,
ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon, size: 16)), icon: Icon(LucideIcons.sun, size: 16),
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(
LucideIcons.monitor,
size: 16,
),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(LucideIcons.moon, size: 16),
),
], ],
selected: {themeMode}, selected: {themeMode},
onSelectionChanged: (Set<ThemeMode> newSelection) { onSelectionChanged:
ref.read(themeProvider.notifier).setTheme(newSelection.first); (Set<ThemeMode> newSelection) {
}, ref
.read(themeProvider.notifier)
.setTheme(newSelection.first);
},
showSelectedIcon: false, showSelectedIcon: false,
style: ButtonStyle( style: ButtonStyle(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize:
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) { MaterialTapTargetSize.shrinkWrap,
if (states.contains(WidgetState.selected)) { backgroundColor:
return primaryColor.withOpacity(0.2); WidgetStateProperty.resolveWith<
} Color?
return null; >((states) {
}), if (states.contains(
WidgetState.selected,
)) {
return primaryColor.withOpacity(
0.2,
);
}
return null;
}),
), ),
), ),
), ),
_buildDivider(context, isDark), _buildDivider(context, isDark),
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final isBusinessMode = ref.watch(businessModeProvider); final isBusinessMode = ref.watch(
businessModeProvider,
);
return Column( return Column(
children: [ children: [
if (_profileType == 'BUSINESS') ...[ if (_profileType == 'BUSINESS') ...[
@@ -271,7 +353,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
title: 'Business Mode', title: 'Business Mode',
subtitle: 'Inventory and Sales Hub', subtitle: 'Inventory and Sales Hub',
value: isBusinessMode, value: isBusinessMode,
onChanged: (val) => ref.read(businessModeProvider.notifier).toggleMode(), onChanged: (val) => ref
.read(
businessModeProvider.notifier,
)
.toggleMode(),
), ),
if (isBusinessMode) ...[ if (isBusinessMode) ...[
_buildDivider(context, isDark), _buildDivider(context, isDark),
@@ -280,16 +366,27 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
icon: LucideIcons.settings2, icon: LucideIcons.settings2,
iconColor: Colors.grey.shade600, iconColor: Colors.grey.shade600,
title: 'Business Settings', title: 'Business Settings',
subtitle: 'Configure Taxes, Barcodes, etc.', subtitle:
trailing: const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), 'Configure Taxes, Barcodes, etc.',
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen())), trailing: const Icon(
LucideIcons.chevronRight,
size: 20,
color: Colors.grey,
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) =>
const BusinessSettingsScreen(),
),
),
), ),
], ],
_buildDivider(context, isDark), _buildDivider(context, isDark),
], ],
], ],
); );
} },
), ),
_buildDivider(context, isDark), _buildDivider(context, isDark),
_buildSettingsTile( _buildSettingsTile(
@@ -297,7 +394,11 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
icon: LucideIcons.helpCircle, icon: LucideIcons.helpCircle,
iconColor: Colors.green, iconColor: Colors.green,
title: 'Help & Support', title: 'Help & Support',
trailing: const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), trailing: const Icon(
LucideIcons.chevronRight,
size: 20,
color: Colors.grey,
),
onTap: () {}, onTap: () {},
), ),
], ],
@@ -316,20 +417,33 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
colors: [Colors.red.shade400, Colors.red.shade600], colors: [Colors.red.shade400, Colors.red.shade600],
), ),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.red.withOpacity(0.3), blurRadius: 15, offset: const Offset(0, 5)), BoxShadow(
color: Colors.red.withOpacity(0.3),
blurRadius: 15,
offset: const Offset(0, 5),
),
], ],
), ),
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: () => _logout(context), onPressed: () => _logout(),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
icon: const Icon(LucideIcons.logOut, size: 22), icon: const Icon(LucideIcons.logOut, size: 22),
label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, letterSpacing: 1.0)), label: const Text(
'Log Out',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
letterSpacing: 1.0,
),
),
), ),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -364,8 +478,19 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> with SingleTicker
), ),
child: Icon(icon, color: iconColor, size: 24), child: Icon(icon, color: iconColor, size: 24),
), ),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), title: Text(
subtitle: subtitle != null ? Text(subtitle, style: TextStyle(fontSize: 13, color: isDark ? Colors.grey.shade400 : Colors.grey.shade600)) : null, title,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
subtitle: subtitle != null
? Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.grey.shade400 : Colors.grey.shade600,
),
)
: null,
trailing: trailing, trailing: trailing,
onTap: onTap, onTap: onTap,
); );

View File

@@ -9,7 +9,8 @@ class ResetPasswordScreen extends ConsumerStatefulWidget {
const ResetPasswordScreen({super.key, required this.email}); const ResetPasswordScreen({super.key, required this.email});
@override @override
ConsumerState<ResetPasswordScreen> createState() => _ResetPasswordScreenState(); ConsumerState<ResetPasswordScreen> createState() =>
_ResetPasswordScreenState();
} }
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> { class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
@@ -52,7 +53,9 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
_errorMessage = null; _errorMessage = null;
}); });
final success = await ref.read(authControllerProvider.notifier).resetPassword(widget.email, otp, newPassword); final success = await ref
.read(authControllerProvider.notifier)
.resetPassword(widget.email, otp, newPassword);
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
@@ -69,15 +72,24 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
(route) => false, (route) => false,
); );
} else { } else {
setState(() => _errorMessage = 'Invalid OTP or reset failed. Please try again.'); setState(
() =>
_errorMessage = 'Invalid OTP or reset failed. Please try again.',
);
} }
} }
} }
InputDecoration _buildInputDecoration(String hint, {IconData? prefixIcon, Widget? suffixIcon}) { InputDecoration _buildInputDecoration(
String hint, {
IconData? prefixIcon,
Widget? suffixIcon,
}) {
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: Colors.grey.shade500) : null, prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: Colors.grey.shade500)
: null,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
@@ -125,24 +137,38 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Container( Container(
width: 80, height: 80, width: 80,
height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1), color: Theme.of(context).primaryColor.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Center(child: Icon(LucideIcons.shieldCheck, size: 40, color: Theme.of(context).primaryColor)), child: Center(
child: Icon(
LucideIcons.shieldCheck,
size: 40,
color: Theme.of(context).primaryColor,
),
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Reset Password', 'Reset Password',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.black87), style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'We\'ve sent a 6-digit code to\n${widget.email}', 'We\'ve sent a 6-digit code to\n${widget.email}',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey.shade600, height: 1.5), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey.shade600,
height: 1.5,
),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -152,18 +178,40 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
maxLength: 6, maxLength: 6,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 24, letterSpacing: 16), style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 24,
letterSpacing: 16,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
decoration: InputDecoration( decoration: InputDecoration(
hintText: '000000', hintText: '000000',
hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 16), hintStyle: TextStyle(
color: Colors.grey.shade300,
letterSpacing: 16,
),
counterText: '', counterText: '',
filled: true, filled: true,
fillColor: Colors.white, fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), contentPadding: const EdgeInsets.symmetric(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)), horizontal: 16,
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade300)), vertical: 20,
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2)), ),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).primaryColor,
width: 2,
),
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -178,8 +226,15 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
'New Password', 'New Password',
prefixIcon: LucideIcons.lock, prefixIcon: LucideIcons.lock,
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), icon: Icon(
onPressed: () => setState(() => _obscurePassword = !_obscurePassword), _obscurePassword
? LucideIcons.eyeOff
: LucideIcons.eye,
color: Colors.grey.shade500,
),
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
), ),
), ),
), ),
@@ -196,8 +251,15 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
'Confirm Password', 'Confirm Password',
prefixIcon: LucideIcons.lock, prefixIcon: LucideIcons.lock,
suffixIcon: IconButton( suffixIcon: IconButton(
icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), icon: Icon(
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), _obscureConfirm
? LucideIcons.eyeOff
: LucideIcons.eye,
color: Colors.grey.shade500,
),
onPressed: () => setState(
() => _obscureConfirm = !_obscureConfirm,
),
), ),
), ),
), ),
@@ -210,17 +272,35 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
? Padding( ? Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.red.shade50, color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200), border: Border.all(
color: Colors.red.shade200,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20), Icon(
LucideIcons.alertCircle,
color: Colors.red.shade400,
size: 20,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 14, fontWeight: FontWeight.w500))), Expanded(
child: Text(
_errorMessage!,
style: TextStyle(
color: Colors.red.shade700,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
], ],
), ),
), ),
@@ -237,12 +317,28 @@ class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 20), padding: const EdgeInsets.symmetric(vertical: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2, elevation: 2,
), ),
child: _isLoading child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) ? const SizedBox(
: const Text('Reset Password', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5)), width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Reset Password',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
), ),
), ),
], ],

View File

@@ -12,7 +12,8 @@ class SetupWizardScreen extends StatefulWidget {
State<SetupWizardScreen> createState() => _SetupWizardScreenState(); State<SetupWizardScreen> createState() => _SetupWizardScreenState();
} }
class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTickerProviderStateMixin { class _SetupWizardScreenState extends State<SetupWizardScreen>
with SingleTickerProviderStateMixin {
bool _isLoading = false; bool _isLoading = false;
int _currentStep = 0; int _currentStep = 0;
String _accountType = 'INDIVIDUAL'; String _accountType = 'INDIVIDUAL';
@@ -43,7 +44,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
if (username.length < 3) return; if (username.length < 3) return;
setState(() => _checkingUsername = true); setState(() => _checkingUsername = true);
try { try {
final res = await DioClient().dio.get('/account/username/availability', queryParameters: {'username': username}); final res = await DioClient().dio.get(
'/account/username/availability',
queryParameters: {'username': username},
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isUsernameAvailable = res.data['available'] == true; _isUsernameAvailable = res.data['available'] == true;
@@ -70,7 +74,9 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
if (_accountType == 'BUSINESS') { if (_accountType == 'BUSINESS') {
data.addAll({ data.addAll({
'businessName': _businessNameController.text.isNotEmpty ? _businessNameController.text : _nameController.text, 'businessName': _businessNameController.text.isNotEmpty
? _businessNameController.text
: _nameController.text,
'natureOfBusiness': _natureOfBusiness!, 'natureOfBusiness': _natureOfBusiness!,
'address': _addressController.text, 'address': _addressController.text,
'emailId': _emailController.text, 'emailId': _emailController.text,
@@ -82,12 +88,17 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
await DioClient().dio.post('/account/setup', data: data); await DioClient().dio.post('/account/setup', data: data);
if (mounted) { if (mounted) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen())); Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
} }
} catch (e) { } catch (e) {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Setup failed: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Setup failed: $e')));
} }
} }
} }
@@ -102,21 +113,31 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
void _nextStep() { void _nextStep() {
if (_currentStep == 1) { if (_currentStep == 1) {
if (_nameController.text.trim().isEmpty) { if (_nameController.text.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please enter your Full Name'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your Full Name')),
);
return; return;
} }
if (_usernameController.text.trim().length < 3) { if (_usernameController.text.trim().length < 3) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Username must be at least 3 characters'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username must be at least 3 characters'),
),
);
return; return;
} }
if (!_isUsernameAvailable) { if (!_isUsernameAvailable) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Username is unavailable or invalid'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Username is unavailable or invalid')),
);
return; return;
} }
} }
if (_accountType == 'BUSINESS' && _currentStep == 2) { if (_accountType == 'BUSINESS' && _currentStep == 2) {
if (_natureOfBusiness == null) { if (_natureOfBusiness == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select Nature of Business'))); ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select Nature of Business')),
);
return; return;
} }
} }
@@ -163,7 +184,11 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
children: [ children: [
Text( Text(
'Step ${_currentStep + 1} of $_totalSteps', 'Step ${_currentStep + 1} of $_totalSteps',
style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold, fontSize: 14), style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 14,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
@@ -172,7 +197,9 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
value: (_currentStep + 1) / _totalSteps, value: (_currentStep + 1) / _totalSteps,
minHeight: 6, minHeight: 6,
backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1),
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor), valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
), ),
), ),
], ],
@@ -185,18 +212,39 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
key: const ValueKey(0), key: const ValueKey(0),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Welcome to Kifi!', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), const Text(
'Welcome to Kifi!',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('How will you be using the application?', style: TextStyle(fontSize: 16, color: Colors.grey)), const Text(
'How will you be using the application?',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildAccountTypeCard('INDIVIDUAL', 'Individual', 'For personal use and expenses', LucideIcons.user), _buildAccountTypeCard(
'INDIVIDUAL',
'Individual',
'For personal use and expenses',
LucideIcons.user,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAccountTypeCard('BUSINESS', 'Business', 'For managing business and inventory', LucideIcons.building2), _buildAccountTypeCard(
'BUSINESS',
'Business',
'For managing business and inventory',
LucideIcons.building2,
),
], ],
); );
} }
Widget _buildAccountTypeCard(String type, String title, String subtitle, IconData icon) { Widget _buildAccountTypeCard(
String type,
String title,
String subtitle,
IconData icon,
) {
final isSelected = _accountType == type; final isSelected = _accountType == type;
return InkWell( return InkWell(
onTap: () => setState(() => _accountType = type), onTap: () => setState(() => _accountType = type),
@@ -205,40 +253,67 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? Theme.of(context).primaryColor.withOpacity(0.08) : Colors.white, color: isSelected
? Theme.of(context).primaryColor.withOpacity(0.08)
: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade300, color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade300,
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
boxShadow: isSelected ? [] : [ boxShadow: isSelected
BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 10, offset: const Offset(0, 4)), ? []
], : [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade100, color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade100,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(icon, color: isSelected ? Colors.white : Colors.grey[600], size: 28), child: Icon(
icon,
color: isSelected ? Colors.white : Colors.grey[600],
size: 28,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: Colors.grey[600], fontSize: 14)), Text(
subtitle,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
], ],
), ),
), ),
Icon( Icon(
isSelected ? LucideIcons.checkCircle2 : LucideIcons.circle, isSelected ? LucideIcons.checkCircle2 : LucideIcons.circle,
color: isSelected ? Theme.of(context).primaryColor : Colors.grey[300], color: isSelected
? Theme.of(context).primaryColor
: Colors.grey[300],
size: 28, size: 28,
), ),
], ],
@@ -252,9 +327,15 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
key: const ValueKey(1), key: const ValueKey(1),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Personal Details', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), const Text(
'Personal Details',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Tell us a bit about yourself.', style: TextStyle(fontSize: 16, color: Colors.grey)), const Text(
'Tell us a bit about yourself.',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 32), const SizedBox(height: 32),
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
@@ -266,17 +347,28 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
controller: _usernameController, controller: _usernameController,
decoration: _buildInputDecoration('Username *').copyWith( decoration: _buildInputDecoration('Username *').copyWith(
suffixIcon: SizedBox( suffixIcon: SizedBox(
width: 48, height: 48, width: 48,
height: 48,
child: Center( child: Center(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: _checkingUsername child: _checkingUsername
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(
: Icon( width: 20,
_isUsernameAvailable ? LucideIcons.checkCircle2 : LucideIcons.xCircle, height: 20,
key: ValueKey(_isUsernameAvailable), child: CircularProgressIndicator(strokeWidth: 2),
color: _usernameController.text.isEmpty ? Colors.transparent : (_isUsernameAvailable ? Colors.green : Colors.red), )
), : Icon(
_isUsernameAvailable
? LucideIcons.checkCircle2
: LucideIcons.xCircle,
key: ValueKey(_isUsernameAvailable),
color: _usernameController.text.isEmpty
? Colors.transparent
: (_isUsernameAvailable
? Colors.green
: Colors.red),
),
), ),
), ),
), ),
@@ -299,18 +391,39 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
key: const ValueKey(2), key: const ValueKey(2),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Business Profile', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), const Text(
'Business Profile',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Set up your company details for invoicing and tracking.', style: TextStyle(fontSize: 16, color: Colors.grey)), const Text(
'Set up your company details for invoicing and tracking.',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 32), const SizedBox(height: 32),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
decoration: _buildInputDecoration('Nature of Business *'), decoration: _buildInputDecoration('Nature of Business *'),
dropdownColor: Colors.white, dropdownColor: Colors.white,
value: _natureOfBusiness, value: _natureOfBusiness,
items: const [ items: const [
DropdownMenuItem(value: 'JEWELLERY', child: Text('Jewellery', style: TextStyle(color: Colors.black87))), DropdownMenuItem(
DropdownMenuItem(value: 'PROJECT_MANAGEMENT', child: Text('Project Management', style: TextStyle(color: Colors.black87))), value: 'JEWELLERY',
DropdownMenuItem(value: 'INVENTORY_MANAGEMENT', child: Text('Inventory Management', style: TextStyle(color: Colors.black87))), child: Text('Jewellery', style: TextStyle(color: Colors.black87)),
),
DropdownMenuItem(
value: 'PROJECT_MANAGEMENT',
child: Text(
'Project Management',
style: TextStyle(color: Colors.black87),
),
),
DropdownMenuItem(
value: 'INVENTORY_MANAGEMENT',
child: Text(
'Inventory Management',
style: TextStyle(color: Colors.black87),
),
),
], ],
onChanged: (val) => setState(() => _natureOfBusiness = val), onChanged: (val) => setState(() => _natureOfBusiness = val),
), ),
@@ -364,9 +477,15 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
key: const ValueKey(3), key: const ValueKey(3),
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Final Step', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), const Text(
'Final Step',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('Please review and accept our policies to continue.', style: TextStyle(fontSize: 16, color: Colors.grey)), const Text(
'Please review and accept our policies to continue.',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
const SizedBox(height: 32), const SizedBox(height: 32),
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -374,7 +493,11 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade300), border: Border.all(color: Colors.grey.shade300),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.02), blurRadius: 10, offset: const Offset(0, 4)), BoxShadow(
color: Colors.black.withOpacity(0.02),
blurRadius: 10,
offset: const Offset(0, 4),
),
], ],
), ),
child: Column( child: Column(
@@ -382,25 +505,49 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
CheckboxListTile( CheckboxListTile(
title: InkWell( title: InkWell(
onTap: () => _launchUrl('terms'), onTap: () => _launchUrl('terms'),
child: const Text('I accept the Terms & Conditions', style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline, fontSize: 15, fontWeight: FontWeight.w500)), child: const Text(
'I accept the Terms & Conditions',
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
), ),
value: _termsAccepted, value: _termsAccepted,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
onChanged: (val) => setState(() => _termsAccepted = val ?? false), onChanged: (val) =>
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), setState(() => _termsAccepted = val ?? false),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
), ),
Divider(height: 1, indent: 56, color: Colors.grey.shade200), Divider(height: 1, indent: 56, color: Colors.grey.shade200),
CheckboxListTile( CheckboxListTile(
title: InkWell( title: InkWell(
onTap: () => _launchUrl('privacy'), onTap: () => _launchUrl('privacy'),
child: const Text('I accept the Privacy Policy', style: TextStyle(color: Colors.blue, decoration: TextDecoration.underline, fontSize: 15, fontWeight: FontWeight.w500)), child: const Text(
'I accept the Privacy Policy',
style: TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
), ),
value: _privacyAccepted, value: _privacyAccepted,
controlAffinity: ListTileControlAffinity.leading, controlAffinity: ListTileControlAffinity.leading,
activeColor: Theme.of(context).primaryColor, activeColor: Theme.of(context).primaryColor,
onChanged: (val) => setState(() => _privacyAccepted = val ?? false), onChanged: (val) =>
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), setState(() => _privacyAccepted = val ?? false),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
), ),
], ],
), ),
@@ -412,7 +559,8 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
Widget _buildCurrentStep() { Widget _buildCurrentStep() {
if (_currentStep == 0) return _buildStep0AccountType(); if (_currentStep == 0) return _buildStep0AccountType();
if (_currentStep == 1) return _buildStep1Personal(); if (_currentStep == 1) return _buildStep1Personal();
if (_accountType == 'BUSINESS' && _currentStep == 2) return _buildStep2Business(); if (_accountType == 'BUSINESS' && _currentStep == 2)
return _buildStep2Business();
return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business) return _buildStep3Consent(); // Step 2 (Individual) or Step 3 (Business)
} }
@@ -424,7 +572,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
elevation: 0, elevation: 0,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
leading: _currentStep > 0 leading: _currentStep > 0
? IconButton(icon: const Icon(LucideIcons.chevronLeft), onPressed: _prevStep) ? IconButton(
icon: const Icon(LucideIcons.chevronLeft),
onPressed: _prevStep,
)
: null, : null,
), ),
body: SafeArea( body: SafeArea(
@@ -446,16 +597,16 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
begin: const Offset(0.05, 0), begin: const Offset(0.05, 0),
end: Offset.zero, end: Offset.zero,
).animate(animation), ).animate(animation),
child: FadeTransition( child: FadeTransition(opacity: animation, child: child),
opacity: animation,
child: child,
),
); );
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
key: ValueKey(_currentStep), key: ValueKey(_currentStep),
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0), padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 16.0,
),
child: _buildCurrentStep(), child: _buildCurrentStep(),
), ),
), ),
@@ -464,7 +615,10 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
), ),
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0), padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 24.0,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)), border: Border(top: BorderSide(color: Colors.grey.shade200)),
@@ -481,14 +635,29 @@ class _SetupWizardScreenState extends State<SetupWizardScreen> with SingleTicker
backgroundColor: Theme.of(context).primaryColor, backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white, foregroundColor: Colors.white,
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
child: _isLoading child: _isLoading
? const SizedBox(height: 24, width: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: Text( height: 24,
_currentStep == _totalSteps - 1 ? 'Complete Setup' : 'Continue', width: 24,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 0.5), child: CircularProgressIndicator(
), color: Colors.white,
strokeWidth: 2,
),
)
: Text(
_currentStep == _totalSteps - 1
? 'Complete Setup'
: 'Continue',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
), ),
), ),
), ),

View File

@@ -17,7 +17,7 @@ class AuthController extends AsyncNotifier<void> {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
final data = await _repository.login(email, password); final data = await _repository.login(email, password);
await DioClient().storage.write(key: 'jwt_token', value: data['token']); await DioClient().saveToken(data['token']);
state = const AsyncValue.data(null); state = const AsyncValue.data(null);
return true; return true;
} catch (e, st) { } catch (e, st) {
@@ -42,7 +42,7 @@ class AuthController extends AsyncNotifier<void> {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
final data = await _repository.verifyOtp(email, otp); final data = await _repository.verifyOtp(email, otp);
await DioClient().storage.write(key: 'jwt_token', value: data['token']); await DioClient().saveToken(data['token']);
state = const AsyncValue.data(null); state = const AsyncValue.data(null);
return true; return true;
} catch (e, st) { } catch (e, st) {
@@ -63,7 +63,11 @@ class AuthController extends AsyncNotifier<void> {
} }
} }
Future<bool> resetPassword(String email, String otp, String newPassword) async { Future<bool> resetPassword(
String email,
String otp,
String newPassword,
) async {
state = const AsyncValue.loading(); state = const AsyncValue.loading();
try { try {
await _repository.resetPassword(email, otp, newPassword); await _repository.resetPassword(email, otp, newPassword);

View File

@@ -34,7 +34,9 @@ class BusinessFeature {
bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY', bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY',
stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true, stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true,
barcodeSource: json['barcodeSource'] ?? 'SKU', barcodeSource: json['barcodeSource'] ?? 'SKU',
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
); );
} }
@@ -71,7 +73,8 @@ class BusinessFeature {
purchaseManagement: purchaseManagement ?? this.purchaseManagement, purchaseManagement: purchaseManagement ?? this.purchaseManagement,
multiLocation: multiLocation ?? this.multiLocation, multiLocation: multiLocation ?? this.multiLocation,
bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy, bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy,
stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, stockDeductionOnInvoice:
stockDeductionOnInvoice ?? this.stockDeductionOnInvoice,
barcodeSource: barcodeSource ?? this.barcodeSource, barcodeSource: barcodeSource ?? this.barcodeSource,
createdAt: createdAt, createdAt: createdAt,
); );

View File

@@ -3,11 +3,7 @@ class IndianState {
final String name; final String name;
final String gstCode; final String gstCode;
IndianState({ IndianState({required this.id, required this.name, required this.gstCode});
required this.id,
required this.name,
required this.gstCode,
});
factory IndianState.fromJson(Map<String, dynamic> json) { factory IndianState.fromJson(Map<String, dynamic> json) {
return IndianState( return IndianState(
@@ -18,10 +14,6 @@ class IndianState {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'id': id, 'name': name, 'gstCode': gstCode};
'id': id,
'name': name,
'gstCode': gstCode,
};
} }
} }

View File

@@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/nature_colors.dart'; import '../../../../core/theme/nature_colors.dart';
import '../../../inventory/presentation/product_list_screen.dart'; import '../../../inventory/presentation/product_list_screen.dart';
import '../../../inventory/presentation/daily_rates_screen.dart';
import '../../../inventory/presentation/quick_adjust_stock_screen.dart'; import '../../../inventory/presentation/quick_adjust_stock_screen.dart';
import '../../../inventory/presentation/uoms_list_screen.dart';
import '../../../inventory/presentation/category_management_screen.dart'; import '../../../inventory/presentation/category_management_screen.dart';
import '../../../sales/presentation/customers_list_screen.dart'; import '../../../sales/presentation/customers_list_screen.dart';
import '../../../sales/presentation/invoices_list_screen.dart'; import '../../../sales/presentation/invoices_list_screen.dart';
@@ -23,7 +23,8 @@ class BusinessHubScreen extends ConsumerWidget {
final businessState = ref.watch(businessProfileProvider); final businessState = ref.watch(businessProfileProvider);
final profile = businessState.value; final profile = businessState.value;
final title = (profile?.businessName != null && profile!.businessName.isNotEmpty) final title =
(profile?.businessName != null && profile!.businessName.isNotEmpty)
? profile.businessName ? profile.businessName
: 'Business Overview'; : 'Business Overview';
final subtitle = (profile?.address != null && profile!.address!.isNotEmpty) final subtitle = (profile?.address != null && profile!.address!.isNotEmpty)
@@ -35,236 +36,337 @@ class BusinessHubScreen extends ConsumerWidget {
final bool showSales = featureState?.salesManagement ?? false; final bool showSales = featureState?.salesManagement ?? false;
final bool showPurchase = featureState?.purchaseManagement ?? false; final bool showPurchase = featureState?.purchaseManagement ?? false;
return SingleChildScrollView( return RefreshIndicator(
padding: const EdgeInsets.all(24.0), onRefresh: () async {
child: Column( ref.invalidate(businessProfileProvider);
crossAxisAlignment: CrossAxisAlignment.start, ref.invalidate(businessFeatureProvider);
children: [ },
InkWell( child: SingleChildScrollView(
onTap: () { physics: const AlwaysScrollableScrollPhysics(),
showModalBottomSheet( padding: const EdgeInsets.all(24.0),
context: context, child: Column(
isScrollControlled: true, crossAxisAlignment: CrossAxisAlignment.start,
backgroundColor: Colors.transparent, children: [
builder: (context) => const BusinessProfileFormSheet(), InkWell(
); onTap: () {
}, showModalBottomSheet(
borderRadius: BorderRadius.circular(16), context: context,
child: Container( isScrollControlled: true,
padding: const EdgeInsets.all(16), backgroundColor: Colors.transparent,
decoration: BoxDecoration( builder: (context) => const BusinessProfileFormSheet(),
color: Theme.of(context).colorScheme.primary.withOpacity(0.1), );
borderRadius: BorderRadius.circular(16), },
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.2)), borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
),
),
child: Row(
children: [
Icon(
LucideIcons.briefcase,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
subtitle,
style: const TextStyle(color: Colors.grey),
),
],
),
),
],
),
), ),
child: Row( ),
children: [ const SizedBox(height: 24),
Icon(LucideIcons.briefcase, color: Theme.of(context).colorScheme.primary, size: 32), GridView.count(
const SizedBox(width: 16), crossAxisCount: 2,
Expanded( crossAxisSpacing: 16,
child: Column( mainAxisSpacing: 16,
crossAxisAlignment: CrossAxisAlignment.start, shrinkWrap: true,
children: [ physics: const NeverScrollableScrollPhysics(),
Text(title, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), childAspectRatio: 1.2,
Text(subtitle, style: const TextStyle(color: Colors.grey)), children: [
], if (showInventory) ...[
_buildActionCard(
context,
'Categories',
'Manage catalog structure',
LucideIcons.listTree,
Colors.teal,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CategoryManagementScreen(),
),
),
),
_buildActionCard(
context,
'Products Catalog',
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ProductListScreen(),
),
), ),
), ),
], ],
), if (showSales)
_buildActionCard(
context,
'Customers',
'Manage clients',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CustomersListScreen(),
),
),
),
if (showPurchase)
_buildActionCard(
context,
'Vendors',
'Manage suppliers',
LucideIcons.truck,
Colors.orange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const VendorsListScreen(),
),
),
),
if (showPurchase)
_buildActionCard(
context,
'Purchases',
'Manage inward stock',
LucideIcons.clipboardList,
Colors.deepOrange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PurchaseOrdersListScreen(),
),
),
),
if (showSales)
_buildActionCard(
context,
'Sales & Invoices',
'Create invoices',
LucideIcons.receipt,
Colors.green,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const InvoicesListScreen(),
),
),
),
if (showInventory)
_buildActionCard(
context,
'Daily Rates',
'Sync live gold/silver rates',
LucideIcons.refreshCw,
Colors.orange,
() => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DailyRatesScreen(),
),
),
),
_buildActionCard(
context,
'Reports',
'Analytics & Valuation',
LucideIcons.barChart2,
Colors.indigo,
() => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ReportsScreen()),
),
),
],
), ),
), if (showInventory) ...[
const SizedBox(height: 24), const SizedBox(height: 32),
GridView.count( Text(
crossAxisCount: 2, 'Low Stock Alerts',
crossAxisSpacing: 16, style: Theme.of(
mainAxisSpacing: 16,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.2,
children: [
if (showInventory) ...[
_buildActionCard(
context, context,
'Products Catalog', ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
'View all products',
LucideIcons.packageSearch,
Colors.blue,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductListScreen())),
),
_buildActionCard(
context,
'Adjust Stock',
'Quick Stock Edit',
LucideIcons.arrowRightLeft,
Colors.orange,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const QuickAdjustStockScreen())),
),
_buildActionCard(
context,
'Units of Measure',
'Manage measurements',
LucideIcons.ruler,
Colors.purple,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const UomsListScreen())),
),
_buildActionCard(
context,
'Categories',
'Manage catalog structure',
LucideIcons.listTree,
Colors.teal,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CategoryManagementScreen())),
),
],
if (showSales) ...[
_buildActionCard(
context,
'Sales & Invoices',
'Create invoices',
LucideIcons.receipt,
Colors.green,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())),
),
_buildActionCard(
context,
'Customers',
'Manage clients',
LucideIcons.users,
NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())),
),
],
if (showPurchase) ...[
_buildActionCard(
context,
'Vendors',
'Manage suppliers',
LucideIcons.truck,
Colors.orange,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())),
),
_buildActionCard(
context,
'Purchase Orders',
'Manage inward stock',
LucideIcons.clipboardList,
Colors.deepOrange,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseOrdersListScreen())),
),
],
_buildActionCard(
context,
'Reports',
'Analytics & Valuation',
LucideIcons.barChart2,
Colors.indigo,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ReportsScreen())),
), ),
], Consumer(
), builder: (context, ref, child) {
if (showInventory) ...[ final productsState = ref.watch(productsProvider);
const SizedBox(height: 32), return productsState.when(
Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), data: (products) {
Consumer( final lowStockProducts = products.where((p) {
builder: (context, ref, child) { if (p.currentStock == null) return false;
final productsState = ref.watch(productsProvider); if (p.currentStock == null) return false;
return productsState.when( final minStock = 0;
data: (products) { return p.currentStock! <= minStock;
final lowStockProducts = products.where((p) { }).toList();
if (!(p.trackInventory ?? false)) return false;
if (p.currentStock == null) return false;
final minStock = p.minStock ?? 0;
return p.currentStock! <= minStock;
}).toList();
if (lowStockProducts.isEmpty) { if (lowStockProducts.isEmpty) {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text('All products are adequately stocked.', style: TextStyle(color: Colors.grey)),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: lowStockProducts.length > 5 ? 5 : lowStockProducts.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final product = lowStockProducts[index];
final isOutOfStock = product.currentStock! <= 0;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isOutOfStock ? Colors.red.withOpacity(0.05) : Colors.orange.withOpacity(0.05), color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(color: isOutOfStock ? Colors.red.withOpacity(0.2) : Colors.orange.withOpacity(0.2)),
), ),
child: Row( child: const Center(
children: [ child: Text(
Container( 'All products are adequately stocked.',
padding: const EdgeInsets.all(8), style: TextStyle(color: Colors.grey),
decoration: BoxDecoration( ),
color: isOutOfStock ? Colors.red.withOpacity(0.1) : Colors.orange.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: isOutOfStock ? Colors.red : Colors.orange,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name, style: const TextStyle(fontWeight: FontWeight.bold)),
if (product.sku != null && product.sku!.isNotEmpty)
Text('SKU: ${product.sku}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${product.currentStock} left',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isOutOfStock ? Colors.red : Colors.orange.shade800,
),
),
Text(
'Min: ${product.minStock ?? 0}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
],
),
],
), ),
); );
}, }
);
}, return ListView.separated(
loading: () => const Center(child: CircularProgressIndicator()), shrinkWrap: true,
error: (error, stack) => Center(child: Text('Error: $error')), physics: const NeverScrollableScrollPhysics(),
); itemCount: lowStockProducts.length > 5
}, ? 5
), : lowStockProducts.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
itemBuilder: (context, index) {
final product = lowStockProducts[index];
final isOutOfStock = product.currentStock! <= 0;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isOutOfStock
? Colors.red.withOpacity(0.05)
: Colors.orange.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isOutOfStock
? Colors.red.withOpacity(0.2)
: Colors.orange.withOpacity(0.2),
),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: isOutOfStock
? Colors.red.withOpacity(0.1)
: Colors.orange.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: isOutOfStock
? Colors.red
: Colors.orange,
size: 20,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
product.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
if (product.sku != null &&
product.sku!.isNotEmpty)
Text(
'SKU: ${product.sku}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${product.currentStock} left',
style: TextStyle(
fontWeight: FontWeight.bold,
color: isOutOfStock
? Colors.red
: Colors.orange.shade800,
),
),
Text(
'Out of stock',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
],
),
);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, stack) =>
Center(child: Text('Error: $error')),
);
},
),
],
], ],
], ),
), ),
); );
} }
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) { Widget _buildActionCard(
BuildContext context,
String title,
String subtitle,
IconData icon,
Color color,
VoidCallback onTap,
) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
@@ -280,9 +382,19 @@ class BusinessHubScreen extends ConsumerWidget {
children: [ children: [
Icon(icon, color: color, size: 28), Icon(icon, color: color, size: 28),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)), Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: color.withOpacity(0.7), fontSize: 12)), Text(
subtitle,
style: TextStyle(color: color.withOpacity(0.7), fontSize: 12),
),
], ],
), ),
), ),

View File

@@ -13,9 +13,7 @@ class ReportsScreen extends ConsumerWidget {
final formatCurrency = NumberFormat.currency(symbol: ''); final formatCurrency = NumberFormat.currency(symbol: '');
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Business Reports')),
title: const Text('Business Reports'),
),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@@ -29,7 +27,10 @@ class ReportsScreen extends ConsumerWidget {
content: valuationState.when( content: valuationState.when(
data: (val) => Text( data: (val) => Text(
'Total Estimated Value: ${formatCurrency.format(val)}', 'Total Estimated Value: ${formatCurrency.format(val)}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator(),
error: (e, s) => Text('Error: $e'), error: (e, s) => Text('Error: $e'),
@@ -41,7 +42,9 @@ class ReportsScreen extends ConsumerWidget {
title: 'GST Report (Coming Soon)', title: 'GST Report (Coming Soon)',
icon: LucideIcons.fileText, icon: LucideIcons.fileText,
color: Colors.orange, color: Colors.orange,
content: const Text('Export GSTR-1 & GSTR-3B formats based on invoices.'), content: const Text(
'Export GSTR-1 & GSTR-3B formats based on invoices.',
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildReportCard( _buildReportCard(
@@ -57,7 +60,8 @@ class ReportsScreen extends ConsumerWidget {
); );
} }
Widget _buildReportCard(BuildContext context, { Widget _buildReportCard(
BuildContext context, {
required String title, required String title,
required IconData icon, required IconData icon,
required Color color, required Color color,
@@ -81,7 +85,13 @@ class ReportsScreen extends ConsumerWidget {
child: Icon(icon, color: color), child: Icon(icon, color: color),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@@ -7,10 +7,12 @@ class BusinessSettingsScreen extends ConsumerStatefulWidget {
const BusinessSettingsScreen({super.key}); const BusinessSettingsScreen({super.key});
@override @override
ConsumerState<BusinessSettingsScreen> createState() => _BusinessSettingsScreenState(); ConsumerState<BusinessSettingsScreen> createState() =>
_BusinessSettingsScreenState();
} }
class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen> { class _BusinessSettingsScreenState
extends ConsumerState<BusinessSettingsScreen> {
bool _taxIncludedInPrice = false; bool _taxIncludedInPrice = false;
bool _salesEnabled = true; bool _salesEnabled = true;
bool _inventoryEnabled = true; bool _inventoryEnabled = true;
@@ -48,11 +50,20 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
body: ListView( body: ListView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
children: [ children: [
const Text('Module Configuration', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)), const Text(
'Module Configuration',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
title: const Text('Inventory Management'), title: const Text('Inventory Management'),
subtitle: const Text('Track stock levels, multiple locations, and products'), subtitle: const Text(
'Track stock levels, multiple locations, and products',
),
value: _inventoryEnabled, value: _inventoryEnabled,
onChanged: (val) { onChanged: (val) {
setState(() => _inventoryEnabled = val); setState(() => _inventoryEnabled = val);
@@ -62,7 +73,9 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
), ),
SwitchListTile( SwitchListTile(
title: const Text('Sales & POS'), title: const Text('Sales & POS'),
subtitle: const Text('Enable point of sale, invoicing, and receivables'), subtitle: const Text(
'Enable point of sale, invoicing, and receivables',
),
value: _salesEnabled, value: _salesEnabled,
onChanged: (val) { onChanged: (val) {
setState(() => _salesEnabled = val); setState(() => _salesEnabled = val);
@@ -72,7 +85,9 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
), ),
SwitchListTile( SwitchListTile(
title: const Text('Vendor & Purchases'), title: const Text('Vendor & Purchases'),
subtitle: const Text('Manage vendors, purchase orders, and inward stock'), subtitle: const Text(
'Manage vendors, purchase invoices, and inward stock',
),
value: _purchaseEnabled, value: _purchaseEnabled,
onChanged: (val) { onChanged: (val) {
setState(() => _purchaseEnabled = val); setState(() => _purchaseEnabled = val);
@@ -81,64 +96,105 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
secondary: const Icon(LucideIcons.truck), secondary: const Icon(LucideIcons.truck),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('Inventory Strategy', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)), const Text(
'Inventory Strategy',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Consumer( Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final featureState = ref.watch(businessFeatureProvider); final featureState = ref.watch(businessFeatureProvider);
final currentStrategy = featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY'; final currentStrategy =
final deductParentStock = currentStrategy == 'PARENT_AND_COMPONENTS'; featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY';
final stockDeductionOnInvoice = featureState.value?.stockDeductionOnInvoice ?? true; final deductParentStock =
currentStrategy == 'PARENT_AND_COMPONENTS';
final stockDeductionOnInvoice =
featureState.value?.stockDeductionOnInvoice ?? true;
return Column( return Column(
children: [ children: [
SwitchListTile( SwitchListTile(
title: const Text('Deduct Parent Stock on BOM Sale'), title: const Text('Deduct Parent Stock on BOM Sale'),
subtitle: const Text('If enabled, selling an assembled product reduces both parent and component stock.'), subtitle: const Text(
'If enabled, selling an assembled product reduces both parent and component stock.',
),
value: deductParentStock, value: deductParentStock,
secondary: const Icon(LucideIcons.gitMerge), secondary: const Icon(LucideIcons.gitMerge),
onChanged: (val) { onChanged: (val) {
if (featureState.value != null) { if (featureState.value != null) {
final newStrategy = val ? 'PARENT_AND_COMPONENTS' : 'COMPONENTS_ONLY'; final newStrategy = val
final updated = featureState.value!.copyWith(bomReductionStrategy: newStrategy); ? 'PARENT_AND_COMPONENTS'
ref.read(businessFeatureProvider.notifier).updateFeatures(updated); : 'COMPONENTS_ONLY';
final updated = featureState.value!.copyWith(
bomReductionStrategy: newStrategy,
);
ref
.read(businessFeatureProvider.notifier)
.updateFeatures(updated);
} }
}, },
), ),
SwitchListTile( SwitchListTile(
title: const Text('Stock Deduction on Invoice'), title: const Text('Stock Deduction on Invoice'),
subtitle: const Text('If enabled, finalizing an invoice automatically deducts product stock. Turn off for a two-step fulfillment process.'), subtitle: const Text(
'If enabled, finalizing an invoice automatically deducts product stock. Turn off for a two-step fulfillment process.',
),
value: stockDeductionOnInvoice, value: stockDeductionOnInvoice,
secondary: const Icon(LucideIcons.boxes), secondary: const Icon(LucideIcons.boxes),
onChanged: (val) { onChanged: (val) {
if (featureState.value != null) { if (featureState.value != null) {
final updated = featureState.value!.copyWith(stockDeductionOnInvoice: val); final updated = featureState.value!.copyWith(
ref.read(businessFeatureProvider.notifier).updateFeatures(updated); stockDeductionOnInvoice: val,
);
ref
.read(businessFeatureProvider.notifier)
.updateFeatures(updated);
} }
}, },
), ),
SwitchListTile( SwitchListTile(
title: const Text('Use EAN/Barcode for Scanner'), title: const Text('Use EAN/Barcode for Scanner'),
subtitle: const Text('If disabled, the barcode scanner will search using the SKU field instead.'), subtitle: const Text(
value: (featureState.value?.barcodeSource ?? 'SKU') == 'BARCODE', 'If disabled, the barcode scanner will search using the SKU field instead.',
),
value:
(featureState.value?.barcodeSource ?? 'SKU') ==
'BARCODE',
secondary: const Icon(LucideIcons.scanLine), secondary: const Icon(LucideIcons.scanLine),
onChanged: (val) { onChanged: (val) {
if (featureState.value != null) { if (featureState.value != null) {
final updated = featureState.value!.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU'); final updated = featureState.value!.copyWith(
ref.read(businessFeatureProvider.notifier).updateFeatures(updated); barcodeSource: val ? 'BARCODE' : 'SKU',
);
ref
.read(businessFeatureProvider.notifier)
.updateFeatures(updated);
} }
}, },
), ),
], ],
); );
} },
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('Pricing & Taxation', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.grey)), const Text(
'Pricing & Taxation',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.grey,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SwitchListTile( SwitchListTile(
title: const Text('Selling Price includes Tax'), title: const Text('Selling Price includes Tax'),
subtitle: const Text('If enabled, GST is assumed to be inclusive in the entered selling price.'), subtitle: const Text(
'If enabled, GST is assumed to be inclusive in the entered selling price.',
),
value: _taxIncludedInPrice, value: _taxIncludedInPrice,
onChanged: (val) { onChanged: (val) {
setState(() => _taxIncludedInPrice = val); setState(() => _taxIncludedInPrice = val);
@@ -154,7 +210,9 @@ class _BusinessSettingsScreenState extends ConsumerState<BusinessSettingsScreen>
void _saveSettings() { void _saveSettings() {
final profileState = ref.read(businessProfileProvider).value; final profileState = ref.read(businessProfileProvider).value;
if (profileState != null) { if (profileState != null) {
final updated = profileState.copyWith(taxIncludedInPrice: _taxIncludedInPrice); final updated = profileState.copyWith(
taxIncludedInPrice: _taxIncludedInPrice,
);
ref.read(businessProfileProvider.notifier).updateProfile(updated); ref.read(businessProfileProvider.notifier).updateProfile(updated);
} }
} }

View File

@@ -35,9 +35,10 @@ class BusinessProfileNotifier extends AsyncNotifier<BusinessProfile?> {
} }
} }
final businessProfileProvider = AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() { final businessProfileProvider =
return BusinessProfileNotifier(); AsyncNotifierProvider<BusinessProfileNotifier, BusinessProfile?>(() {
}); return BusinessProfileNotifier();
});
class BusinessFeatureNotifier extends AsyncNotifier<BusinessFeature?> { class BusinessFeatureNotifier extends AsyncNotifier<BusinessFeature?> {
@override @override
@@ -74,6 +75,7 @@ class BusinessFeatureNotifier extends AsyncNotifier<BusinessFeature?> {
} }
} }
final businessFeatureProvider = AsyncNotifierProvider<BusinessFeatureNotifier, BusinessFeature?>(() { final businessFeatureProvider =
return BusinessFeatureNotifier(); AsyncNotifierProvider<BusinessFeatureNotifier, BusinessFeature?>(() {
}); return BusinessFeatureNotifier();
});

View File

@@ -9,17 +9,27 @@ class IndianStatesNotifier extends AsyncNotifier<List<IndianState>> {
} }
Future<List<IndianState>> _fetchStates() async { Future<List<IndianState>> _fetchStates() async {
final response = await DioClient().dio.get('/master/states').timeout( final response = await DioClient().dio
const Duration(seconds: 5), .get('/master/states')
onTimeout: () => throw Exception('Connection timed out'), .timeout(
); const Duration(seconds: 5),
onTimeout: () => throw Exception('Connection timed out'),
);
if (response.data == null || response.data.toString().isEmpty) { if (response.data == null || response.data.toString().isEmpty) {
return []; return [];
} }
return (response.data as List).map((e) => IndianState.fromJson(e)).toList(); final list = (response.data as List).map((e) => IndianState.fromJson(e)).toList();
final Map<String, IndianState> uniqueStates = {};
for (var state in list) {
uniqueStates[state.gstCode] = state;
}
final result = uniqueStates.values.toList();
result.sort((a, b) => a.name.compareTo(b.name));
return result;
} }
} }
final indianStatesProvider = AsyncNotifierProvider<IndianStatesNotifier, List<IndianState>>(() { final indianStatesProvider =
return IndianStatesNotifier(); AsyncNotifierProvider<IndianStatesNotifier, List<IndianState>>(() {
}); return IndianStatesNotifier();
});

View File

@@ -21,14 +21,19 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
void _showFilterSheet() { void _showFilterSheet() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) { builder: (ctx) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text('Filter Ledger', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const Text(
'Filter Ledger',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
title: const Text('Today'), title: const Text('Today'),
@@ -95,7 +100,13 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
data: (transactions) { data: (transactions) {
// 1. Filter all transactions related to this wallet // 1. Filter all transactions related to this wallet
final walletTxs = transactions.where((t) => t.fromWalletId == widget.wallet.id || t.toWalletId == widget.wallet.id).toList(); final walletTxs = transactions
.where(
(t) =>
t.fromWalletId == widget.wallet.id ||
t.toWalletId == widget.wallet.id,
)
.toList();
// Sort chronologically (oldest first) to compute running balance // Sort chronologically (oldest first) to compute running balance
walletTxs.sort((a, b) => a.date.compareTo(b.date)); walletTxs.sort((a, b) => a.date.compareTo(b.date));
@@ -110,9 +121,17 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
} else if (_selectedFilter == 'Current Month') { } else if (_selectedFilter == 'Current Month') {
startDate = DateTime(now.year, now.month, 1); startDate = DateTime(now.year, now.month, 1);
endDate = DateTime(now.year, now.month + 1, 0, 23, 59, 59); endDate = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
} else if (_selectedFilter == 'Custom Range' && _customDateRange != null) { } else if (_selectedFilter == 'Custom Range' &&
_customDateRange != null) {
startDate = _customDateRange!.start; startDate = _customDateRange!.start;
endDate = DateTime(_customDateRange!.end.year, _customDateRange!.end.month, _customDateRange!.end.day, 23, 59, 59); endDate = DateTime(
_customDateRange!.end.year,
_customDateRange!.end.month,
_customDateRange!.end.day,
23,
59,
59,
);
} }
// 3. Calculate opening balance (sum of all transactions BEFORE start date) // 3. Calculate opening balance (sum of all transactions BEFORE start date)
@@ -142,25 +161,41 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
// Add Opening Balance Row if we have a date filter // Add Opening Balance Row if we have a date filter
if (startDate != null) { if (startDate != null) {
rows.add(DataRow( rows.add(
cells: [ DataRow(
const DataCell(Text('-')), cells: [
DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))), const DataCell(Text('-')),
DataCell(Text('Rs. ${openingBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))),
const DataCell(Text('-')), DataCell(
const DataCell(Text('-')), Text(
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), 'Rs. ${openingBalance.toStringAsFixed(2)}',
], style: const TextStyle(fontWeight: FontWeight.bold),
)); ),
),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(
Text(
'Rs. ${runningBalance.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
} }
for (int i = 0; i < visibleTxs.length; i++) { for (int i = 0; i < visibleTxs.length; i++) {
final t = visibleTxs[i]; final t = visibleTxs[i];
bool isIncome = t.toWalletId == widget.wallet.id && t.fromWalletId == null; bool isIncome =
bool isExpense = t.fromWalletId == widget.wallet.id && t.toWalletId == null; t.toWalletId == widget.wallet.id && t.fromWalletId == null;
bool isTransferIn = t.toWalletId == widget.wallet.id && t.fromWalletId != null; bool isExpense =
bool isTransferOut = t.fromWalletId == widget.wallet.id && t.toWalletId != null; t.fromWalletId == widget.wallet.id && t.toWalletId == null;
bool isTransferIn =
t.toWalletId == widget.wallet.id && t.fromWalletId != null;
bool isTransferOut =
t.fromWalletId == widget.wallet.id && t.toWalletId != null;
double amount = t.amount; double amount = t.amount;
if (isExpense || isTransferOut) { if (isExpense || isTransferOut) {
@@ -174,7 +209,12 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
if (isIncome || isTransferIn) { if (isIncome || isTransferIn) {
if (t.fromWalletId != null && walletsState.hasValue) { if (t.fromWalletId != null && walletsState.hasValue) {
fromName = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull?.name ?? 'Unknown'; fromName =
walletsState.value!
.where((w) => w.id == t.fromWalletId)
.firstOrNull
?.name ??
'Unknown';
} else { } else {
fromName = 'External'; fromName = 'External';
} }
@@ -182,29 +222,53 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
} else if (isExpense || isTransferOut) { } else if (isExpense || isTransferOut) {
fromName = widget.wallet.name; fromName = widget.wallet.name;
if (t.toWalletId != null && walletsState.hasValue) { if (t.toWalletId != null && walletsState.hasValue) {
toName = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull?.name ?? 'Unknown'; toName =
walletsState.value!
.where((w) => w.id == t.toWalletId)
.firstOrNull
?.name ??
'Unknown';
} else { } else {
toName = 'External'; toName = 'External';
} }
} }
Color amountColor = (isExpense || isTransferOut) ? Colors.red : Colors.green.shade700; Color amountColor = (isExpense || isTransferOut)
? Colors.red
: Colors.green.shade700;
String amountPrefix = (isExpense || isTransferOut) ? '-' : '+'; String amountPrefix = (isExpense || isTransferOut) ? '-' : '+';
rows.add(DataRow( rows.add(
cells: [ DataRow(
DataCell(Text('${startDate != null ? i + 1 : i + 1}')), cells: [
DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))), DataCell(Text('${startDate != null ? i + 1 : i + 1}')),
DataCell(Text('$amountPrefix Rs. ${amount.toStringAsFixed(2)}', style: TextStyle(color: amountColor, fontWeight: FontWeight.w600))), DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))),
DataCell(Text(fromName)), DataCell(
DataCell(Text(toName)), Text(
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))), '$amountPrefix Rs. ${amount.toStringAsFixed(2)}',
], style: TextStyle(
)); color: amountColor,
fontWeight: FontWeight.w600,
),
),
),
DataCell(Text(fromName)),
DataCell(Text(toName)),
DataCell(
Text(
'Rs. ${runningBalance.toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
} }
if (rows.isEmpty) { if (rows.isEmpty) {
return const Center(child: Text('No transactions found for this period.')); return const Center(
child: Text('No transactions found for this period.'),
);
} }
return Column( return Column(
@@ -214,8 +278,17 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Filter: $_selectedFilter', style: TextStyle(color: Colors.grey.shade600)), Text(
Text('Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), 'Filter: $_selectedFilter',
style: TextStyle(color: Colors.grey.shade600),
),
Text(
'Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
], ],
), ),
), ),
@@ -225,15 +298,47 @@ class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), headingRowColor: WidgetStateProperty.resolveWith(
(states) => Colors.grey.shade100,
),
columnSpacing: 24, columnSpacing: 24,
columns: const [ columns: const [
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), label: Text(
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), 'S.No',
DataColumn(label: Text('From', style: TextStyle(fontWeight: FontWeight.bold))), style: TextStyle(fontWeight: FontWeight.bold),
DataColumn(label: Text('To', style: TextStyle(fontWeight: FontWeight.bold))), ),
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))), ),
DataColumn(
label: Text(
'Date',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Amount',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'From',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'To',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Balance',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
], ],
rows: rows, rows: rows,
), ),

View File

@@ -23,22 +23,30 @@ class BudgetStatusCard extends ConsumerWidget {
} }
final now = DateTime.now(); final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year); final currentMonthTxs = transactions.where(
(t) => t.date.month == now.month && t.date.year == now.year,
);
double totalSpent = 0; double totalSpent = 0;
for (var b in budgets) { for (var b in budgets) {
if (b.walletId != null) { if (b.walletId != null) {
final spentForWallet = currentMonthTxs.where((t) => t.toWalletId == b.walletId).fold(0.0, (s, t) => s + t.amount); final spentForWallet = currentMonthTxs
.where((t) => t.toWalletId == b.walletId)
.fold(0.0, (s, t) => s + t.amount);
totalSpent += spentForWallet; totalSpent += spentForWallet;
} }
} }
final totalLimit = budgets.fold(0.0, (s, b) => s + b.monthlyLimit); final totalLimit = budgets.fold(0.0, (s, b) => s + b.monthlyLimit);
final progress = totalLimit > 0 ? (totalSpent / totalLimit).clamp(0.0, 1.0) : 0.0; final progress = totalLimit > 0
? (totalSpent / totalLimit).clamp(0.0, 1.0)
: 0.0;
Color progressColor = Colors.green; Color progressColor = Colors.green;
if (progress > 0.9) progressColor = Colors.red; if (progress > 0.9)
else if (progress > 0.7) progressColor = Colors.orange; progressColor = Colors.red;
else if (progress > 0.7)
progressColor = Colors.orange;
return Card( return Card(
child: InkWell( child: InkWell(
@@ -55,7 +63,10 @@ class BudgetStatusCard extends ConsumerWidget {
children: [ children: [
Icon(LucideIcons.target, size: 18), Icon(LucideIcons.target, size: 18),
SizedBox(width: 8), SizedBox(width: 8),
Text('Overall Budget Status', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Text(
'Overall Budget Status',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -70,10 +81,16 @@ class BudgetStatusCard extends ConsumerWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('Spent: Rs. ${totalSpent.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)), Text(
Text('Limit: Rs. ${totalLimit.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold)), 'Spent: Rs. ${totalSpent.toStringAsFixed(0)}',
style: TextStyle(color: Colors.grey.shade700),
),
Text(
'Limit: Rs. ${totalLimit.toStringAsFixed(0)}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
], ],
) ),
], ],
), ),
), ),

View File

@@ -28,9 +28,16 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
final Map<int, double> categoryTotals = {}; final Map<int, double> categoryTotals = {};
for (var t in widget.transactions) { for (var t in widget.transactions) {
bool matches = false; bool matches = false;
if (widget.selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); if (widget.selectedNature == 'INCOME')
else if (widget.selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); matches =
else if (widget.selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT'; t.type == 'INCOME' ||
(t.type == 'TRANSFER' && t.fromWalletId == null);
else if (widget.selectedNature == 'EXPENSE')
matches =
t.type == 'EXPENSE' ||
(t.type == 'TRANSFER' && t.toWalletId == null);
else if (widget.selectedNature == 'INVESTMENTS')
matches = t.type == 'INVESTMENT';
if (matches) { if (matches) {
final catId = t.categoryId ?? -1; final catId = t.categoryId ?? -1;
@@ -42,8 +49,9 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
return const SizedBox(); return const SizedBox();
} }
final List<MapEntry<int, double>> sortedTotals = categoryTotals.entries.toList() final List<MapEntry<int, double>> sortedTotals =
..sort((a, b) => b.value.compareTo(a.value)); categoryTotals.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final List<Color> colors = NatureColors.getPalette(widget.selectedNature); final List<Color> colors = NatureColors.getPalette(widget.selectedNature);
@@ -77,9 +85,20 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.1), blurRadius: 4)], boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: isDark ? 0.3 : 0.1,
),
blurRadius: 4,
),
],
),
child: Icon(
Icons.touch_app,
size: 16,
color: colors[i % colors.length],
), ),
child: Icon(Icons.touch_app, size: 16, color: colors[i % colors.length]),
) )
: null, : null,
badgePositionPercentageOffset: 1.1, badgePositionPercentageOffset: 1.1,
@@ -89,21 +108,39 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2))), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
side: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2),
),
),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: isDark ? [Theme.of(context).cardColor, Theme.of(context).cardColor.withOpacity(0.8)] : [Colors.white, Colors.grey.shade50], colors: isDark
? [
Theme.of(context).cardColor,
Theme.of(context).cardColor.withOpacity(0.8),
]
: [Colors.white, Colors.grey.shade50],
), ),
), ),
padding: const EdgeInsets.all(28.0), padding: const EdgeInsets.all(28.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5, color: Theme.of(context).textTheme.bodyLarge?.color)), Text(
'Category Breakdown',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( SizedBox(
@@ -116,11 +153,15 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
pieTouchData: PieTouchData( pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) { touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() { setState(() {
if (!event.isInterestedForInteractions || pieTouchResponse == null || pieTouchResponse.touchedSection == null) { if (!event.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse.touchedSection == null) {
touchedIndex = -1; touchedIndex = -1;
return; return;
} }
touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex; touchedIndex = pieTouchResponse
.touchedSection!
.touchedSectionIndex;
}); });
}, },
), ),
@@ -134,8 +175,22 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
Column( Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Total', style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w600)), Text(
Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Theme.of(context).textTheme.bodyLarge?.color)), 'Total',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
fontWeight: FontWeight.w600,
),
),
Text(
'Rs. ${totalAmount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
], ],
), ),
], ],
@@ -143,38 +198,62 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).textTheme.bodyLarge?.color)), Text(
'Details',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Modern List for Legends // Modern List for Legends
...List.generate(sortedTotals.length, (i) { ...List.generate(sortedTotals.length, (i) {
String categoryName = 'Uncategorized'; String categoryName = 'Uncategorized';
if (sortedTotals[i].key != -1) { if (sortedTotals[i].key != -1) {
final match = widget.categories.where((c) => c.id == sortedTotals[i].key); final match = widget.categories.where(
(c) => c.id == sortedTotals[i].key,
);
if (match.isNotEmpty) categoryName = match.first.name; if (match.isNotEmpty) categoryName = match.first.name;
} }
final amount = sortedTotals[i].value; final amount = sortedTotals[i].value;
final percentage = (amount / totalAmount * 100).toStringAsFixed(1); final percentage = (amount / totalAmount * 100).toStringAsFixed(
1,
);
final isTouched = i == touchedIndex; final isTouched = i == touchedIndex;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : (isDark ? Theme.of(context).scaffoldBackgroundColor : Colors.white), color: isTouched
? colors[i % colors.length].withValues(alpha: 0.05)
: (isDark
? Theme.of(context).scaffoldBackgroundColor
: Colors.white),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.05), color: isTouched
? colors[i % colors.length].withValues(alpha: 0.3)
: Theme.of(
context,
).dividerColor.withOpacity(isDark ? 0.1 : 0.05),
width: isTouched ? 1.5 : 1.0, width: isTouched ? 1.5 : 1.0,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: isTouched ? 0.05 : 0.02), color: Colors.black.withValues(
alpha: isTouched ? 0.05 : 0.02,
),
blurRadius: isTouched ? 12 : 6, blurRadius: isTouched ? 12 : 6,
offset: const Offset(0, 4), offset: const Offset(0, 4),
) ),
], ],
), ),
child: Row( child: Row(
@@ -184,10 +263,16 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.15), color: colors[i % colors.length].withValues(
alpha: 0.15,
),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.category, color: colors[i % colors.length], size: 20), child: Icon(
Icons.category,
color: colors[i % colors.length],
size: 20,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -196,19 +281,45 @@ class _CategorySpendingChartState extends State<CategorySpendingChart> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Theme.of(context).textTheme.bodyLarge?.color)), Text(
categoryName,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade500)), Text(
'Rs. ${amount.toStringAsFixed(0)}',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
color: Colors.grey.shade500,
),
),
], ],
), ),
), ),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.1), color: colors[i % colors.length].withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text('$percentage%', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: colors[i % colors.length])), child: Text(
'$percentage%',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 13,
color: colors[i % colors.length],
),
),
), ),
], ],
), ),

View File

@@ -23,31 +23,50 @@ class DayWiseSpendingChart extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 1. Determine date range length // 1. Determine date range length
final int daysCount = endDate.difference(startDate).inDays + 1; final int daysCount = endDate.difference(startDate).inDays + 1;
final int displayDays = daysCount > 0 && daysCount < 1000 ? daysCount : 30; // Guard against 'All Time' returning thousands final int displayDays = daysCount > 0 && daysCount < 1000
? daysCount
: 30; // Guard against 'All Time' returning thousands
final days = List.generate(displayDays, (i) => startDate.add(Duration(days: i))); final days = List.generate(
displayDays,
(i) => startDate.add(Duration(days: i)),
);
// 2. Initialize daily totals // 2. Initialize daily totals
final Map<String, double> dailyTotals = {}; final Map<String, double> dailyTotals = {};
for (var d in days) { for (var d in days) {
dailyTotals["${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"] = 0.0; dailyTotals["${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"] =
0.0;
} }
// 3. Populate totals // 3. Populate totals
for (var t in transactions) { for (var t in transactions) {
final tDate = "${t.date.year}-${t.date.month.toString().padLeft(2, '0')}-${t.date.day.toString().padLeft(2, '0')}"; final tDate =
"${t.date.year}-${t.date.month.toString().padLeft(2, '0')}-${t.date.day.toString().padLeft(2, '0')}";
if (dailyTotals.containsKey(tDate)) { if (dailyTotals.containsKey(tDate)) {
bool matches = false; bool matches = false;
if (selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null); if (selectedNature == 'INCOME')
else if (selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null); matches =
else if (selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT'; t.type == 'INCOME' ||
else if (selectedNature == 'PAYABLES' || selectedNature == 'RECEIVABLES') { (t.type == 'TRANSFER' && t.fromWalletId == null);
else if (selectedNature == 'EXPENSE')
matches =
t.type == 'EXPENSE' ||
(t.type == 'TRANSFER' && t.toWalletId == null);
else if (selectedNature == 'INVESTMENTS')
matches = t.type == 'INVESTMENT';
else if (selectedNature == 'PAYABLES' ||
selectedNature == 'RECEIVABLES') {
if (t.type == 'TRANSFER' && t.toWalletId != null) { if (t.type == 'TRANSFER' && t.toWalletId != null) {
final w = wallets.where((w) => w.id == t.toWalletId); final w = wallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) { if (w.isNotEmpty) {
final nature = w.first.nature; final nature = w.first.nature;
if (selectedNature == 'PAYABLES' && (nature == 'PAYABLES' || nature == 'LOAN')) matches = true; if (selectedNature == 'PAYABLES' &&
if (selectedNature == 'RECEIVABLES' && (nature == 'RECEIVABLES' || nature == 'LENDING')) matches = true; (nature == 'PAYABLES' || nature == 'LOAN'))
matches = true;
if (selectedNature == 'RECEIVABLES' &&
(nature == 'RECEIVABLES' || nature == 'LENDING'))
matches = true;
} }
} }
} }
@@ -69,19 +88,26 @@ class DayWiseSpendingChart extends StatelessWidget {
// Make chart horizontally scrollable if there are many days // Make chart horizontally scrollable if there are many days
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
double chartWidth = (screenWidth / 7) * displayDays; double chartWidth = (screenWidth / 7) * displayDays;
if (chartWidth < screenWidth - 80) { // minimum width if (chartWidth < screenWidth - 80) {
// minimum width
chartWidth = screenWidth - 80; chartWidth = screenWidth - 80;
} }
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(color: Colors.grey.shade200),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text('Spending Trends', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text(
'Spending Trends',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 32), const SizedBox(height: 32),
SingleChildScrollView( SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -100,51 +126,73 @@ class DayWiseSpendingChart extends StatelessWidget {
showTitles: true, showTitles: true,
getTitlesWidget: (value, meta) { getTitlesWidget: (value, meta) {
final index = value.toInt(); final index = value.toInt();
if (index < 0 || index >= days.length) return const SizedBox(); if (index < 0 || index >= days.length)
return const SizedBox();
final date = days[index]; final date = days[index];
final text = "${date.day} ${_monthStr(date.month)}"; final text = "${date.day} ${_monthStr(date.month)}";
// To prevent overcrowding on large ranges // To prevent overcrowding on large ranges
if (displayDays > 14 && index % 2 != 0) return const SizedBox(); if (displayDays > 14 && index % 2 != 0)
if (displayDays > 30 && index % 5 != 0) return const SizedBox(); return const SizedBox();
if (displayDays > 30 && index % 5 != 0)
return const SizedBox();
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 10, fontWeight: FontWeight.bold)), child: Text(
text,
style: const TextStyle(
color: Colors.grey,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
); );
}, },
reservedSize: 28, reservedSize: 28,
), ),
), ),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles(
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), sideTitles: SideTitles(showTitles: false),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
), ),
gridData: FlGridData( gridData: FlGridData(
show: true, show: true,
drawVerticalLine: false, drawVerticalLine: false,
horizontalInterval: maxY / 4 == 0 ? 1 : maxY / 4, horizontalInterval: maxY / 4 == 0 ? 1 : maxY / 4,
getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.shade200, strokeWidth: 1, dashArray: [4, 4]), getDrawingHorizontalLine: (value) => FlLine(
color: Colors.grey.shade200,
strokeWidth: 1,
dashArray: [4, 4],
),
), ),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
barGroups: dailyTotals.entries.toList().asMap().entries.map((entry) { barGroups: dailyTotals.entries.toList().asMap().entries.map(
return BarChartGroupData( (entry) {
x: entry.key, return BarChartGroupData(
barRods: [ x: entry.key,
BarChartRodData( barRods: [
toY: entry.value.value, BarChartRodData(
color: barColor, toY: entry.value.value,
width: 16, color: barColor,
borderRadius: BorderRadius.circular(8), width: 16,
backDrawRodData: BackgroundBarChartRodData( borderRadius: BorderRadius.circular(8),
show: true, backDrawRodData: BackgroundBarChartRodData(
toY: maxY * 1.2, show: true,
color: Colors.grey.shade100, toY: maxY * 1.2,
color: Colors.grey.shade100,
),
), ),
), ],
], );
); },
}).toList(), ).toList(),
), ),
), ),
), ),
@@ -156,7 +204,20 @@ class DayWiseSpendingChart extends StatelessWidget {
} }
String _monthStr(int m) { String _monthStr(int m) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
return months[m - 1]; return months[m - 1];
} }
} }

View File

@@ -38,11 +38,21 @@ class StatisticsTab extends StatefulWidget {
class _StatisticsTabState extends State<StatisticsTab> { class _StatisticsTabState extends State<StatisticsTab> {
String _selectedNature = 'EXPENSE'; String _selectedNature = 'EXPENSE';
Widget _buildTotalCard(String title, double amount, Color color, IconData icon) { Widget _buildTotalCard(
String title,
double amount,
Color color,
IconData icon,
) {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return Card( return Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2))), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@@ -51,13 +61,35 @@ class _StatisticsTabState extends State<StatisticsTab> {
children: [ children: [
Row( Row(
children: [ children: [
CircleAvatar(backgroundColor: color.withOpacity(0.1), radius: 16, child: Icon(icon, color: color, size: 16)), CircleAvatar(
backgroundColor: color.withOpacity(0.1),
radius: 16,
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)), Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)), Text(
'Rs. ${amount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: color,
),
),
], ],
), ),
), ),
@@ -67,9 +99,23 @@ class _StatisticsTabState extends State<StatisticsTab> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Compute totals // Compute totals
final totalIncome = widget.transactions.where((t) => t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null)).fold(0.0, (s, t) => s + t.amount); final totalIncome = widget.transactions
final totalExpense = widget.transactions.where((t) => t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null)).fold(0.0, (s, t) => s + t.amount); .where(
final totalInvestment = widget.transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount); (t) =>
t.type == 'INCOME' ||
(t.type == 'TRANSFER' && t.fromWalletId == null),
)
.fold(0.0, (s, t) => s + t.amount);
final totalExpense = widget.transactions
.where(
(t) =>
t.type == 'EXPENSE' ||
(t.type == 'TRANSFER' && t.toWalletId == null),
)
.fold(0.0, (s, t) => s + t.amount);
final totalInvestment = widget.transactions
.where((t) => t.type == 'INVESTMENT')
.fold(0.0, (s, t) => s + t.amount);
double totalPayablesPeriod = 0.0; double totalPayablesPeriod = 0.0;
double totalReceivablesPeriod = 0.0; double totalReceivablesPeriod = 0.0;
@@ -124,10 +170,16 @@ class _StatisticsTabState extends State<StatisticsTab> {
), ),
if (widget.selectedFilter == 'Custom') if (widget.selectedFilter == 'Custom')
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text( child: Text(
'${widget.startDate.day} ${widget.startDate.month} - ${widget.endDate.day} ${widget.endDate.month}', '${widget.startDate.day} ${widget.startDate.month} - ${widget.endDate.day} ${widget.endDate.month}',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey,
),
), ),
), ),
@@ -139,9 +191,18 @@ class _StatisticsTabState extends State<StatisticsTab> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Analytics', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), const Text(
'Analytics',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 0,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: barColor.withOpacity(0.1), color: barColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -150,13 +211,31 @@ class _StatisticsTabState extends State<StatisticsTab> {
value: _selectedNature, value: _selectedNature,
underline: const SizedBox(), underline: const SizedBox(),
icon: Icon(Icons.arrow_drop_down, color: barColor), icon: Icon(Icons.arrow_drop_down, color: barColor),
style: TextStyle(color: barColor, fontWeight: FontWeight.bold), style: TextStyle(
color: barColor,
fontWeight: FontWeight.bold,
),
items: const [ items: const [
DropdownMenuItem(value: 'EXPENSE', child: Text('Expense')), DropdownMenuItem(
DropdownMenuItem(value: 'INCOME', child: Text('Income')), value: 'EXPENSE',
DropdownMenuItem(value: 'PAYABLES', child: Text('Payables')), child: Text('Expense'),
DropdownMenuItem(value: 'RECEIVABLES', child: Text('Receivables')), ),
DropdownMenuItem(value: 'INVESTMENTS', child: Text('Investments')), DropdownMenuItem(
value: 'INCOME',
child: Text('Income'),
),
DropdownMenuItem(
value: 'PAYABLES',
child: Text('Payables'),
),
DropdownMenuItem(
value: 'RECEIVABLES',
child: Text('Receivables'),
),
DropdownMenuItem(
value: 'INVESTMENTS',
child: Text('Investments'),
),
], ],
onChanged: (val) { onChanged: (val) {
if (val != null) { if (val != null) {
@@ -172,10 +251,24 @@ class _StatisticsTabState extends State<StatisticsTab> {
if (widget.transactions.isEmpty) if (widget.transactions.isEmpty)
Card( Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Theme.of(context).dividerColor.withOpacity(Theme.of(context).brightness == Brightness.dark ? 0.1 : 0.2))), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: BorderSide(
color: Theme.of(context).dividerColor.withOpacity(
Theme.of(context).brightness == Brightness.dark
? 0.1
: 0.2,
),
),
),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(32.0), padding: EdgeInsets.all(32.0),
child: Center(child: Text('No transactions in this period.', style: TextStyle(color: Colors.grey))), child: Center(
child: Text(
'No transactions in this period.',
style: TextStyle(color: Colors.grey),
),
),
), ),
) )
else ...[ else ...[
@@ -195,7 +288,10 @@ class _StatisticsTabState extends State<StatisticsTab> {
], ],
const SizedBox(height: 32), const SizedBox(height: 32),
const Text('Summary Totals', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text(
'Summary Totals',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16), const SizedBox(height: 16),
GridView.count( GridView.count(
@@ -206,11 +302,36 @@ class _StatisticsTabState extends State<StatisticsTab> {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5, childAspectRatio: 1.5,
children: [ children: [
_buildTotalCard('Income', totalIncome, NatureColors.getColor('INCOME'), LucideIcons.arrowDown), _buildTotalCard(
_buildTotalCard('Expense', totalExpense, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUp), 'Income',
_buildTotalCard('Investment', totalInvestment, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp), totalIncome,
_buildTotalCard('Payables', totalPayablesPeriod, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle), NatureColors.getColor('INCOME'),
_buildTotalCard('Receivables', totalReceivablesPeriod, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft), LucideIcons.arrowDown,
),
_buildTotalCard(
'Expense',
totalExpense,
NatureColors.getColor('EXPENSE'),
LucideIcons.arrowUp,
),
_buildTotalCard(
'Investment',
totalInvestment,
NatureColors.getColor('INVESTMENTS'),
LucideIcons.trendingUp,
),
_buildTotalCard(
'Payables',
totalPayablesPeriod,
NatureColors.getColor('PAYABLES'),
LucideIcons.alertCircle,
),
_buildTotalCard(
'Receivables',
totalReceivablesPeriod,
NatureColors.getColor('RECEIVABLES'),
LucideIcons.arrowDownLeft,
),
], ],
), ),
const SizedBox(height: 32), const SizedBox(height: 32),

View File

@@ -22,10 +22,7 @@ class CarouselItemData {
class SwipeableAccountCard extends StatefulWidget { class SwipeableAccountCard extends StatefulWidget {
final List<CarouselItemData> items; final List<CarouselItemData> items;
const SwipeableAccountCard({ const SwipeableAccountCard({super.key, required this.items});
super.key,
required this.items,
});
@override @override
State<SwipeableAccountCard> createState() => _SwipeableAccountCardState(); State<SwipeableAccountCard> createState() => _SwipeableAccountCardState();
@@ -71,14 +68,25 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
return GestureDetector( return GestureDetector(
onTap: item.onTap, onTap: item.onTap,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0), margin: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 4.0,
),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).cardColor, color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2)), border: Border.all(
color: Theme.of(
context,
).dividerColor.withOpacity(isDark ? 0.1 : 0.2),
),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.black.withOpacity(isDark ? 0.2 : 0.02), blurRadius: 8, offset: const Offset(0, 2)) BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.2 : 0.02),
blurRadius: 8,
offset: const Offset(0, 2),
),
], ],
), ),
child: Row( child: Row(
@@ -89,11 +97,7 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
color: item.color.withOpacity(0.1), color: item.color.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(item.icon, color: item.color, size: 20),
item.icon,
color: item.color,
size: 20,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
@@ -103,7 +107,10 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
children: [ children: [
Text( Text(
item.title, item.title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -111,17 +118,24 @@ class _SwipeableAccountCardState extends State<SwipeableAccountCard> {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.subtitle, item.subtitle,
style: TextStyle(color: Colors.grey.shade600, fontSize: 12), style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
] ],
], ],
), ),
), ),
Text( Text(
'Rs. ${item.amount.toStringAsFixed(0)}', 'Rs. ${item.amount.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Theme.of(context).textTheme.bodyLarge?.color), style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
), ),
], ],
), ),

View File

@@ -63,7 +63,7 @@ class UpcomingDuesWidget extends ConsumerWidget {
} else if (w.paymentCycle == 'YEARLY') { } else if (w.paymentCycle == 'YEARLY') {
// Assume cycleDate is day of current month, this is simplistic // Assume cycleDate is day of current month, this is simplistic
if (now.day > w.cycleDate!) { if (now.day > w.cycleDate!) {
year++; year++;
} }
} }
@@ -73,7 +73,9 @@ class UpcomingDuesWidget extends ConsumerWidget {
dueDate = DateTime(year, month, day); dueDate = DateTime(year, month, day);
int daysLeft = dueDate.difference(DateTime(now.year, now.month, now.day)).inDays; int daysLeft = dueDate
.difference(DateTime(now.year, now.month, now.day))
.inDays;
if (daysLeft == 0) { if (daysLeft == 0) {
dueLabel = 'Due Today'; dueLabel = 'Due Today';
@@ -108,9 +110,18 @@ class UpcomingDuesWidget extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(LucideIcons.calendarClock, color: Color(0xFF6C63FF), size: 20), const Icon(
LucideIcons.calendarClock,
color: Color(0xFF6C63FF),
size: 20,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('Upcoming Dues', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)), Text(
'Upcoming Dues',
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -127,11 +138,26 @@ class UpcomingDuesWidget extends ConsumerWidget {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUrgent ? (isDark ? Colors.red.withOpacity(0.2) : Colors.red.shade50) : Theme.of(context).cardColor, color: isUrgent
? (isDark ? Colors.red.withOpacity(0.2) : Colors.red.shade50)
: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: isUrgent ? (isDark ? Colors.red.withOpacity(0.5) : Colors.red.shade200) : Theme.of(context).dividerColor.withOpacity(isDark ? 0.1 : 0.2)), border: Border.all(
color: isUrgent
? (isDark
? Colors.red.withOpacity(0.5)
: Colors.red.shade200)
: Theme.of(
context,
).dividerColor.withOpacity(isDark ? 0.1 : 0.2),
),
boxShadow: [ boxShadow: [
if (!isUrgent) BoxShadow(color: Colors.black.withOpacity(isDark ? 0.2 : 0.02), blurRadius: 8, offset: const Offset(0, 2)) if (!isUrgent)
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.2 : 0.02),
blurRadius: 8,
offset: const Offset(0, 2),
),
], ],
), ),
child: Row( child: Row(
@@ -139,13 +165,22 @@ class UpcomingDuesWidget extends ConsumerWidget {
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (isUrgent ? Colors.red : NatureColors.getColor('PAYABLES')).withOpacity(0.1), color:
(isUrgent
? Colors.red
: NatureColors.getColor('PAYABLES'))
.withOpacity(0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
w.subNature == 'CREDIT_CARD' ? LucideIcons.creditCard : w.subNature == 'CREDIT_CARD'
(w.subNature == 'LOAN_EMI' ? LucideIcons.home : LucideIcons.fileText), ? LucideIcons.creditCard
color: isUrgent ? Colors.red : NatureColors.getColor('PAYABLES'), : (w.subNature == 'LOAN_EMI'
? LucideIcons.home
: LucideIcons.fileText),
color: isUrgent
? Colors.red
: NatureColors.getColor('PAYABLES'),
size: 20, size: 20,
), ),
), ),
@@ -154,14 +189,25 @@ class UpcomingDuesWidget extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(w.name, style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15, color: Theme.of(context).textTheme.bodyLarge?.color)), Text(
w.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'$label${DateFormat('MMM dd').format(dueDate)}', '$label${DateFormat('MMM dd').format(dueDate)}',
style: TextStyle( style: TextStyle(
color: isUrgent ? Colors.red.shade700 : Colors.grey.shade600, color: isUrgent
? Colors.red.shade700
: Colors.grey.shade600,
fontSize: 12, fontSize: 12,
fontWeight: isUrgent ? FontWeight.bold : FontWeight.normal fontWeight: isUrgent
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -172,7 +218,9 @@ class UpcomingDuesWidget extends ConsumerWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 16, fontSize: 16,
color: isUrgent ? (isDark ? Colors.redAccent : Colors.red.shade700) : Theme.of(context).textTheme.bodyLarge?.color color: isUrgent
? (isDark ? Colors.redAccent : Colors.red.shade700)
: Theme.of(context).textTheme.bodyLarge?.color,
), ),
), ),
], ],

View File

@@ -15,7 +15,9 @@ final insightProvider = Provider<Insight?>((ref) {
final budgetsState = ref.watch(budgetProvider); final budgetsState = ref.watch(budgetProvider);
final categoriesState = ref.watch(categoryProvider); final categoriesState = ref.watch(categoryProvider);
if (!transState.hasValue || !budgetsState.hasValue || !categoriesState.hasValue) { if (!transState.hasValue ||
!budgetsState.hasValue ||
!categoriesState.hasValue) {
return null; return null;
} }
@@ -24,17 +26,23 @@ final insightProvider = Provider<Insight?>((ref) {
final categories = categoriesState.value!; final categories = categoriesState.value!;
final now = DateTime.now(); final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year).toList(); final currentMonthTxs = transactions
.where((t) => t.date.month == now.month && t.date.year == now.year)
.toList();
final totalIncome = currentMonthTxs.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount); final totalIncome = currentMonthTxs
final totalExpense = currentMonthTxs.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount); .where((t) => t.type == 'INCOME')
.fold(0.0, (s, t) => s + t.amount);
final totalExpense = currentMonthTxs
.where((t) => t.type == 'EXPENSE')
.fold(0.0, (s, t) => s + t.amount);
// Rule 1: Expenses > Income // Rule 1: Expenses > Income
if (totalExpense > totalIncome && totalIncome > 0) { if (totalExpense > totalIncome && totalIncome > 0) {
return Insight( return Insight(
'Spending Alert', 'Spending Alert',
'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})', 'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})',
'WARNING' 'WARNING',
); );
} }
@@ -45,18 +53,28 @@ final insightProvider = Provider<Insight?>((ref) {
.fold(0.0, (s, t) => s + t.amount); .fold(0.0, (s, t) => s + t.amount);
if (spent > budget.monthlyLimit) { if (spent > budget.monthlyLimit) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name; final categoryName = categories
.firstWhere(
(c) => c.id == budget.categoryId,
orElse: () => Category(id: 0, name: 'Unknown'),
)
.name;
return Insight( return Insight(
'Budget Exceeded', 'Budget Exceeded',
'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.', 'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.',
'WARNING' 'WARNING',
); );
} else if (spent > budget.monthlyLimit * 0.9) { } else if (spent > budget.monthlyLimit * 0.9) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name; final categoryName = categories
.firstWhere(
(c) => c.id == budget.categoryId,
orElse: () => Category(id: 0, name: 'Unknown'),
)
.name;
return Insight( return Insight(
'Nearing Budget Limit', 'Nearing Budget Limit',
'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.', 'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.',
'WARNING' 'WARNING',
); );
} }
} }
@@ -66,18 +84,26 @@ final insightProvider = Provider<Insight?>((ref) {
final Map<int, double> spentByCategory = {}; final Map<int, double> spentByCategory = {};
for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) { for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) {
if (t.categoryId != null) { if (t.categoryId != null) {
spentByCategory[t.categoryId!] = (spentByCategory[t.categoryId!] ?? 0) + t.amount; spentByCategory[t.categoryId!] =
(spentByCategory[t.categoryId!] ?? 0) + t.amount;
} }
} }
if (spentByCategory.isNotEmpty) { if (spentByCategory.isNotEmpty) {
final maxEntry = spentByCategory.entries.reduce((a, b) => a.value > b.value ? a : b); final maxEntry = spentByCategory.entries.reduce(
(a, b) => a.value > b.value ? a : b,
);
if (maxEntry.value > totalExpense * 0.4) { if (maxEntry.value > totalExpense * 0.4) {
final categoryName = categories.firstWhere((c) => c.id == maxEntry.key, orElse: () => Category(id: 0, name: 'Unknown')).name; final categoryName = categories
.firstWhere(
(c) => c.id == maxEntry.key,
orElse: () => Category(id: 0, name: 'Unknown'),
)
.name;
return Insight( return Insight(
'High Concentration', 'High Concentration',
'${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.', '${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.',
'INFO' 'INFO',
); );
} }
} }
@@ -88,13 +114,13 @@ final insightProvider = Provider<Insight?>((ref) {
return Insight( return Insight(
'Great Job!', 'Great Job!',
'You have saved over 50% of your income this month. Keep it up!', 'You have saved over 50% of your income this month. Keep it up!',
'SUCCESS' 'SUCCESS',
); );
} }
return Insight( return Insight(
'On Track', 'On Track',
'Your finances are looking stable this month.', 'Your finances are looking stable this month.',
'SUCCESS' 'SUCCESS',
); );
}); });

View File

@@ -31,7 +31,8 @@ class CategoryRateHistory {
'id': id, 'id': id,
'categoryId': categoryId, 'categoryId': categoryId,
'rate': rate, 'rate': rate,
'date': "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}", 'date':
"${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}",
'createdAt': createdAt.toIso8601String(), 'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(), 'updatedAt': updatedAt.toIso8601String(),
}; };

View File

@@ -22,6 +22,7 @@ class InventoryItem {
final int? branchId; final int? branchId;
final String? purchaseRef; final String? purchaseRef;
final String? status; final String? status;
final DateTime? createdAt;
InventoryItem({ InventoryItem({
this.id, this.id,
@@ -47,6 +48,7 @@ class InventoryItem {
this.branchId, this.branchId,
this.purchaseRef, this.purchaseRef,
this.status, this.status,
this.createdAt,
}); });
factory InventoryItem.fromJson(Map<String, dynamic> json) { factory InventoryItem.fromJson(Map<String, dynamic> json) {
@@ -74,6 +76,7 @@ class InventoryItem {
branchId: json['branchId'], branchId: json['branchId'],
purchaseRef: json['purchaseRef'], purchaseRef: json['purchaseRef'],
status: json['status'], status: json['status'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null,
); );
} }
} }

View File

@@ -9,13 +9,9 @@ class Product {
final String? sku; final String? sku;
final String? barcode; final String? barcode;
final String? description; final String? description;
final double? purchasePrice;
final double? sellingPrice; final double? sellingPrice;
final double? minStock;
final double? reorderLevel;
final double? gstRate; final double? gstRate;
final String? dimensions; final String? dimensions;
final double? weight;
final String? color; final String? color;
final String? size; final String? size;
final String priceCalcRule; final String priceCalcRule;
@@ -24,7 +20,6 @@ class Product {
final double? makingCharges; final double? makingCharges;
final String? makingChargesType; final String? makingChargesType;
final double? wastagePercentage; final double? wastagePercentage;
final bool trackInventory;
final bool isActive; final bool isActive;
final List<int> imageIds; final List<int> imageIds;
final double? currentStock; final double? currentStock;
@@ -39,13 +34,9 @@ class Product {
this.sku, this.sku,
this.barcode, this.barcode,
this.description, this.description,
this.purchasePrice,
this.sellingPrice, this.sellingPrice,
this.minStock,
this.reorderLevel,
this.gstRate, this.gstRate,
this.dimensions, this.dimensions,
this.weight,
this.color, this.color,
this.size, this.size,
this.priceCalcRule = 'MANUAL', this.priceCalcRule = 'MANUAL',
@@ -54,7 +45,6 @@ class Product {
this.makingCharges = 0.0, this.makingCharges = 0.0,
this.makingChargesType = 'FLAT', this.makingChargesType = 'FLAT',
this.wastagePercentage = 0.0, this.wastagePercentage = 0.0,
this.trackInventory = true,
this.isActive = true, this.isActive = true,
this.imageIds = const [], this.imageIds = const [],
this.currentStock = 0.0, this.currentStock = 0.0,
@@ -71,27 +61,36 @@ class Product {
sku: json['sku'], sku: json['sku'],
barcode: json['barcode'], barcode: json['barcode'],
description: json['description'], description: json['description'],
purchasePrice: (json['purchasePrice'] ?? json['purchase_price'] as num?)?.toDouble(), sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)
sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(), ?.toDouble(),
minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble(),
reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble(),
gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(), gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(),
dimensions: json['dimensions'], dimensions: json['dimensions'],
weight: (json['weight'] as num?)?.toDouble(),
color: json['color'], color: json['color'],
size: json['size'], size: json['size'],
priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', priceCalcRule:
autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL',
purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? 1.0, autoCalculatePrice:
makingCharges: (json['makingCharges'] ?? json['making_charges'] as num?)?.toDouble() ?? 0.0, json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false,
makingChargesType: json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT', purityFactor:
wastagePercentage: (json['wastagePercentage'] ?? json['wastage_percentage'] as num?)?.toDouble() ?? 0.0, (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ??
trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true, 1.0,
makingCharges:
(json['makingCharges'] ?? json['making_charges'] as num?)
?.toDouble() ??
0.0,
makingChargesType:
json['makingChargesType'] ?? json['making_charges_type'] ?? 'FLAT',
wastagePercentage:
(json['wastagePercentage'] ?? json['wastage_percentage'] as num?)
?.toDouble() ??
0.0,
isActive: json['isActive'] ?? json['is_active'] ?? true, isActive: json['isActive'] ?? json['is_active'] ?? true,
imageIds: json['images'] != null imageIds: json['images'] != null
? (json['images'] as List).map((i) => i['id'] as int).toList() ? (json['images'] as List).map((i) => i['id'] as int).toList()
: [], : [],
currentStock: (json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ?? 0.0, currentStock:
(json['currentStock'] ?? json['current_stock'] as num?)?.toDouble() ??
0.0,
); );
} }
@@ -106,13 +105,9 @@ class Product {
'sku': sku, 'sku': sku,
'barcode': barcode, 'barcode': barcode,
'description': description, 'description': description,
'purchasePrice': purchasePrice,
'sellingPrice': sellingPrice, 'sellingPrice': sellingPrice,
'minStock': minStock,
'reorderLevel': reorderLevel,
'gstRate': gstRate, 'gstRate': gstRate,
'dimensions': dimensions, 'dimensions': dimensions,
'weight': weight,
'color': color, 'color': color,
'size': size, 'size': size,
'priceCalcRule': priceCalcRule, 'priceCalcRule': priceCalcRule,
@@ -121,7 +116,6 @@ class Product {
'makingCharges': makingCharges, 'makingCharges': makingCharges,
'makingChargesType': makingChargesType, 'makingChargesType': makingChargesType,
'wastagePercentage': wastagePercentage, 'wastagePercentage': wastagePercentage,
'trackInventory': trackInventory,
'isActive': isActive, 'isActive': isActive,
}; };
} }

View File

@@ -23,7 +23,9 @@ class ProductBom {
parentProductId: json['parentProductId'], parentProductId: json['parentProductId'],
componentProductId: json['componentProductId'], componentProductId: json['componentProductId'],
quantity: (json['quantity'] as num).toDouble(), quantity: (json['quantity'] as num).toDouble(),
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
componentProduct: json['componentProduct'], componentProduct: json['componentProduct'],
); );
} }

View File

@@ -5,12 +5,22 @@ class StockMovementItem {
final double quantity; final double quantity;
final double? unitPrice; final double? unitPrice;
final String? huid;
final double? weight;
final String? photoUrl;
final double? purchaseAmount;
StockMovementItem({ StockMovementItem({
this.id, this.id,
this.movementId, this.movementId,
this.productId, this.productId,
required this.quantity, required this.quantity,
this.unitPrice, this.unitPrice,
this.huid,
this.weight,
this.photoUrl,
this.purchaseAmount,
}); });
factory StockMovementItem.fromJson(Map<String, dynamic> json) { factory StockMovementItem.fromJson(Map<String, dynamic> json) {
@@ -20,6 +30,11 @@ class StockMovementItem {
productId: json['productId'], productId: json['productId'],
quantity: (json['quantity'] as num).toDouble(), quantity: (json['quantity'] as num).toDouble(),
unitPrice: (json['unitPrice'] as num?)?.toDouble(), unitPrice: (json['unitPrice'] as num?)?.toDouble(),
huid: json['huid'],
weight: json['weight'] != null ? (json['weight'] as num).toDouble() : null,
photoUrl: json['photoUrl'],
purchaseAmount: json['purchaseAmount'] != null ? (json['purchaseAmount'] as num).toDouble() : null,
); );
} }
@@ -30,6 +45,11 @@ class StockMovementItem {
'productId': productId, 'productId': productId,
'quantity': quantity, 'quantity': quantity,
'unitPrice': unitPrice, 'unitPrice': unitPrice,
'huid': huid,
'weight': weight,
'photoUrl': photoUrl,
'purchaseAmount': purchaseAmount,
}; };
} }
} }
@@ -38,7 +58,8 @@ class StockMovement {
final int? id; final int? id;
final int? userId; final int? userId;
final int? locationId; final int? locationId;
final String type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN final String
type; // OPENING, ADDITION, REDUCTION, ADJUSTMENT, DAMAGE, SALE, RETURN
final int? referenceTransactionId; final int? referenceTransactionId;
final String? notes; final String? notes;
final DateTime? createdAt; final DateTime? createdAt;
@@ -63,9 +84,13 @@ class StockMovement {
type: json['type'], type: json['type'],
referenceTransactionId: json['referenceTransactionId'], referenceTransactionId: json['referenceTransactionId'],
notes: json['notes'], notes: json['notes'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
items: json['items'] != null items: json['items'] != null
? (json['items'] as List).map((i) => StockMovementItem.fromJson(i)).toList() ? (json['items'] as List)
.map((i) => StockMovementItem.fromJson(i))
.toList()
: null, : null,
); );
} }

View File

@@ -1,39 +0,0 @@
class UnitOfMeasure {
final int? id;
final String name;
final String? abbreviation;
UnitOfMeasure({
this.id,
required this.name,
this.abbreviation,
});
factory UnitOfMeasure.fromJson(Map<String, dynamic> json) {
return UnitOfMeasure(
id: json['id'],
name: json['name'],
abbreviation: json['abbreviation'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'abbreviation': abbreviation,
};
}
UnitOfMeasure copyWith({
int? id,
String? name,
String? abbreviation,
}) {
return UnitOfMeasure(
id: id ?? this.id,
name: name ?? this.name,
abbreviation: abbreviation ?? this.abbreviation,
);
}
}

View File

@@ -3,15 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage;
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import '../../../../core/widgets/smart_search_dropdown.dart'; import '../../../../core/widgets/smart_search_dropdown.dart';
import '../domain/product.dart'; import '../domain/product.dart';
import '../providers/products_provider.dart'; import '../providers/products_provider.dart';
import '../providers/product_categories_provider.dart'; import '../providers/product_categories_provider.dart';
import '../providers/uoms_provider.dart';
import '../domain/uom.dart';
import '../../business/providers/business_provider.dart'; import '../../business/providers/business_provider.dart';
import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart';
@@ -24,36 +21,18 @@ class AddProductScreen extends ConsumerStatefulWidget {
} }
class _AddProductScreenState extends ConsumerState<AddProductScreen> { class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final int _totalPages = 4;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _hsnController = TextEditingController();
final _gstController = TextEditingController();
// Basic // Basic
String _name = ''; String _name = '';
String _sku = ''; String _sku = '';
String _hsnCode = '';
ProductCategory? _selectedCategory; ProductCategory? _selectedCategory;
int? _uomId; int? _uomId;
// Properties // Properties
String _color = ''; String _color = '';
String _size = '';
String _dimensions = '';
double _weight = 0;
// Pricing & Inventory
double _purchasePrice = 0;
double _sellingPrice = 0;
double _gstRate = 0;
bool _trackInventory = true;
bool _autoCalculatePrice = false;
// Advanced Commodity Fields
double _purityFactor = 1.0;
double _makingCharges = 0;
String _makingChargesType = 'FLAT'; // FLAT, PER_UNIT, PERCENTAGE
double _wastagePercentage = 0;
// Media // Media
final List<XFile> _images = []; final List<XFile> _images = [];
@@ -72,21 +51,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final p = widget.product!; final p = widget.product!;
_name = p.name; _name = p.name;
_sku = p.sku ?? ''; _sku = p.sku ?? '';
_hsnCode = p.hsnCode ?? ''; _hsnController.text = p.hsnCode ?? '';
_uomId = p.uomId; _uomId = p.uomId;
_color = p.color ?? ''; _color = p.color ?? '';
_size = p.size ?? ''; _gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : '';
_dimensions = p.dimensions ?? '';
_weight = p.weight ?? 0;
_purchasePrice = p.purchasePrice ?? 0;
_sellingPrice = p.sellingPrice ?? 0;
_gstRate = p.gstRate ?? 0;
_trackInventory = p.trackInventory;
_autoCalculatePrice = p.autoCalculatePrice;
_purityFactor = p.purityFactor ?? 1.0;
_makingCharges = p.makingCharges ?? 0.0;
_makingChargesType = p.makingChargesType ?? 'FLAT';
_wastagePercentage = p.wastagePercentage ?? 0.0;
if (p.imageIds.isNotEmpty) { if (p.imageIds.isNotEmpty) {
_existingImageIds.addAll(p.imageIds); _existingImageIds.addAll(p.imageIds);
@@ -103,24 +71,11 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
} }
} }
void _nextPage() { @override
FocusScope.of(context).unfocus(); void dispose() {
if (_currentPage < _totalPages - 1) { _hsnController.dispose();
if (_currentPage == 0 && _selectedCategory == null) { _gstController.dispose();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category.'))); super.dispose();
return;
}
_pageController.animateToPage(_currentPage + 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
} else {
_save();
}
}
void _prevPage() {
FocusScope.of(context).unfocus();
if (_currentPage > 0) {
_pageController.animateToPage(_currentPage - 1, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut);
}
} }
Future<void> _pickImages() async { Future<void> _pickImages() async {
@@ -167,7 +122,6 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
); );
} }
Future<void> _save() async { Future<void> _save() async {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
_formKey.currentState!.save(); _formKey.currentState!.save();
@@ -182,24 +136,14 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
final product = Product( final product = Product(
id: widget.product?.id, id: widget.product?.id,
name: _name, name: _name,
hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null, hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null,
sku: _sku.isNotEmpty ? _sku : null, sku: _sku.isNotEmpty ? _sku : null,
categoryId: _selectedCategory?.id, categoryId: _selectedCategory?.id,
uomId: _uomId, uomId: _uomId,
color: _color.isNotEmpty ? _color : null, color: _color.isNotEmpty ? _color : null,
size: _size.isNotEmpty ? _size : null, gstRate: double.tryParse(_gstController.text) ?? 0,
dimensions: _dimensions.isNotEmpty ? _dimensions : null, priceCalcRule: 'MANUAL',
purchasePrice: _purchasePrice, autoCalculatePrice: false,
sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice,
gstRate: _gstRate,
weight: _weight,
priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL',
autoCalculatePrice: _autoCalculatePrice,
purityFactor: _purityFactor,
makingCharges: _makingCharges,
makingChargesType: _makingChargesType,
wastagePercentage: _wastagePercentage,
trackInventory: _trackInventory,
); );
if (widget.product != null) { if (widget.product != null) {
@@ -215,12 +159,10 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
} }
} }
double _calculateLivePrice() {
return _sellingPrice;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar( appBar: AppBar(
@@ -232,23 +174,185 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: Column( child: Column(
children: [ children: [
_buildProgressPills(),
Expanded( Expanded(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: PageView( child: SingleChildScrollView(
controller: _pageController, padding: const EdgeInsets.all(24),
physics: const NeverScrollableScrollPhysics(), child: Column(
onPageChanged: (idx) { crossAxisAlignment: CrossAxisAlignment.start,
FocusScope.of(context).unfocus(); children: [
setState(() => _currentPage = idx); const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
}, const SizedBox(height: 16),
children: [
_buildBasicStep(), _buildPremiumTextField(
_buildPropertiesStep(), label: 'Product Name*',
_buildPricingStep(), initialValue: _name,
_buildMediaStep(), validator: (val) => val == null || val.isEmpty ? 'Required' : null,
], onChanged: (val) => _name = val,
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'SKU / Barcode',
initialValue: _sku,
onChanged: (val) => _sku = val,
),
const SizedBox(height: 24),
const Text('Category & Unit', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
categoriesState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading categories: $err'),
data: (categories) {
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
return leafCategories.isEmpty
? const Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))
: SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemAsString: (c) => c.name,
itemBuilder: (context, item) {
List<String> path = [];
ProductCategory? current = item;
while (current != null) {
path.insert(0, current.name);
if (current.parentCategoryId != null) {
current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull;
} else {
current = null;
}
}
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
Text(path.join(' -> '), style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
);
},
onChanged: (val) {
setState(() {
_selectedCategory = val;
if (val != null) {
if (val.defaultHsn != null && val.defaultHsn!.isNotEmpty) {
_hsnController.text = val.defaultHsn!;
}
if (val.defaultGst != null && val.defaultGst! > 0) {
_gstController.text = val.defaultGst.toString();
}
}
});
},
);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: const InputDecoration(labelText: 'Unit of Measure (Optional)'),
value: _uomId,
items: const [
DropdownMenuItem(value: 1, child: Text('Grams (g)')),
DropdownMenuItem(value: 2, child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 3, child: Text('Pieces (pcs)')),
],
onChanged: (val) => setState(() => _uomId = val),
),
const SizedBox(height: 24),
const Text('Pricing & Taxes', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _hsnController,
decoration: const InputDecoration(labelText: 'HSN Code'),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _gstController,
decoration: const InputDecoration(labelText: 'GST Rate (%)', suffixText: '%'),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 24),
const Text('Product Images', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisSpacing: 16, mainAxisSpacing: 16, childAspectRatio: 1,
),
itemCount: _images.length + _existingImageIds.length,
itemBuilder: (context, index) {
if (index < _existingImageIds.length) {
final imageId = _existingImageIds[index];
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
return _buildImageThumbnail(
isNetwork: true,
url: imageUrl,
onTap: () => _previewImage(index),
onDelete: () async {
try {
setState(() => _isSaving = true);
await DioClient().dio.delete('/inventory/products/images/$imageId');
setState(() => _existingImageIds.removeAt(index));
ref.invalidate(productsProvider);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
);
} else {
final localIndex = index - _existingImageIds.length;
return _buildImageThumbnail(
isNetwork: false,
file: File(_images[localIndex].path),
onTap: () => _previewImage(index),
onDelete: () => _removeImage(localIndex),
);
}
},
),
if ((_images.length + _existingImageIds.length) < 4) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: _pickImages,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3)),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(LucideIcons.uploadCloud, size: 32, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 8),
const Text('Tap to Upload Images', style: TextStyle(fontWeight: FontWeight.bold)),
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
),
),
]
],
),
), ),
), ),
), ),
@@ -259,476 +363,28 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
); );
} }
Widget _buildProgressPills() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: List.generate(_totalPages, (index) {
bool isActive = index <= _currentPage;
return Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: EdgeInsets.only(right: index == _totalPages - 1 ? 0 : 8),
height: 6,
decoration: BoxDecoration(
color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(10),
),
),
);
}),
),
);
}
Widget _buildBottomBar() { Widget _buildBottomBar() {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(16.0),
child: Row( child: SizedBox(
children: [ width: double.infinity,
if (_currentPage > 0) child: ElevatedButton(
Expanded( onPressed: _isSaving ? null : _save,
flex: 1, style: ElevatedButton.styleFrom(
child: OutlinedButton( padding: const EdgeInsets.symmetric(vertical: 16),
onPressed: _isSaving ? null : _prevPage, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
style: OutlinedButton.styleFrom( elevation: 4,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: const Icon(LucideIcons.chevronLeft),
),
),
if (_currentPage > 0) const SizedBox(width: 16),
Expanded(
flex: 3,
child: ElevatedButton(
onPressed: _isSaving ? null : _nextPage,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
shadowColor: Theme.of(context).colorScheme.primary.withOpacity(0.4),
),
child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(
_currentPage == _totalPages - 1
? (widget.product != null ? 'Update Product' : 'Publish Product')
: 'Continue',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)
),
),
), ),
], child: _isSaving
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.product != null ? 'Update Product' : 'Publish Product', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
), ),
), ),
); );
} }
// ==== STEPS ====
Widget _buildBasicStep() {
final categoriesState = ref.watch(productCategoriesProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Basic Information', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Let\'s start with the core details of your product.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
_buildPremiumTextField(
label: 'Product Name*',
initialValue: _name,
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onChanged: (val) => _name = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'SKU / Barcode',
initialValue: _sku,
onChanged: (val) => _sku = val,
),
const SizedBox(height: 32),
const Text('Category', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
categoriesState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading categories: $err'),
data: (categories) {
final leafCategories = categories.where((c) => !categories.any((child) => child.parentCategoryId == c.id)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (leafCategories.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.orange.withOpacity(0.1), borderRadius: BorderRadius.circular(16)),
child: const Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.orange),
SizedBox(width: 12),
Expanded(child: Text('No categories found. Manage categories in Business Hub.', style: TextStyle(color: Colors.orange))),
],
),
)
else
SmartSearchDropdown<ProductCategory>(
hintText: 'Select Category*',
value: _selectedCategory,
items: leafCategories,
itemAsString: (c) => c.name,
itemBuilder: (context, item) {
// Build full path
List<String> path = [];
ProductCategory? current = item;
while (current != null) {
path.insert(0, current.name);
if (current.parentCategoryId != null) {
current = categories.where((p) => p.id == current!.parentCategoryId).firstOrNull;
} else {
current = null;
}
}
final pathString = path.join(' -> ');
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 2),
Text(pathString, style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
);
},
onChanged: (val) => setState(() => _selectedCategory = val),
),
],
);
},
),
const SizedBox(height: 32),
const Text('Unit of Measure', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Consumer(
builder: (context, ref, child) {
final uomsState = ref.watch(uomsProvider);
return uomsState.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error loading UOMs: $err'),
data: (uoms) {
return SmartSearchDropdown<int>(
hintText: 'Select Unit of Measure (Optional)',
value: _uomId,
items: uoms.map((u) => u.id!).toList(),
itemAsString: (id) {
final uom = uoms.firstWhere((u) => u.id == id);
return '${uom.name} (${uom.abbreviation})';
},
onChanged: (val) => setState(() => _uomId = val),
);
},
);
},
),
],
),
);
}
Widget _buildPropertiesStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Properties & Attributes', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Define the physical characteristics.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
_buildPremiumTextField(
label: 'Weight',
initialValue: _weight == 0 ? '' : _weight.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _weight = double.tryParse(val) ?? 0),
suffixText: _getUomAbbreviation() ?? _selectedCategory?.baseUnit ?? 'unit',
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Color',
initialValue: _color,
onChanged: (val) => _color = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Size',
initialValue: _size,
onChanged: (val) => _size = val,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: 'Dimensions',
initialValue: _dimensions,
onChanged: (val) => _dimensions = val,
),
],
),
);
}
Widget _buildPricingStep() {
final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false;
final isCommodity = false;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pricing & Inventory', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Set up costs, pricing rules, and tracking.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
if (isCommodity) ...[
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.blue.shade800, Colors.blue.shade500]),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.blue.withOpacity(0.3), blurRadius: 10, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(LucideIcons.activity, color: Colors.white),
const SizedBox(width: 12),
const Text('Commodity Pricing', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
Switch(
value: _autoCalculatePrice,
activeColor: Colors.white,
onChanged: (val) => setState(() => _autoCalculatePrice = val),
),
],
),
if (_autoCalculatePrice) ...[
const Divider(color: Colors.white24, height: 32),
_buildPremiumTextField(
label: 'Purity Factor (e.g. 0.916 for 22K)',
initialValue: _purityFactor.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _purityFactor = double.tryParse(val) ?? 1.0),
darkTheme: true,
),
const SizedBox(height: 16),
_buildPremiumTextField(
label: 'Wastage Percentage (%)',
initialValue: _wastagePercentage.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _wastagePercentage = double.tryParse(val) ?? 0.0),
darkTheme: true,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 1,
child: _buildPremiumTextField(
label: 'Making Charges',
initialValue: _makingCharges.toString(),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => setState(() => _makingCharges = double.tryParse(val) ?? 0.0),
darkTheme: true,
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: _buildPremiumDropdown<String>(
label: 'Type',
value: _makingChargesType,
items: const ['FLAT', 'PER_UNIT', 'PERCENTAGE'],
itemLabel: (t) => t == 'PER_UNIT' ? 'Per ${_selectedCategory?.baseUnit ?? "Unit"}' : t == 'PERCENTAGE' ? 'Percentage' : 'Flat',
onChanged: (val) => setState(() => _makingChargesType = val!),
darkTheme: true,
),
),
],
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(12)),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Live Selling Price:', style: TextStyle(color: Colors.white70)),
Text('${_calculateLivePrice().toStringAsFixed(2)}', style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
],
),
)
]
],
),
),
const SizedBox(height: 32),
],
if (!_autoCalculatePrice) ...[
_buildPremiumTextField(
label: 'Purchase Price',
initialValue: _purchasePrice == 0 ? '' : _purchasePrice.toString(),
prefixText: '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => _purchasePrice = double.tryParse(val) ?? 0,
),
const SizedBox(height: 20),
_buildPremiumTextField(
label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*',
initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(),
prefixText: '',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0,
),
const SizedBox(height: 20),
],
Row(
children: [
Expanded(
child: _buildPremiumTextField(
label: 'HSN Code',
initialValue: _hsnCode,
onChanged: (val) => _hsnCode = val,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildPremiumTextField(
label: 'GST Rate (%)',
initialValue: _gstRate == 0 ? '' : _gstRate.toString(),
suffixText: '%',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
onChanged: (val) => _gstRate = double.tryParse(val) ?? 0,
),
),
],
),
const SizedBox(height: 32),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(16),
),
child: SwitchListTile(
title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Monitor stock levels automatically'),
value: _trackInventory,
onChanged: (val) => setState(() => _trackInventory = val),
),
)
],
),
);
}
Widget _buildMediaStep() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Product Images', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('Upload up to 4 high-quality images.', style: TextStyle(color: Colors.grey)),
const SizedBox(height: 32),
if (_images.isNotEmpty || _existingImageIds.isNotEmpty)
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: _images.length + _existingImageIds.length,
itemBuilder: (context, index) {
if (index < _existingImageIds.length) {
final imageId = _existingImageIds[index];
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content';
return _buildImageThumbnail(
isNetwork: true,
url: imageUrl,
onTap: () => _previewImage(index),
onDelete: () async {
try {
setState(() => _isSaving = true);
await DioClient().dio.delete('/inventory/products/images/$imageId');
setState(() => _existingImageIds.removeAt(index));
// Update product list in background
ref.read(productsProvider.notifier).refresh();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to delete image: $e')));
} finally {
if (mounted) setState(() => _isSaving = false);
}
}
);
} else {
final localIndex = index - _existingImageIds.length;
return _buildImageThumbnail(
isNetwork: false,
file: File(_images[localIndex].path),
onTap: () => _previewImage(index),
onDelete: () => _removeImage(localIndex)
);
}
},
),
const SizedBox(height: 24),
if ((_images.length + _existingImageIds.length) < 4)
GestureDetector(
onTap: _pickImages,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withOpacity(0.05),
border: Border.all(color: Theme.of(context).colorScheme.primary.withOpacity(0.3), style: BorderStyle.solid),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
Icon(LucideIcons.uploadCloud, size: 48, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
const Text('Tap to Upload', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Text('${4 - (_images.length + _existingImageIds.length)} slots remaining', style: const TextStyle(color: Colors.grey)),
],
),
),
),
],
),
);
}
Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) { Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) {
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
@@ -737,33 +393,29 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
onTap: onTap, onTap: onTap,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)), border: Border.all(color: Colors.grey.withOpacity(0.2)),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(16),
child: Container( child: Container(
color: Colors.grey[200], color: Colors.grey[200],
child: isNetwork child: isNetwork
? (_token == null ? (_token == null
? const Icon(LucideIcons.image, color: Colors.grey) ? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network( : Image.network(
url!, url!,
fit: BoxFit.cover, fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'}, headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey),
)) ))
: Image.file( : Image.file(file!, fit: BoxFit.cover),
file!, ),
fit: BoxFit.cover,
),
), ),
), ),
), ),
), Positioned(
Positioned( top: 8,
top: 8,
right: 8, right: 8,
child: GestureDetector( child: GestureDetector(
onTap: onDelete, onTap: onDelete,
@@ -778,8 +430,6 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
); );
} }
// ==== WIDGET HELPERS ====
Widget _buildPremiumTextField({ Widget _buildPremiumTextField({
required String label, required String label,
String? initialValue, String? initialValue,
@@ -789,25 +439,13 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
void Function(String)? onChanged, void Function(String)? onChanged,
void Function(String?)? onSaved, void Function(String?)? onSaved,
String? Function(String?)? validator, String? Function(String?)? validator,
bool darkTheme = false,
}) { }) {
return TextFormField( return TextFormField(
initialValue: initialValue, initialValue: initialValue,
style: TextStyle(color: darkTheme ? Colors.white : null),
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]),
prefixText: prefixText, prefixText: prefixText,
prefixStyle: TextStyle(color: darkTheme ? Colors.white : Colors.black, fontWeight: FontWeight.bold),
suffixText: suffixText, suffixText: suffixText,
suffixStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey),
filled: true,
fillColor: darkTheme ? Colors.black26 : Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: darkTheme ? Colors.white : Theme.of(context).colorScheme.primary, width: 2)
),
), ),
keyboardType: keyboardType, keyboardType: keyboardType,
onChanged: onChanged, onChanged: onChanged,
@@ -815,59 +453,4 @@ class _AddProductScreenState extends ConsumerState<AddProductScreen> {
validator: validator, validator: validator,
); );
} }
Widget _buildPremiumDropdown<T>({
required String label,
required T? value,
required List<T> items,
String Function(T)? itemLabel,
required void Function(T?) onChanged,
bool darkTheme = false,
}) {
return Theme(
data: darkTheme ? Theme.of(context).copyWith(
textTheme: Theme.of(context).textTheme.apply(bodyColor: Colors.white, displayColor: Colors.white),
inputDecorationTheme: InputDecorationTheme(
labelStyle: const TextStyle(color: Colors.white70),
filled: true,
fillColor: Colors.black26,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Colors.white, width: 2)
),
)
) : Theme.of(context).copyWith(
inputDecorationTheme: InputDecorationTheme(
labelStyle: TextStyle(color: Colors.grey[600]),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)
),
)
),
child: SmartSearchDropdown<T>(
hintText: label,
value: value,
items: items,
itemAsString: (e) => itemLabel != null ? itemLabel(e) : e.toString(),
onChanged: onChanged,
),
);
}
String? _getUomAbbreviation() {
if (_uomId == null) return null;
final uomsState = ref.read(uomsProvider).value;
if (uomsState == null) return null;
try {
final uom = uomsState.firstWhere((u) => u.id == _uomId);
return uom.abbreviation;
} catch (_) {
return null;
}
}
} }

View File

@@ -27,13 +27,17 @@ class _BomTabState extends ConsumerState<BomTab> {
Future<void> _loadBom() async { Future<void> _loadBom() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId); final items = await ref
.read(productsProvider.notifier)
.fetchProductBom(widget.productId);
setState(() { setState(() {
_bomItems = items; _bomItems = items;
}); });
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading BOM: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error loading BOM: $e')));
} }
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
@@ -42,7 +46,9 @@ class _BomTabState extends ConsumerState<BomTab> {
void _showAddComponentSheet() { void _showAddComponentSheet() {
final productsState = ref.read(productsProvider); final productsState = ref.read(productsProvider);
final availableProducts = (productsState.value ?? []).where((p) => p.id != widget.productId).toList(); final availableProducts = (productsState.value ?? [])
.where((p) => p.id != widget.productId)
.toList();
Product? selectedProduct; Product? selectedProduct;
final qtyCtrl = TextEditingController(text: '1'); final qtyCtrl = TextEditingController(text: '1');
@@ -63,7 +69,10 @@ class _BomTabState extends ConsumerState<BomTab> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("Add Component", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text(
"Add Component",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SmartSearchDropdown<Product>( SmartSearchDropdown<Product>(
hintText: 'Select Product', hintText: 'Select Product',
@@ -79,10 +88,11 @@ class _BomTabState extends ConsumerState<BomTab> {
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: qtyCtrl, controller: qtyCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Quantity Required', labelText: 'Quantity Required',
border: OutlineInputBorder(),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -100,12 +110,18 @@ class _BomTabState extends ConsumerState<BomTab> {
); );
try { try {
await ref.read(productsProvider.notifier).addBomItem(widget.productId, bomItem); await ref
.read(productsProvider.notifier)
.addBomItem(widget.productId, bomItem);
if (context.mounted) Navigator.pop(context); if (context.mounted) Navigator.pop(context);
_loadBom(); _loadBom();
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding component: $e'))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error adding component: $e'),
),
);
} }
} }
}, },
@@ -121,7 +137,7 @@ class _BomTabState extends ConsumerState<BomTab> {
], ],
), ),
); );
} },
), ),
); );
} }
@@ -138,7 +154,12 @@ class _BomTabState extends ConsumerState<BomTab> {
return Stack( return Stack(
children: [ children: [
if (_bomItems.isEmpty) if (_bomItems.isEmpty)
const Center(child: Text("No components added yet.", style: TextStyle(color: Colors.grey))) const Center(
child: Text(
"No components added yet.",
style: TextStyle(color: Colors.grey),
),
)
else else
ListView.builder( ListView.builder(
padding: const EdgeInsets.all(16).copyWith(bottom: 80), padding: const EdgeInsets.all(16).copyWith(bottom: 80),
@@ -147,24 +168,36 @@ class _BomTabState extends ConsumerState<BomTab> {
final item = _bomItems[index]; final item = _bomItems[index];
final component = allProducts.firstWhere( final component = allProducts.firstWhere(
(p) => p.id == item.componentProductId, (p) => p.id == item.componentProductId,
orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'), orElse: () =>
Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'),
); );
return Card( return Card(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile( child: ListTile(
title: Text(component.name, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(
component.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('Quantity Required: ${item.quantity}'), subtitle: Text('Quantity Required: ${item.quantity}'),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red), icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async { onPressed: () async {
try { try {
await ref.read(productsProvider.notifier).deleteBomItem(item.id!); await ref
.read(productsProvider.notifier)
.deleteBomItem(item.id!);
_loadBom(); _loadBom();
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error deleting component: $e'))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error deleting component: $e'),
),
);
} }
} }
}, },

View File

@@ -1,6 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/product_categories_provider.dart'; import '../providers/product_categories_provider.dart';
import '../../../core/theme/nature_colors.dart';
import '../../business/providers/business_mode_provider.dart';
import '../../../core/theme/app_theme.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'dart:ui';
class CategoryManagementScreen extends ConsumerStatefulWidget { class CategoryManagementScreen extends ConsumerStatefulWidget {
const CategoryManagementScreen({super.key}); const CategoryManagementScreen({super.key});
@@ -13,93 +18,575 @@ class _CategoryManagementScreenState extends ConsumerState<CategoryManagementScr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final categoriesAsync = ref.watch(productCategoriesProvider); final categoriesAsync = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Category Management'), title: const Text('Category Management', style: TextStyle(fontWeight: FontWeight.bold)),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
), ),
body: categoriesAsync.when( body: Column(
data: (categories) {
// Build category tree
final rootCategories = categories.where((c) => c.parentCategoryId == null).toList();
return ListView.builder(
itemCount: rootCategories.length,
itemBuilder: (context, index) {
return _buildCategoryTile(rootCategories[index], categories, 0);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddCategoryDialog(null),
child: const Icon(Icons.add),
),
);
}
Widget _buildCategoryTile(ProductCategory category, List<ProductCategory> allCategories, int depth) {
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
if (children.isEmpty) {
return ListTile(
contentPadding: EdgeInsets.only(left: 16.0 + (depth * 24.0), right: 16.0),
title: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => _showAddCategoryDialog(category),
),
);
}
return ExpansionTile(
tilePadding: EdgeInsets.only(left: 16.0 + (depth * 24.0), right: 16.0),
title: Text(category.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( Container(
icon: const Icon(Icons.add_circle_outline), color: Colors.white,
onPressed: () => _showAddCategoryDialog(category), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
decoration: InputDecoration(
hintText: 'Search categories...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
contentPadding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
Expanded(
child: categoriesAsync.when(
data: (categories) {
Widget content;
if (categories.isEmpty) {
content = _buildEmptyState();
} else {
final rootCategories = categories.where((c) => c.parentCategoryId == null).toList();
content = ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: rootCategories.length,
itemBuilder: (context, index) {
return _buildCategoryNode(rootCategories[index], categories, 0, isDark);
},
);
}
return RefreshIndicator(
onRefresh: () async => ref.invalidate(productCategoriesProvider),
child: content,
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
), ),
const Icon(Icons.expand_more),
], ],
), ),
children: children.map((child) => _buildCategoryTile(child, allCategories, depth + 1)).toList(), floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showAddCategorySheet(context, null, ref),
icon: const Icon(LucideIcons.plus),
label: const Text('Add Category'),
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
); );
} }
void _showAddCategoryDialog(ProductCategory? parent) { Widget _buildEmptyState() {
final nameController = TextEditingController(); return ListView(
showDialog( physics: const AlwaysScrollableScrollPhysics(),
context: context, children: [
builder: (context) => AlertDialog( const SizedBox(height: 150),
title: Text(parent == null ? 'Add Root Category' : 'Add Subcategory to ${parent.name}'), Icon(LucideIcons.layers, size: 64, color: Colors.grey.withOpacity(0.5)),
content: TextField( const SizedBox(height: 16),
controller: nameController, const Center(child: Text('No categories yet', style: TextStyle(fontSize: 18, color: Colors.grey))),
decoration: const InputDecoration(labelText: 'Category Name'), const SizedBox(height: 8),
), const Center(child: Text('Create your first product category to get started', style: TextStyle(color: Colors.grey))),
actions: [ ],
TextButton( );
onPressed: () => Navigator.pop(context), }
child: const Text('Cancel'),
Widget _buildCategoryNode(ProductCategory category, List<ProductCategory> allCategories, int depth, bool isDark) {
final children = allCategories.where((c) => c.parentCategoryId == category.id).toList();
final isLeaf = children.isEmpty && !category.hasChild;
// A nice frosted glass card
return Container(
margin: EdgeInsets.only(left: depth * 16.0, bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor.withOpacity(isDark ? 0.4 : 0.8),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isLeaf ? Colors.blue.withOpacity(0.3) : Colors.white.withOpacity(isDark ? 0.05 : 0.5),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDark ? 0.2 : 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
)
],
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: isLeaf
? ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(LucideIcons.tag, size: 20, color: Colors.blue),
),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: category.commodityCode != null
? Text('${category.commodityCode} • HSN: ${category.defaultHsn ?? "N/A"}',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600))
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (category.defaultGst != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text('GST ${category.defaultGst}%',
style: const TextStyle(color: Colors.green, fontSize: 12, fontWeight: FontWeight.bold)),
),
_buildCategoryPopupMenu(category, isLeaf),
],
),
)
: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(LucideIcons.folder, size: 20, color: AppTheme.primaryColor),
),
title: Text(category.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildCategoryPopupMenu(category, isLeaf),
const Icon(LucideIcons.chevronDown, size: 20, color: Colors.grey),
],
),
children: children.map((child) => _buildCategoryNode(child, allCategories, depth + 1, isDark)).toList(),
),
),
), ),
FilledButton( ),
onPressed: () { ),
if (nameController.text.isNotEmpty) { );
final newCategory = ProductCategory( }
name: nameController.text,
parentCategoryId: parent?.id, Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) {
); return PopupMenuButton<String>(
ref.read(productCategoriesProvider.notifier).createCategory(newCategory); icon: const Icon(LucideIcons.moreVertical, color: Colors.grey),
Navigator.pop(context); onSelected: (value) {
} if (value == 'edit') {
}, _showAddCategorySheet(context, category, ref, isEdit: true);
child: const Text('Save'), } else if (value == 'add_sub') {
_showAddCategorySheet(context, category, ref, isEdit: false);
} else if (value == 'delete') {
if (category.id != null) {
ref.read(productCategoriesProvider.notifier).deleteCategory(category.id!);
}
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(children: [Icon(LucideIcons.edit, size: 18), SizedBox(width: 8), Text('Edit')]),
),
if (!isLeaf)
const PopupMenuItem(
value: 'add_sub',
child: Row(children: [Icon(LucideIcons.plus, size: 18), SizedBox(width: 8), Text('Add Subcategory')]),
),
const PopupMenuItem(
value: 'delete',
child: Row(children: [Icon(LucideIcons.trash2, size: 18, color: Colors.red), SizedBox(width: 8), Text('Delete', style: TextStyle(color: Colors.red))]),
),
],
);
}
}
void _showAddCategorySheet(BuildContext context, ProductCategory? parentOrCategory, WidgetRef ref, {bool isEdit = false}) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => CategoryFormSheet(
parent: isEdit ? null : parentOrCategory,
categoryToEdit: isEdit ? parentOrCategory : null,
),
);
}
class CategoryFormSheet extends ConsumerStatefulWidget {
final ProductCategory? parent;
final ProductCategory? categoryToEdit;
const CategoryFormSheet({super.key, this.parent, this.categoryToEdit});
@override
ConsumerState<CategoryFormSheet> createState() => _CategoryFormSheetState();
}
class _CategoryFormSheetState extends ConsumerState<CategoryFormSheet> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _hsnController = TextEditingController();
final _gstController = TextEditingController();
final _makingChargeController = TextEditingController();
int? _selectedParentId;
bool _isLeaf = false;
bool _huidRequired = false;
String _commodityCode = 'XAU';
String _makingChargeType = 'PER_GRAM';
String _baseUnit = 'g';
final List<Map<String, String>> _commodities = [
{'code': 'XAU', 'label': 'XAU - Gold'},
{'code': 'XAG', 'label': 'XAG - Silver'},
{'code': 'XPT', 'label': 'XPT - Platinum'},
{'code': 'XPD', 'label': 'XPD - Palladium'},
{'code': 'OTH', 'label': 'OTH - Other'},
];
@override
void initState() {
super.initState();
if (widget.categoryToEdit != null) {
final c = widget.categoryToEdit!;
_selectedParentId = c.parentCategoryId;
_nameController.text = c.name;
_isLeaf = !c.hasChild;
if (_isLeaf) {
_hsnController.text = c.defaultHsn ?? '';
_gstController.text = c.defaultGst?.toString() ?? '';
_makingChargeController.text = c.defaultMakingCharge?.toString() ?? '';
_huidRequired = c.huidRequired;
_commodityCode = c.commodityCode ?? 'XAU';
// Handle legacy or varying enum string values
_makingChargeType = c.makingChargeType ?? 'PER_GRAM';
if (_makingChargeType == 'PER_GM') _makingChargeType = 'PER_GRAM';
_baseUnit = c.baseUnit ?? 'g';
if (_baseUnit == 'gm') _baseUnit = 'g';
}
} else if (widget.parent != null) {
_selectedParentId = widget.parent!.id;
}
}
@override
void dispose() {
_nameController.dispose();
_hsnController.dispose();
_gstController.dispose();
_makingChargeController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
final newCategory = ProductCategory(
id: widget.categoryToEdit?.id,
name: _nameController.text.trim(),
parentCategoryId: _selectedParentId,
hasChild: !_isLeaf,
commodityCode: _isLeaf ? _commodityCode : null,
defaultHsn: _isLeaf ? _hsnController.text.trim() : null,
defaultGst: _isLeaf ? double.tryParse(_gstController.text) : null,
huidRequired: _isLeaf ? _huidRequired : false,
defaultMakingCharge: _isLeaf ? double.tryParse(_makingChargeController.text) : null,
makingChargeType: _isLeaf ? _makingChargeType : null,
baseUnit: _isLeaf ? _baseUnit : 'pcs',
);
if (widget.categoryToEdit != null && widget.categoryToEdit!.id != null) {
ref.read(productCategoriesProvider.notifier).updateCategory(widget.categoryToEdit!.id!, newCategory);
} else {
ref.read(productCategoriesProvider.notifier).createCategory(newCategory);
}
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final isBusinessMode = ref.watch(businessModeProvider);
return Container(
height: MediaQuery.of(context).size.height * 0.85,
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 20, offset: const Offset(0, -5)),
],
),
child: Column(
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(top: 12, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.categoryToEdit != null
? 'Edit Category'
: (widget.parent == null ? 'New Root Category' : 'Subcategory of ${widget.parent!.name}'),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Form
Expanded(
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(24),
children: [
Builder(
builder: (context) {
final categoriesAsync = ref.watch(productCategoriesProvider);
final allCategories = categoriesAsync.value ?? [];
final parentOptions = allCategories.where((c) =>
c.hasChild && c.id != widget.categoryToEdit?.id
).toList();
return DropdownButtonFormField<int?>(
value: _selectedParentId,
decoration: InputDecoration(
labelText: 'Parent Category',
prefixIcon: const Icon(LucideIcons.folder),
),
items: [
const DropdownMenuItem(value: null, child: Text('None (Root Category)')),
...parentOptions.map((c) => DropdownMenuItem(
value: c.id,
child: Text(c.name),
)),
],
onChanged: (val) => setState(() => _selectedParentId = val),
);
},
),
const SizedBox(height: 24),
_buildTextField(
controller: _nameController,
label: 'Category Name',
icon: LucideIcons.tag,
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
),
const SizedBox(height: 24),
// Leaf Category Toggle
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? Colors.grey.shade900 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _isLeaf ? Colors.blue.withOpacity(0.1) : AppTheme.primaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_isLeaf ? LucideIcons.checkCircle : LucideIcons.folder,
color: _isLeaf ? Colors.blue : AppTheme.primaryColor),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Final Product Category (Leaf)', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
const SizedBox(height: 4),
Text('Enable this if actual products will be created under this category. Subcategories cannot be added to a leaf.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600)),
],
),
),
Switch(
value: _isLeaf,
onChanged: (val) => setState(() => _isLeaf = val),
activeColor: Colors.blue,
),
],
),
),
if (_isLeaf) ...[
const SizedBox(height: 24),
const Text('Product Defaults', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
if (isBusinessMode) ...[
DropdownButtonFormField<String>(
value: _commodityCode,
decoration: InputDecoration(
labelText: 'Commodity Code',
prefixIcon: const Icon(LucideIcons.barChart2),
),
items: _commodities.map((c) => DropdownMenuItem(
value: c['code'],
child: Text(c['label']!),
)).toList(),
onChanged: (val) => setState(() => _commodityCode = val!),
),
const SizedBox(height: 16),
],
Row(
children: [
Expanded(
child: _buildTextField(
controller: _hsnController,
label: 'HSN Code',
icon: LucideIcons.hash,
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTextField(
controller: _gstController,
label: 'Default GST %',
icon: LucideIcons.percent,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('HUID Required'),
subtitle: const Text('Is Hallmark Unique Identification mandatory?'),
value: _huidRequired,
onChanged: (val) => setState(() => _huidRequired = val),
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
value: _makingChargeType,
decoration: InputDecoration(
labelText: 'Making Charge Type',
),
items: const [
DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')),
DropdownMenuItem(value: 'PER_PIECE', child: Text('Per Piece')),
DropdownMenuItem(value: 'PERCENTAGE', child: Text('Percentage')),
],
onChanged: (val) => setState(() => _makingChargeType = val!),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildTextField(
controller: _makingChargeController,
label: 'Default Charge',
keyboardType: const TextInputType.numberWithOptions(decimal: true),
),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _baseUnit,
decoration: InputDecoration(
labelText: 'Base Unit',
),
items: const [
DropdownMenuItem(value: 'g', child: Text('Grams (g)')),
DropdownMenuItem(value: 'kg', child: Text('Kilograms (kg)')),
DropdownMenuItem(value: 'pcs', child: Text('Pieces (pcs)')),
],
onChanged: (val) => setState(() => _baseUnit = val!),
),
],
const SizedBox(height: 40),
],
),
),
),
// Footer
Padding(
padding: const EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 5,
shadowColor: AppTheme.primaryColor.withOpacity(0.5),
),
child: const Text('Save Category', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
),
), ),
], ],
), ),
); );
} }
Widget _buildTextField({
required TextEditingController controller,
required String label,
IconData? icon,
TextInputType? keyboardType,
String? Function(String?)? validator,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: icon != null ? Icon(icon) : null,
),
);
}
} }

View File

@@ -108,9 +108,9 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[50], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), title: const Text('Daily Commodity Rates', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
elevation: 0, elevation: 0,
@@ -118,7 +118,7 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
), ),
body: categoriesState.when( body: categoriesState.when(
data: (categories) { data: (categories) {
final commodities = categories.where((c) => c.isCommodity).toList(); final commodities = categories.where((c) => c.commodityCode != null && c.commodityCode!.isNotEmpty).toList();
if (commodities.isEmpty) { if (commodities.isEmpty) {
return Center( return Center(
@@ -230,7 +230,6 @@ class _DailyRatesScreenState extends ConsumerState<DailyRatesScreen> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Today\'s Rate (per ${category.baseUnit})', labelText: 'Today\'s Rate (per ${category.baseUnit})',
prefixText: '', prefixText: '',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
), ),

View File

@@ -5,7 +5,6 @@ import 'package:kifi_app/core/network/dio_client.dart';
import 'package:kifi_app/features/inventory/domain/product.dart'; import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart'; import 'package:kifi_app/features/inventory/presentation/add_product_screen.dart';
import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart'; import 'package:kifi_app/features/inventory/presentation/stock_ledger_tab.dart';
import 'package:kifi_app/features/inventory/presentation/bom_tab.dart';
import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart'; import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart';
class ProductDetailScreen extends ConsumerWidget { class ProductDetailScreen extends ConsumerWidget {
@@ -17,17 +16,25 @@ class ProductDetailScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Watch products to get latest updates (like stock changes) // Watch products to get latest updates (like stock changes)
final productsState = ref.watch(productsProvider); final productsState = ref.watch(productsProvider);
final currentProduct = productsState.value?.firstWhere((p) => p.id == product.id, orElse: () => product) ?? product; final currentProduct =
productsState.value?.firstWhere(
(p) => p.id == product.id,
orElse: () => product,
) ??
product;
return DefaultTabController( return DefaultTabController(
length: 3, length: 2,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.grey[50], backgroundColor: Colors.grey[50],
appBar: AppBar( appBar: AppBar(
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(
currentProduct.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.edit, color: Colors.black), icon: const Icon(Icons.edit, color: Colors.black),
@@ -35,7 +42,8 @@ class ProductDetailScreen extends ConsumerWidget {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => AddProductScreen(product: currentProduct), builder: (context) =>
AddProductScreen(product: currentProduct),
), ),
); );
}, },
@@ -47,7 +55,6 @@ class ProductDetailScreen extends ConsumerWidget {
indicatorColor: Colors.blue, indicatorColor: Colors.blue,
tabs: [ tabs: [
Tab(text: "Overview"), Tab(text: "Overview"),
Tab(text: "BOM"),
Tab(text: "Stock Ledger"), Tab(text: "Stock Ledger"),
], ],
), ),
@@ -55,23 +62,9 @@ class ProductDetailScreen extends ConsumerWidget {
body: TabBarView( body: TabBarView(
children: [ children: [
_buildOverviewTab(context, currentProduct, ref), _buildOverviewTab(context, currentProduct, ref),
BomTab(productId: currentProduct.id!), StockLedgerTab(product: currentProduct),
StockLedgerTab(productId: currentProduct.id!),
], ],
), ),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AdjustStockSheet(productId: currentProduct.id!),
);
},
backgroundColor: Colors.blue,
icon: const Icon(Icons.inventory),
label: const Text("Adjust Stock"),
),
), ),
); );
} }
@@ -100,68 +93,46 @@ class ProductDetailScreen extends ConsumerWidget {
} }
final token = snapshot.data; final token = snapshot.data;
if (token == null) { if (token == null) {
return const Icon(Icons.image, size: 50, color: Colors.grey); return const Icon(
Icons.image,
size: 50,
color: Colors.grey,
);
} }
return Image.network( return Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content', '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content',
fit: BoxFit.cover, fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $token'}, headers: {'Authorization': 'Bearer $token'},
errorBuilder: (context, error, stackTrace) => const Icon(Icons.image, size: 50, color: Colors.grey), errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.image, size: 50, color: Colors.grey),
); );
}, },
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Stock Summary Card // Product Details Card
Card( Card(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
child: Padding( borderRadius: BorderRadius.circular(12),
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.inventory_2, color: Colors.blue),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("Current Stock", style: TextStyle(color: Colors.grey[600], fontSize: 14)),
Text(
"${p.currentStock ?? 0}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
),
],
),
],
),
), ),
),
const SizedBox(height: 16),
// Details Card
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("Product Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), const Text(
"Product Details",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const Divider(), const Divider(),
_buildDetailRow("SKU", p.sku ?? "-"), _buildDetailRow("SKU / Barcode", p.sku ?? "-"),
_buildDetailRow("Purchase Price", "${p.purchasePrice?.toStringAsFixed(2) ?? '0.00'}"), _buildDetailRow("HSN Code", p.hsnCode ?? "-"),
_buildDetailRow("Selling Price", "${p.sellingPrice?.toStringAsFixed(2) ?? '0.00'}"), _buildDetailRow(
_buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"), "GST Rate",
_buildDetailRow("Reorder Level", "${p.reorderLevel ?? 0}"), "${p.gstRate?.toStringAsFixed(1) ?? '0'}%",
),
], ],
), ),
), ),

View File

@@ -43,7 +43,8 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage(); ref.read(productsProvider.notifier).fetchNextPage();
} }
} }
@@ -54,124 +55,206 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
final categoriesState = ref.watch(productCategoriesProvider); final categoriesState = ref.watch(productCategoriesProvider);
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Products Catalog'), title: const Text(
'Products Catalog',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0, elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
centerTitle: true,
), ),
body: Column( body: Column(
children: [ children: [
Padding( Container(
padding: const EdgeInsets.all(16.0), color: Colors.white,
child: TextField( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
controller: _searchController, child: TextField(
decoration: InputDecoration( controller: _searchController,
hintText: 'Search products by name or SKU...', decoration: InputDecoration(
prefixIcon: const Icon(LucideIcons.search), hintText: 'Search products by name or SKU...',
filled: true, prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
fillColor: Colors.grey.shade100, contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(productsProvider.notifier).search(val);
});
},
), ),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(productsProvider.notifier).search(val);
});
},
), ),
Expanded( ),
child: productsState.when( Expanded(
loading: () => const Center(child: CircularProgressIndicator()), child: productsState.when(
error: (err, stack) => Center(child: Text('Error: $err')), loading: () => const Center(child: CircularProgressIndicator()),
data: (products) { error: (err, stack) => Center(child: Text('Error: $err')),
if (products.isEmpty) { data: (products) {
return RefreshIndicator( if (products.isEmpty) {
onRefresh: () => ref.read(productsProvider.notifier).refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 100),
Center(child: Text('No products found.', style: TextStyle(color: Colors.grey)))
],
),
);
}
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(), onRefresh: () =>
child: ListView.builder( ref.read(productsProvider.notifier).refresh(),
controller: _scrollController, child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator children: const [
padding: const EdgeInsets.symmetric(horizontal: 16), SizedBox(height: 100),
itemBuilder: (context, index) { Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.packageSearch,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No products found.',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: products.length + 1, // +1 for loading indicator
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
if (index == products.length) { if (index == products.length) {
return const SizedBox(height: 80); return const SizedBox(height: 80);
} }
final p = products[index]; final p = products[index];
final catName = categoriesState.value?.firstWhere((c) => c.id == p.categoryId, orElse: () => categoriesState.value!.first).name ?? 'No Category'; final catName =
categoriesState.value
?.firstWhere(
(c) => c.id == p.categoryId,
orElse: () => categoriesState.value!.first,
)
.name ??
'No Category';
return Card( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(
elevation: 2, color: Colors.white,
child: InkWell( borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(16), boxShadow: [
onTap: () { BoxShadow(
Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p))); color: Colors.grey.withOpacity(0.08),
}, blurRadius: 10,
child: Padding( offset: const Offset(0, 4),
padding: const EdgeInsets.all(12.0), ),
child: Row( ],
children: [ ),
_buildProductImage(p), child: Material(
const SizedBox(width: 16), color: Colors.transparent,
Expanded( borderRadius: BorderRadius.circular(20),
child: Column( child: InkWell(
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(20),
children: [ onTap: () {
Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), maxLines: 2, overflow: TextOverflow.ellipsis), Navigator.push(
const SizedBox(height: 4), context,
Row( MaterialPageRoute(
children: [ builder: (_) =>
Container( ProductDetailScreen(product: p),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), ),
decoration: BoxDecoration(color: Colors.blue.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), );
child: Text(catName, style: const TextStyle(color: Colors.blue, fontSize: 12, fontWeight: FontWeight.bold)), },
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_buildProductImage(p),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
p.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
), ),
if (p.sku != null && p.sku!.isNotEmpty) ...[ maxLines: 2,
const SizedBox(width: 8), overflow: TextOverflow.ellipsis,
Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)), ),
] const SizedBox(height: 4),
], Row(
children: [
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.blue.withOpacity(
0.1,
),
borderRadius:
BorderRadius.circular(8),
),
child: Text(
catName,
style: const TextStyle(
color: Colors.blue,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
if (p.sku != null &&
p.sku!.isNotEmpty) ...[
const SizedBox(width: 8),
Text(
p.sku!,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
],
],
),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
(p.currentStock != null)
? 'Stock: ${p.currentStock ?? 0}'
: 'Untracked',
style: TextStyle(
color: (p.currentStock != null)
? Colors.orange
: Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
), ],
Column( ),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${(p.sellingPrice ?? 0).toStringAsFixed(2)}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
p.trackInventory ? 'Stock: ${p.currentStock ?? 0}' : 'Untracked',
style: TextStyle(
color: p.trackInventory ? Colors.orange : Colors.grey,
fontSize: 12,
fontWeight: FontWeight.bold
),
)
],
),
],
), ),
), ),
), ),
@@ -181,12 +264,15 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
); );
}, },
), ),
) ),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () { onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen())); Navigator.push(
context,
MaterialPageRoute(builder: (_) => const AddProductScreen()),
);
}, },
backgroundColor: Theme.of(context).colorScheme.primary, backgroundColor: Theme.of(context).colorScheme.primary,
child: const Icon(LucideIcons.plus, color: Colors.white), child: const Icon(LucideIcons.plus, color: Colors.white),
@@ -207,7 +293,8 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
); );
} }
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content'; final imageUrl =
'${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -216,14 +303,16 @@ class _ProductListScreenState extends ConsumerState<ProductListScreen> {
height: 60, height: 60,
color: Colors.grey[200], color: Colors.grey[200],
child: _token == null child: _token == null
? const Icon(LucideIcons.image, color: Colors.grey) ? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network( : Image.network(
imageUrl, imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'}, headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey), errorBuilder: (context, error, stackTrace) =>
), const Icon(LucideIcons.imageOff, color: Colors.grey),
),
), ),
); );
} }
} }

View File

@@ -11,10 +11,12 @@ class QuickAdjustStockScreen extends ConsumerStatefulWidget {
const QuickAdjustStockScreen({super.key}); const QuickAdjustStockScreen({super.key});
@override @override
ConsumerState<QuickAdjustStockScreen> createState() => _QuickAdjustStockScreenState(); ConsumerState<QuickAdjustStockScreen> createState() =>
_QuickAdjustStockScreenState();
} }
class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen> { class _QuickAdjustStockScreenState
extends ConsumerState<QuickAdjustStockScreen> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
Timer? _debounce; Timer? _debounce;
@@ -28,7 +30,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(productsProvider.notifier).fetchNextPage(); ref.read(productsProvider.notifier).fetchNextPage();
} }
} }
@@ -53,10 +56,7 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
final productsState = ref.watch(productsProvider); final productsState = ref.watch(productsProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Quick Adjust Stock'), elevation: 0),
title: const Text('Quick Adjust Stock'),
elevation: 0,
),
body: Column( body: Column(
children: [ children: [
Padding( Padding(
@@ -66,13 +66,10 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search product to adjust...', hintText: 'Search product to adjust...',
prefixIcon: const Icon(LucideIcons.search), prefixIcon: const Icon(LucideIcons.search),
filled: true, contentPadding: const EdgeInsets.symmetric(
fillColor: Colors.grey.shade100, horizontal: 16,
border: OutlineInputBorder( vertical: 12,
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
), ),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
), ),
onChanged: (val) { onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel(); if (_debounce?.isActive ?? false) _debounce!.cancel();
@@ -89,7 +86,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
data: (products) { data: (products) {
if (products.isEmpty) { if (products.isEmpty) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(), onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView( child: ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: const [ children: const [
@@ -101,7 +99,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
} }
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => ref.read(productsProvider.notifier).refresh(), onRefresh: () =>
ref.read(productsProvider.notifier).refresh(),
child: ListView.builder( child: ListView.builder(
controller: _scrollController, controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -112,43 +111,60 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
return const SizedBox(height: 80); return const SizedBox(height: 80);
} }
final p = products[index]; final p = products[index];
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.grey.shade200), side: BorderSide(color: Colors.grey.shade200),
),
child: ListTile(
contentPadding: const EdgeInsets.all(12),
leading: _buildProductImage(p),
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(p.sku ?? 'No SKU', style: TextStyle(color: Colors.grey.shade600)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${p.currentStock ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.blue),
),
const Text('in stock', style: TextStyle(fontSize: 10, color: Colors.grey)),
],
), ),
onTap: () { child: ListTile(
showModalBottomSheet( contentPadding: const EdgeInsets.all(12),
context: context, leading: _buildProductImage(p),
isScrollControlled: true, title: Text(
backgroundColor: Colors.transparent, p.name,
builder: (context) => AdjustStockSheet(productId: p.id!), style: const TextStyle(fontWeight: FontWeight.bold),
); ),
}, subtitle: Text(
), p.sku ?? 'No SKU',
); style: TextStyle(color: Colors.grey.shade600),
}, ),
), trailing: Column(
); mainAxisAlignment: MainAxisAlignment.center,
}, crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${p.currentStock ?? 0}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Colors.blue,
),
),
const Text(
'in stock',
style: TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) =>
AdjustStockSheet(productId: p.id!),
);
},
),
);
},
),
);
},
), ),
), ),
], ],
@@ -169,7 +185,8 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
); );
} }
final imageUrl = '${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content'; final imageUrl =
'${DioClient().dio.options.baseUrl}/inventory/products/${product.id}/images/${product.imageIds.first}/content';
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -178,13 +195,14 @@ class _QuickAdjustStockScreenState extends ConsumerState<QuickAdjustStockScreen>
height: 50, height: 50,
color: Colors.grey[200], color: Colors.grey[200],
child: _token == null child: _token == null
? const Icon(LucideIcons.image, color: Colors.grey) ? const Icon(LucideIcons.image, color: Colors.grey)
: Image.network( : Image.network(
imageUrl, imageUrl,
fit: BoxFit.cover, fit: BoxFit.cover,
headers: {'Authorization': 'Bearer $_token'}, headers: {'Authorization': 'Bearer $_token'},
errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey), errorBuilder: (context, error, stackTrace) =>
), const Icon(LucideIcons.imageOff, color: Colors.grey),
),
), ),
); );
} }

View File

@@ -1,20 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:kifi_app/features/inventory/domain/stock_movement.dart'; import 'package:kifi_app/features/inventory/domain/inventory_item.dart';
import 'package:kifi_app/features/inventory/domain/product.dart';
import 'package:kifi_app/features/inventory/providers/products_provider.dart'; import 'package:kifi_app/features/inventory/providers/products_provider.dart';
import 'package:kifi_app/features/inventory/providers/product_categories_provider.dart';
import 'package:kifi_app/core/network/dio_client.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:kifi_app/features/vendor/presentation/purchase_order_details_screen.dart';
import 'package:kifi_app/features/vendor/providers/purchase_orders_provider.dart';
class StockLedgerTab extends ConsumerStatefulWidget { class StockLedgerTab extends ConsumerStatefulWidget {
final int productId; final Product product;
const StockLedgerTab({super.key, required this.productId}); const StockLedgerTab({super.key, required this.product});
@override @override
ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState(); ConsumerState<StockLedgerTab> createState() => _StockLedgerTabState();
} }
class _StockLedgerTabState extends ConsumerState<StockLedgerTab> { class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
List<StockMovement>? _ledger; List<InventoryItem>? _items;
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -25,10 +31,12 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
Future<void> _fetchLedger() async { Future<void> _fetchLedger() async {
try { try {
final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId); final items = await ref
.read(productsProvider.notifier)
.fetchInventoryItems(widget.product.id!);
if (mounted) { if (mounted) {
setState(() { setState(() {
_ledger = ledger; _items = items;
_isLoading = false; _isLoading = false;
}); });
} }
@@ -37,7 +45,9 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
} }
} }
} }
@@ -48,59 +58,225 @@ class _StockLedgerTabState extends ConsumerState<StockLedgerTab> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_ledger == null || _ledger!.isEmpty) { if (_items == null || _items!.isEmpty) {
return const Center(child: Text("No stock movements recorded.")); return const Center(child: Text("No stock available."));
} }
final categoriesState = ref.watch(productCategoriesProvider);
final category = categoriesState.value?.firstWhere(
(c) => c.id == widget.product.categoryId,
);
final String unit = category?.baseUnit ?? 'g';
final double currentRate = category?.dailyRate ?? 0.0;
return RefreshIndicator( return RefreshIndicator(
onRefresh: _fetchLedger, onRefresh: _fetchLedger,
child: ListView.builder( child: ListView.builder(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
itemCount: _ledger!.length, itemCount: _items!.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final movement = _ledger![index]; final item = _items![index];
final isAddition = movement.type == 'ADDITION' || movement.type == 'OPENING'; final weight = item.grossWeight ?? 0.0;
final qty = movement.items?.isNotEmpty == true ? movement.items!.first.quantity : 0.0; final purchaseRate = item.purchaseCost ?? 0.0;
final purchasePrice = weight * purchaseRate;
final currentPrice = weight * currentRate;
final gainLoss = currentPrice - purchasePrice;
final isGain = gainLoss >= 0;
return Card( return Card(
elevation: 0, elevation: 0,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
child: ListTile( borderRadius: BorderRadius.circular(12),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)),
leading: Container( ),
padding: const EdgeInsets.all(10), child: Padding(
decoration: BoxDecoration( padding: const EdgeInsets.all(12.0),
color: isAddition ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1), child: Column(
shape: BoxShape.circle, crossAxisAlignment: CrossAxisAlignment.start,
),
child: Icon(
isAddition ? Icons.arrow_downward : Icons.arrow_upward,
color: isAddition ? Colors.green : Colors.red,
),
),
title: Text(movement.type, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(
movement.createdAt != null ? DateFormat('dd MMM yyyy, hh:mm a').format(movement.createdAt!) : '',
style: TextStyle(color: Colors.grey[600]),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Row(
'${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}', crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
fontWeight: FontWeight.bold, Container(
fontSize: 16, width: 40,
color: isAddition ? Colors.green : Colors.red, height: 40,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: FutureBuilder<String?>(
future: DioClient().storage.read(key: 'jwt_token'),
builder: (context, snapshot) {
if (snapshot.hasData && widget.product.imageIds.isNotEmpty) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
'${DioClient().dio.options.baseUrl}/inventory/products/${widget.product.id}/images/${widget.product.imageIds.first}/content',
fit: BoxFit.cover,
headers: {'Authorization': 'Bearer ${snapshot.data}'},
),
);
}
return const Icon(LucideIcons.image, color: Colors.grey, size: 20);
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.createdAt != null
? DateFormat('dd MMM yyyy').format(item.createdAt!)
: '',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
const SizedBox(height: 4),
if (item.purchaseRef != null)
InkWell(
onTap: () async {
try {
var pos = ref.read(purchaseOrdersProvider).value;
if (pos == null || pos.isEmpty) {
pos = await ref.read(purchaseOrdersProvider.future);
}
final po = pos!.firstWhere(
(p) => p.poNumber == item.purchaseRef,
orElse: () => throw Exception('Purchase invoice not found'),
);
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PurchaseOrderDetailsScreen(po: po),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Could not open purchase invoice: $e')),
);
}
}
},
child: Text(
'INV: ${item.purchaseRef}',
style: const TextStyle(
color: Colors.blue,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
),
),
if (item.huid != null && item.huid!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('HUID: ${item.huid}', style: const TextStyle(fontWeight: FontWeight.w500)),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'IN STOCK',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
),
const SizedBox(height: 4),
Text(
'${weight.toStringAsFixed(3)} $unit',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Purchase Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchaseRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Purchase Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${purchasePrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current Rate', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentRate.toStringAsFixed(2)}/$unit', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Current Amt', style: TextStyle(color: Colors.grey[500], fontSize: 12)),
Text('${currentPrice.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.w600)),
],
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isGain ? Colors.green.withValues(alpha: 0.1) : Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isGain ? LucideIcons.trendingUp : LucideIcons.trendingDown,
color: isGain ? Colors.green : Colors.red,
size: 16,
),
const SizedBox(width: 8),
Text(
'${isGain ? 'Gain' : 'Loss'}: ₹${gainLoss.abs().toStringAsFixed(2)}',
style: TextStyle(
color: isGain ? Colors.green : Colors.red,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
), ),
), ),
if (movement.notes != null && movement.notes!.isNotEmpty)
Text(
movement.notes!,
style: TextStyle(color: Colors.grey[500], fontSize: 12),
),
], ],
), ),
), ),

View File

@@ -1,180 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/uoms_provider.dart';
import 'widgets/add_uom_sheet.dart';
class UomsListScreen extends ConsumerStatefulWidget {
const UomsListScreen({super.key});
@override
ConsumerState<UomsListScreen> createState() => _UomsListScreenState();
}
class _UomsListScreenState extends ConsumerState<UomsListScreen> {
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
@override
void dispose() {
_searchController.dispose();
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final uomsState = ref.watch(uomsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Units of Measure'),
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search units of measure...',
prefixIcon: const Icon(LucideIcons.search),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
),
onChanged: (val) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
ref.read(uomsProvider.notifier).search(val);
});
},
),
),
Expanded(
child: uomsState.when(
data: (uoms) {
if (uoms.isEmpty) {
return RefreshIndicator(
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 100),
Icon(LucideIcons.ruler, size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
Center(child: Text('No Units of Measure found', style: TextStyle(color: Colors.grey[600], fontSize: 16))),
],
),
);
}
return RefreshIndicator(
onRefresh: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: uoms.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final uom = uoms[index];
return Dismissible(
key: ValueKey(uom.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(LucideIcons.trash2, color: Colors.white),
),
confirmDismiss: (direction) async {
return await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete UOM'),
content: Text('Are you sure you want to delete ${uom.name}?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Delete', style: TextStyle(color: Colors.red))),
],
),
);
},
onDismissed: (direction) {
ref.read(uomsProvider.notifier).deleteUom(uom.id!);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))
],
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
title: Text(uom.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('Abbreviation: ${uom.abbreviation}'),
),
trailing: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[100],
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.chevronRight, size: 20),
),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => AddUomSheet(uom: uom),
);
},
),
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $e', style: const TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: () => ref.read(uomsProvider.notifier).fetchUoms(),
child: const Text('Retry'),
)
],
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => const AddUomSheet(),
);
},
child: const Icon(LucideIcons.plus),
),
);
}
}

View File

@@ -1,142 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/uom.dart';
import '../../providers/uoms_provider.dart';
class AddUomSheet extends ConsumerStatefulWidget {
final UnitOfMeasure? uom;
const AddUomSheet({super.key, this.uom});
@override
ConsumerState<AddUomSheet> createState() => _AddUomSheetState();
}
class _AddUomSheetState extends ConsumerState<AddUomSheet> {
final _formKey = GlobalKey<FormState>();
late TextEditingController _nameController;
late TextEditingController _abbrevController;
bool _isLoading = false;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.uom?.name ?? '');
_abbrevController = TextEditingController(text: widget.uom?.abbreviation ?? '');
}
@override
void dispose() {
_nameController.dispose();
_abbrevController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final uom = UnitOfMeasure(
id: widget.uom?.id,
name: _nameController.text.trim(),
abbreviation: _abbrevController.text.trim(),
);
if (widget.uom == null) {
await ref.read(uomsProvider.notifier).createUom(uom);
} else {
await ref.read(uomsProvider.notifier).updateUom(widget.uom!.id!, uom);
}
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(widget.uom == null ? 'Unit of Measure created' : 'Unit of Measure updated')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
top: 24,
left: 24,
right: 24,
),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(widget.uom == null ? "Add Unit of Measure" : "Edit Unit of Measure", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: "Name (e.g., Kilogram)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]
),
validator: (val) => val == null || val.isEmpty ? 'Please enter a name' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _abbrevController,
decoration: InputDecoration(
labelText: "Abbreviation (e.g., kg)",
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Colors.blue, width: 2)),
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
filled: true,
fillColor: Colors.grey[100]
),
validator: (val) => val == null || val.isEmpty ? 'Please enter an abbreviation' : null,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(widget.uom == null ? "Save UOM" : "Update UOM", style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/nature_colors.dart';
import '../../providers/product_categories_provider.dart';
class QuickRateUpdateSheet extends ConsumerStatefulWidget {
const QuickRateUpdateSheet({super.key});
@override
ConsumerState<QuickRateUpdateSheet> createState() => _QuickRateUpdateSheetState();
}
class _QuickRateUpdateSheetState extends ConsumerState<QuickRateUpdateSheet> {
String? _selectedCommodityCode;
final _rateController = TextEditingController();
bool _isLoading = false;
@override
void dispose() {
_rateController.dispose();
super.dispose();
}
Future<void> _saveRate() async {
if (_selectedCommodityCode == null || _rateController.text.isEmpty) return;
setState(() => _isLoading = true);
try {
final rate = double.parse(_rateController.text);
await ref.read(productCategoriesProvider.notifier).updateCommodityRate(_selectedCommodityCode!, rate);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Rate updated successfully')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error updating rate: $e')));
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
final categoriesState = ref.watch(productCategoriesProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
final commodityCodes = categoriesState.value
?.where((c) => c.commodityCode != null && c.commodityCode!.isNotEmpty)
.map((c) => c.commodityCode!)
.toSet()
.toList() ?? [];
if (_selectedCommodityCode == null && commodityCodes.isNotEmpty) {
_selectedCommodityCode = commodityCodes.first;
}
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
left: 24,
right: 24,
top: 12,
),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(32)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.3),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 24),
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withOpacity(isDark ? 0.2 : 0.1),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.bolt, color: Colors.orange, size: 28),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Quick Rate Update', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, letterSpacing: -0.5)),
Text('Update live commodity prices', style: TextStyle(fontSize: 14, color: Colors.grey)),
],
),
),
],
),
const SizedBox(height: 32),
if (commodityCodes.isEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.05),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.withOpacity(0.2)),
),
child: const Row(
children: [
Icon(Icons.info_outline, color: Colors.grey),
SizedBox(width: 12),
Expanded(child: Text('No commodities found. Create a leaf category with a commodity code to update rates.', style: TextStyle(color: Colors.grey))),
],
),
)
else ...[
DropdownButtonFormField<String>(
value: _selectedCommodityCode,
decoration: InputDecoration(
labelText: 'Select Commodity',
labelStyle: const TextStyle(fontWeight: FontWeight.w500),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
icon: const Icon(Icons.unfold_more, color: Colors.grey),
items: commodityCodes.map((c) => DropdownMenuItem(value: c, child: Text(c, style: const TextStyle(fontWeight: FontWeight.w600)))).toList(),
onChanged: (val) => setState(() => _selectedCommodityCode = val),
),
const SizedBox(height: 16),
TextField(
controller: _rateController,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
decoration: InputDecoration(
labelText: 'Base Rate (24k / Purity 1)',
labelStyle: const TextStyle(fontWeight: FontWeight.w500),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
prefixIcon: const Icon(Icons.currency_rupee, color: Colors.orange),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _saveRate,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 18),
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2.5))
: const Text('Update Rate', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, letterSpacing: 0.5)),
),
],
],
),
);
}
}

View File

@@ -8,15 +8,16 @@ class ProductCategory {
final String name; final String name;
final int? parentCategoryId; final int? parentCategoryId;
final bool hasChild; final bool hasChild;
final String? commodityCode;
final String? defaultHsn; final String? defaultHsn;
final double? defaultGst; final double? defaultGst;
final bool huidRequired; final bool huidRequired;
final double? defaultMakingCharge; final double? defaultMakingCharge;
final String? makingChargeType; final String? makingChargeType;
final String calculationMethod;
final String baseUnit; final String baseUnit;
final bool isActive; final bool isActive;
final int sortOrder; final int sortOrder;
final double? dailyRate;
ProductCategory({ ProductCategory({
this.id, this.id,
@@ -24,15 +25,16 @@ class ProductCategory {
required this.name, required this.name,
this.parentCategoryId, this.parentCategoryId,
this.hasChild = false, this.hasChild = false,
this.commodityCode,
this.defaultHsn, this.defaultHsn,
this.defaultGst, this.defaultGst,
this.huidRequired = false, this.huidRequired = false,
this.defaultMakingCharge, this.defaultMakingCharge,
this.makingChargeType, this.makingChargeType,
this.calculationMethod = 'UNIT',
this.baseUnit = 'pcs', this.baseUnit = 'pcs',
this.isActive = true, this.isActive = true,
this.sortOrder = 0, this.sortOrder = 0,
this.dailyRate,
}); });
factory ProductCategory.fromJson(Map<String, dynamic> json) { factory ProductCategory.fromJson(Map<String, dynamic> json) {
@@ -42,15 +44,16 @@ class ProductCategory {
name: json['name'], name: json['name'],
parentCategoryId: json['parentCategoryId'], parentCategoryId: json['parentCategoryId'],
hasChild: json['hasChild'] ?? false, hasChild: json['hasChild'] ?? false,
commodityCode: json['commodityCode'],
defaultHsn: json['defaultHsn'], defaultHsn: json['defaultHsn'],
defaultGst: (json['defaultGst'] as num?)?.toDouble(), defaultGst: (json['defaultGst'] as num?)?.toDouble(),
huidRequired: json['huidRequired'] ?? false, huidRequired: json['huidRequired'] ?? false,
defaultMakingCharge: (json['defaultMakingCharge'] as num?)?.toDouble(), defaultMakingCharge: (json['defaultMakingCharge'] as num?)?.toDouble(),
makingChargeType: json['makingChargeType'], makingChargeType: json['makingChargeType'],
calculationMethod: json['calculationMethod'] ?? 'UNIT',
baseUnit: json['baseUnit'] ?? 'pcs', baseUnit: json['baseUnit'] ?? 'pcs',
isActive: json['isActive'] ?? true, isActive: json['isActive'] ?? true,
sortOrder: json['sortOrder'] ?? 0, sortOrder: json['sortOrder'] ?? 0,
dailyRate: (json['dailyRate'] as num?)?.toDouble(),
); );
} }
@@ -61,12 +64,12 @@ class ProductCategory {
'name': name, 'name': name,
'parentCategoryId': parentCategoryId, 'parentCategoryId': parentCategoryId,
'hasChild': hasChild, 'hasChild': hasChild,
'commodityCode': commodityCode,
'defaultHsn': defaultHsn, 'defaultHsn': defaultHsn,
'defaultGst': defaultGst, 'defaultGst': defaultGst,
'huidRequired': huidRequired, 'huidRequired': huidRequired,
'defaultMakingCharge': defaultMakingCharge, 'defaultMakingCharge': defaultMakingCharge,
'makingChargeType': makingChargeType, 'makingChargeType': makingChargeType,
'calculationMethod': calculationMethod,
'baseUnit': baseUnit, 'baseUnit': baseUnit,
'isActive': isActive, 'isActive': isActive,
'sortOrder': sortOrder, 'sortOrder': sortOrder,
@@ -101,9 +104,71 @@ class ProductCategoriesNotifier extends AsyncNotifier<List<ProductCategory>> {
} }
} }
Future<void> updateCategory(int id, ProductCategory category) async {
try {
await DioClient().dio.put(
'/inventory/categories/$id',
data: category.toJson(),
);
state = AsyncValue.data(await _fetchCategories());
} catch (e) {
rethrow;
}
}
Future<void> deleteCategory(int id) async {
try {
await DioClient().dio.delete('/inventory/categories/$id');
state = await AsyncValue.guard(() => _fetchCategories());
} catch (e) {
print('Error deleting category: $e');
rethrow;
}
}
Future<List<dynamic>> fetchRateHistory(int categoryId) async {
final response = await DioClient().dio.get(
'/inventory/categories/$categoryId/rates/history',
);
return response.data as List<dynamic>;
}
Future<void> updateCategoryRate(int categoryId, double rate) async {
await DioClient().dio.post(
'/inventory/categories/$categoryId/rates',
data: {'rate': rate},
);
state = await AsyncValue.guard(() => _fetchCategories());
}
Future<void> updateCommodityRate(String commodityCode, double rate) async {
final categories = state.value ?? [];
final matchingCategories = categories.where(
(c) => c.commodityCode == commodityCode,
);
for (var cat in matchingCategories) {
try {
await DioClient().dio.post(
'/inventory/categories/${cat.id}/rates',
data: {'rate': rate},
);
} catch (e) {
print('Error updating rate for category ${cat.id}: $e');
}
}
state = await AsyncValue.guard(() => _fetchCategories());
}
Future<int> syncRates(int categoryId) async {
final response = await DioClient().dio.post(
'/inventory/categories/$categoryId/rates/sync',
);
state = await AsyncValue.guard(() => _fetchCategories());
return response.data['updatedCount'] ?? 0;
}
} }
final productCategoriesProvider = AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() { final productCategoriesProvider =
return ProductCategoriesNotifier(); AsyncNotifierProvider<ProductCategoriesNotifier, List<ProductCategory>>(() {
}); return ProductCategoriesNotifier();
});

View File

@@ -6,6 +6,7 @@ import '../../../core/network/dio_client.dart';
import '../domain/product.dart'; import '../domain/product.dart';
import '../domain/stock_movement.dart'; import '../domain/stock_movement.dart';
import '../domain/product_bom.dart'; import '../domain/product_bom.dart';
import '../domain/inventory_item.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
@@ -24,20 +25,25 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
return _fetchProducts(page: _currentPage, size: _pageSize); return _fetchProducts(page: _currentPage, size: _pageSize);
} }
Future<List<Product>> _fetchProducts({required int page, required int size}) async { Future<List<Product>> _fetchProducts({
required int page,
required int size,
}) async {
final response = await DioClient().dio.get( final response = await DioClient().dio.get(
'/inventory/products', '/inventory/products',
queryParameters: { queryParameters: {
'page': page, 'page': page,
'size': size, 'size': size,
if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery, if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery,
} },
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
if (data.isNotEmpty) { if (data.isNotEmpty) {
try { try {
final file = File('/Users/maddy/Projects/Kifi/kifi-app/debug_product.json'); final file = File(
'/Users/maddy/Projects/Kifi/kifi-app/debug_product.json',
);
await file.writeAsString(jsonEncode(data.first)); await file.writeAsString(jsonEncode(data.first));
} catch (_) {} } catch (_) {}
} }
@@ -73,7 +79,9 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
_currentPage = 0; _currentPage = 0;
_hasMore = true; _hasMore = true;
try { try {
state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize)); state = AsyncValue.data(
await _fetchProducts(page: _currentPage, size: _pageSize),
);
} catch (e, stack) { } catch (e, stack) {
state = AsyncValue.error(e, stack); state = AsyncValue.error(e, stack);
} }
@@ -102,7 +110,10 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
); );
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), 'file': MultipartFile.fromBytes(
compressedBytes,
filename: image.name,
),
}); });
await DioClient().dio.post( await DioClient().dio.post(
'/inventory/products/$productId/images', '/inventory/products/$productId/images',
@@ -118,7 +129,11 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
} }
} }
Future<void> updateProduct(int id, Product product, {List<XFile>? newImages}) async { Future<void> updateProduct(
int id,
Product product, {
List<XFile>? newImages,
}) async {
try { try {
await DioClient().dio.put( await DioClient().dio.put(
'/inventory/products/$id', '/inventory/products/$id',
@@ -135,7 +150,10 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
); );
final formData = FormData.fromMap({ final formData = FormData.fromMap({
'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), 'file': MultipartFile.fromBytes(
compressedBytes,
filename: image.name,
),
}); });
await DioClient().dio.post( await DioClient().dio.post(
'/inventory/products/$id/images', '/inventory/products/$id/images',
@@ -166,7 +184,9 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
Future<List<StockMovement>> fetchStockLedger(int productId) async { Future<List<StockMovement>> fetchStockLedger(int productId) async {
try { try {
final response = await DioClient().dio.get('/inventory/products/$productId/movements'); final response = await DioClient().dio.get(
'/inventory/products/$productId/movements',
);
if (response.data != null) { if (response.data != null) {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
return data.map((e) => StockMovement.fromJson(e)).toList(); return data.map((e) => StockMovement.fromJson(e)).toList();
@@ -177,9 +197,26 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
} }
} }
Future<List<InventoryItem>> fetchInventoryItems(int productId) async {
try {
final response = await DioClient().dio.get(
'/inventory/items/product/$productId',
);
if (response.data != null) {
final List<dynamic> data = response.data;
return data.map((e) => InventoryItem.fromJson(e)).toList();
}
return [];
} catch (e) {
throw Exception('Failed to fetch inventory items: $e');
}
}
Future<List<ProductBom>> fetchProductBom(int productId) async { Future<List<ProductBom>> fetchProductBom(int productId) async {
try { try {
final response = await DioClient().dio.get('/inventory/products/$productId/bom'); final response = await DioClient().dio.get(
'/inventory/products/$productId/bom',
);
if (response.data != null) { if (response.data != null) {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
return data.map((e) => ProductBom.fromJson(e)).toList(); return data.map((e) => ProductBom.fromJson(e)).toList();
@@ -210,6 +247,8 @@ class ProductsNotifier extends AsyncNotifier<List<Product>> {
} }
} }
final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(() { final productsProvider = AsyncNotifierProvider<ProductsNotifier, List<Product>>(
return ProductsNotifier(); () {
}); return ProductsNotifier();
},
);

View File

@@ -1,96 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/dio_client.dart';
import '../domain/uom.dart';
class UomsNotifier extends AsyncNotifier<List<UnitOfMeasure>> {
List<UnitOfMeasure> _allUoms = [];
@override
Future<List<UnitOfMeasure>> build() async {
return _fetchUoms();
}
Future<List<UnitOfMeasure>> _fetchUoms() async {
final response = await DioClient().dio.get('/inventory/uom').timeout(
const Duration(seconds: 5),
onTimeout: () => throw Exception('Connection timed out'),
);
if (response.data == null || response.data.toString().isEmpty) {
_allUoms = [];
return [];
}
final list = (response.data as List).map((e) => UnitOfMeasure.fromJson(e)).toList();
_allUoms = list;
return list;
}
void search(String query) {
if (query.isEmpty) {
state = AsyncValue.data(_allUoms);
return;
}
final lowerQuery = query.toLowerCase();
final filtered = _allUoms.where((uom) =>
uom.name.toLowerCase().contains(lowerQuery) ||
(uom.abbreviation?.toLowerCase().contains(lowerQuery) ?? false)
).toList();
state = AsyncValue.data(filtered);
}
Future<void> fetchUoms() async {
try {
final data = await _fetchUoms();
state = AsyncValue.data(data);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> createUom(UnitOfMeasure uom) async {
try {
final response = await DioClient().dio.post('/inventory/uom', data: uom.toJson());
final newUom = UnitOfMeasure.fromJson(response.data);
if (state is AsyncData) {
_allUoms = [..._allUoms, newUom];
state = AsyncValue.data([...state.value!, newUom]);
} else {
await fetchUoms();
}
} catch (e) {
throw Exception('Failed to create UOM: $e');
}
}
Future<void> updateUom(int id, UnitOfMeasure uom) async {
try {
final response = await DioClient().dio.put('/inventory/uom/$id', data: uom.toJson());
final updatedUom = UnitOfMeasure.fromJson(response.data);
if (state is AsyncData) {
_allUoms = _allUoms.map((e) => e.id == id ? updatedUom : e).toList();
state = AsyncValue.data(
state.value!.map((e) => e.id == id ? updatedUom : e).toList(),
);
}
} catch (e) {
throw Exception('Failed to update UOM: $e');
}
}
Future<void> deleteUom(int id) async {
try {
await DioClient().dio.delete('/inventory/uom/$id');
if (state is AsyncData) {
_allUoms = _allUoms.where((e) => e.id != id).toList();
state = AsyncValue.data(
state.value!.where((e) => e.id != id).toList(),
);
}
} catch (e) {
throw Exception('Failed to delete UOM: $e');
}
}
}
final uomsProvider = AsyncNotifierProvider<UomsNotifier, List<UnitOfMeasure>>(() {
return UomsNotifier();
});

View File

@@ -17,22 +17,26 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
final List<Map<String, dynamic>> _pages = [ final List<Map<String, dynamic>> _pages = [
{ {
'title': 'Welcome to Kifi', 'title': 'Welcome to Kifi',
'description': 'A beautiful, intelligent way to track your personal finances.', 'description':
'A beautiful, intelligent way to track your personal finances.',
'icon': LucideIcons.wallet, 'icon': LucideIcons.wallet,
}, },
{ {
'title': 'Double-Entry, Simplified', 'title': 'Double-Entry, Simplified',
'description': 'We track money moving from one place to another. Every expense comes from a Wallet (like Cash) and goes to a Category (like Food).', 'description':
'We track money moving from one place to another. Every expense comes from a Wallet (like Cash) and goes to a Category (like Food).',
'icon': LucideIcons.arrowRightLeft, 'icon': LucideIcons.arrowRightLeft,
}, },
{ {
'title': 'Intelligent Automation', 'title': 'Intelligent Automation',
'description': 'Set up recurring transactions and let Kifi handle your monthly subscriptions and salary deposits automatically.', 'description':
'Set up recurring transactions and let Kifi handle your monthly subscriptions and salary deposits automatically.',
'icon': LucideIcons.bot, 'icon': LucideIcons.bot,
}, },
{ {
'title': 'Powerful Insights', 'title': 'Powerful Insights',
'description': 'View day-by-day spending trends and strict monthly budgets to take control of your financial future.', 'description':
'View day-by-day spending trends and strict monthly budgets to take control of your financial future.',
'icon': LucideIcons.barChart2, 'icon': LucideIcons.barChart2,
}, },
]; ];
@@ -76,16 +80,21 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
const SizedBox(height: 60), const SizedBox(height: 60),
Text( Text(
page['title'], page['title'],
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 28), style: Theme.of(
context,
).textTheme.displayLarge?.copyWith(fontSize: 28),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
page['description'], page['description'],
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), ?.copyWith(
height: 1.5, color: Theme.of(
), context,
).colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -109,7 +118,9 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: _currentPage == index color: _currentPage == index
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2), : Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
@@ -126,7 +137,11 @@ class _OnboardingScreenState extends State<OnboardingScreen> {
); );
} }
}, },
child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'), child: Text(
_currentPage == _pages.length - 1
? 'Get Started'
: 'Next',
),
), ),
], ],
), ),

View File

@@ -16,7 +16,9 @@ class ProjectRepository {
return (response.data as List).map((p) => Project.fromJson(p)).toList(); return (response.data as List).map((p) => Project.fromJson(p)).toList();
} on DioException catch (e) { } on DioException catch (e) {
if (e.response?.statusCode == 404) { if (e.response?.statusCode == 404) {
throw Exception("Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image."); throw Exception(
"Projects API endpoint not found. Please ensure the backend server is updated to the latest Docker image.",
);
} }
throw Exception(e.message ?? 'Unknown error fetching projects'); throw Exception(e.message ?? 'Unknown error fetching projects');
} }
@@ -28,12 +30,15 @@ class ProjectRepository {
int? customerId, int? customerId,
double? budget, double? budget,
}) async { }) async {
final response = await _dio.post('/projects', data: { final response = await _dio.post(
'name': name, '/projects',
if (description != null) 'description': description, data: {
if (customerId != null) 'customerId': customerId, 'name': name,
if (budget != null) 'budget': budget, if (description != null) 'description': description,
}); if (customerId != null) 'customerId': customerId,
if (budget != null) 'budget': budget,
},
);
return Project.fromJson(response.data); return Project.fromJson(response.data);
} }
@@ -45,13 +50,16 @@ class ProjectRepository {
double? budget, double? budget,
String? status, String? status,
}) async { }) async {
final response = await _dio.put('/projects/$projectId', data: { final response = await _dio.put(
if (name != null) 'name': name, '/projects/$projectId',
if (description != null) 'description': description, data: {
if (customerId != null) 'customerId': customerId, if (name != null) 'name': name,
if (budget != null) 'budget': budget, if (description != null) 'description': description,
if (status != null) 'status': status, if (customerId != null) 'customerId': customerId,
}); if (budget != null) 'budget': budget,
if (status != null) 'status': status,
},
);
return Project.fromJson(response.data); return Project.fromJson(response.data);
} }
@@ -73,14 +81,17 @@ class ProjectRepository {
bool? isBillable, bool? isBillable,
double? hourlyRate, double? hourlyRate,
}) async { }) async {
final response = await _dio.post('/projects/$projectId/tasks', data: { final response = await _dio.post(
'title': title, '/projects/$projectId/tasks',
if (description != null) 'description': description, data: {
if (assigneeName != null) 'assigneeName': assigneeName, 'title': title,
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, if (description != null) 'description': description,
if (isBillable != null) 'isBillable': isBillable, if (assigneeName != null) 'assigneeName': assigneeName,
if (hourlyRate != null) 'hourlyRate': hourlyRate, if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
}); if (isBillable != null) 'isBillable': isBillable,
if (hourlyRate != null) 'hourlyRate': hourlyRate,
},
);
return ProjectTask.fromJson(response.data); return ProjectTask.fromJson(response.data);
} }
@@ -95,25 +106,31 @@ class ProjectRepository {
double? hourlyRate, double? hourlyRate,
double? hoursLogged, double? hoursLogged,
}) async { }) async {
final response = await _dio.put('/projects/tasks/$taskId', data: { final response = await _dio.put(
if (title != null) 'title': title, '/projects/tasks/$taskId',
if (description != null) 'description': description, data: {
if (assigneeName != null) 'assigneeName': assigneeName, if (title != null) 'title': title,
if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, if (description != null) 'description': description,
if (status != null) 'status': status, if (assigneeName != null) 'assigneeName': assigneeName,
if (isBillable != null) 'isBillable': isBillable, if (assigneeUserId != null) 'assigneeUserId': assigneeUserId,
if (hourlyRate != null) 'hourlyRate': hourlyRate, if (status != null) 'status': status,
if (hoursLogged != null) 'hoursLogged': hoursLogged, if (isBillable != null) 'isBillable': isBillable,
}); if (hourlyRate != null) 'hourlyRate': hourlyRate,
if (hoursLogged != null) 'hoursLogged': hoursLogged,
},
);
return ProjectTask.fromJson(response.data); return ProjectTask.fromJson(response.data);
} }
Future<void> deleteTask(int taskId) async { Future<void> deleteTask(int taskId) async {
await _dio.delete('/projects/tasks/$taskId'); await _dio.delete('/projects/tasks/$taskId');
} }
Future<List<ProjectTaskComment>> getComments(int taskId) async {
Future<List<ProjectTaskComment>> getComments(int taskId) async {
final response = await _dio.get('/projects/tasks/$taskId/comments'); final response = await _dio.get('/projects/tasks/$taskId/comments');
return (response.data as List).map((c) => ProjectTaskComment.fromJson(c)).toList(); return (response.data as List)
.map((c) => ProjectTaskComment.fromJson(c))
.toList();
} }
Future<ProjectTaskComment> addComment({ Future<ProjectTaskComment> addComment({
@@ -121,11 +138,14 @@ Future<List<ProjectTaskComment>> getComments(int taskId) async {
required String content, required String content,
List<Map<String, String>>? attachments, List<Map<String, String>>? attachments,
}) async { }) async {
final response = await _dio.post('/projects/tasks/$taskId/comments', data: { final response = await _dio.post(
'content': content, '/projects/tasks/$taskId/comments',
if (attachments != null && attachments.isNotEmpty) data: {
'imagesJson': jsonEncode(attachments), 'content': content,
}); if (attachments != null && attachments.isNotEmpty)
'imagesJson': jsonEncode(attachments),
},
);
return ProjectTaskComment.fromJson(response.data); return ProjectTaskComment.fromJson(response.data);
} }
} }

View File

@@ -28,7 +28,9 @@ class Project {
userId: json['userId'] as int?, userId: json['userId'] as int?,
budget: (json['budget'] as num?)?.toDouble() ?? 0.0, budget: (json['budget'] as num?)?.toDouble() ?? 0.0,
status: json['status'] as String? ?? 'ACTIVE', status: json['status'] as String? ?? 'ACTIVE',
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
); );
} }

View File

@@ -37,7 +37,9 @@ class ProjectTask {
isBillable: json['isBillable'] as bool? ?? false, isBillable: json['isBillable'] as bool? ?? false,
hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0, hourlyRate: (json['hourlyRate'] as num?)?.toDouble() ?? 0.0,
hoursLogged: (json['hoursLogged'] as num?)?.toDouble() ?? 0.0, hoursLogged: (json['hoursLogged'] as num?)?.toDouble() ?? 0.0,
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
); );
} }

View File

@@ -26,7 +26,10 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: Text(widget.project.name, style: const TextStyle(fontWeight: FontWeight.bold)), title: Text(
widget.project.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
elevation: 0, elevation: 0,
@@ -49,7 +52,12 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
data: (tasks) { data: (tasks) {
final columns = [ final columns = [
_ColumnDef('To Do', 'TODO', Colors.blueGrey, LucideIcons.circle), _ColumnDef('To Do', 'TODO', Colors.blueGrey, LucideIcons.circle),
_ColumnDef('In Progress', 'IN_PROGRESS', Colors.blue, LucideIcons.loader), _ColumnDef(
'In Progress',
'IN_PROGRESS',
Colors.blue,
LucideIcons.loader,
),
_ColumnDef('Review', 'REVIEW', Colors.orange, LucideIcons.eye), _ColumnDef('Review', 'REVIEW', Colors.orange, LucideIcons.eye),
_ColumnDef('Done', 'DONE', Colors.green, LucideIcons.checkCircle), _ColumnDef('Done', 'DONE', Colors.green, LucideIcons.checkCircle),
]; ];
@@ -60,8 +68,14 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: columns.map((col) { children: columns.map((col) {
final columnTasks = tasks.where((t) => t.status == col.status).toList(); final columnTasks = tasks
return _buildColumn(col, columnTasks, showAdd: col.status == 'TODO'); .where((t) => t.status == col.status)
.toList();
return _buildColumn(
col,
columnTasks,
showAdd: col.status == 'TODO',
);
}).toList(), }).toList(),
), ),
); );
@@ -90,7 +104,9 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: col.color.withValues(alpha: 0.08), color: col.color.withValues(alpha: 0.08),
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -106,14 +122,21 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: col.color.withValues(alpha: 0.15), color: col.color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
'${tasks.length}', '${tasks.length}',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: col.color), style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: col.color,
),
), ),
), ),
], ],
@@ -121,41 +144,41 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
), ),
// Task cards // Task cards
...tasks.map((t) => TaskCard( ...tasks.map(
task: t, (t) => TaskCard(
onEdit: () { task: t,
showModalBottomSheet( onEdit: () {
context: context, showModalBottomSheet(
isScrollControlled: true, context: context,
backgroundColor: Colors.transparent, isScrollControlled: true,
builder: (_) => AddTaskSheet( backgroundColor: Colors.transparent,
projectId: widget.project.id, builder: (_) =>
task: t, AddTaskSheet(projectId: widget.project.id, task: t),
), );
); },
}, onGenerateInvoice: () {
onGenerateInvoice: () { Navigator.push(
Navigator.push( context,
context, MaterialPageRoute(
MaterialPageRoute( builder: (_) => InvoiceBuilderScreen(
builder: (_) => InvoiceBuilderScreen( initialCustomerId: widget.project.customerId,
initialCustomerId: widget.project.customerId, initialItems: [
initialItems: [ InvoiceItem(
InvoiceItem( id: 0,
id: 0, invoiceId: 0,
invoiceId: 0, productId: null,
productId: null, description: t.title,
description: t.title, quantity: t.hoursLogged,
quantity: t.hoursLogged, unitPrice: t.hourlyRate,
unitPrice: t.hourlyRate, total: t.hoursLogged * t.hourlyRate,
total: t.hoursLogged * t.hourlyRate, ),
) ],
], ),
), ),
), );
); },
}, ),
)), ),
if (showAdd) if (showAdd)
Padding( Padding(
@@ -166,17 +189,23 @@ class _ProjectBoardScreenState extends ConsumerState<ProjectBoardScreen> {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: col.color, foregroundColor: col.color,
side: BorderSide(color: col.color.withValues(alpha: 0.3)), side: BorderSide(color: col.color.withValues(alpha: 0.3)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
), ),
icon: const Icon(LucideIcons.plus, size: 16), icon: const Icon(LucideIcons.plus, size: 16),
label: const Text('Add Task', style: TextStyle(fontWeight: FontWeight.w600)), label: const Text(
'Add Task',
style: TextStyle(fontWeight: FontWeight.w600),
),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (_) => AddTaskSheet(projectId: widget.project.id), builder: (_) =>
AddTaskSheet(projectId: widget.project.id),
); );
}, },
), ),

View File

@@ -31,20 +31,37 @@ class ProjectHubScreen extends ConsumerWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1), color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)), border: Border.all(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
),
), ),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.layoutDashboard, color: Theme.of(context).colorScheme.primary, size: 32), Icon(
LucideIcons.layoutDashboard,
color: Theme.of(context).colorScheme.primary,
size: 32,
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Agency OS', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), Text(
const Text('Manage projects, tasks, and client billing', style: TextStyle(color: Colors.grey)), 'Agency OS',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
const Text(
'Manage projects, tasks, and client billing',
style: TextStyle(color: Colors.grey),
),
], ],
), ),
), ),
@@ -67,7 +84,10 @@ class ProjectHubScreen extends ConsumerWidget {
'Manage all projects', 'Manage all projects',
LucideIcons.folderKanban, LucideIcons.folderKanban,
Colors.blue, Colors.blue,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProjectsScreen())), () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProjectsScreen()),
),
), ),
_buildActionCard( _buildActionCard(
context, context,
@@ -75,7 +95,10 @@ class ProjectHubScreen extends ConsumerWidget {
'Client billing', 'Client billing',
LucideIcons.receipt, LucideIcons.receipt,
Colors.green, Colors.green,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())), () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const InvoicesListScreen()),
),
), ),
_buildActionCard( _buildActionCard(
context, context,
@@ -83,7 +106,12 @@ class ProjectHubScreen extends ConsumerWidget {
'Manage clients', 'Manage clients',
LucideIcons.users, LucideIcons.users,
NatureColors.getColor('RECEIVABLES'), NatureColors.getColor('RECEIVABLES'),
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())), () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const CustomersListScreen(),
),
),
), ),
_buildActionCard( _buildActionCard(
context, context,
@@ -91,7 +119,10 @@ class ProjectHubScreen extends ConsumerWidget {
'Manage vendors', 'Manage vendors',
LucideIcons.truck, LucideIcons.truck,
Colors.orange, Colors.orange,
() => Navigator.push(context, MaterialPageRoute(builder: (_) => const VendorsListScreen())), () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const VendorsListScreen()),
),
), ),
], ],
), ),
@@ -100,7 +131,14 @@ class ProjectHubScreen extends ConsumerWidget {
); );
} }
Widget _buildActionCard(BuildContext context, String title, String subtitle, IconData icon, Color color, VoidCallback onTap) { Widget _buildActionCard(
BuildContext context,
String title,
String subtitle,
IconData icon,
Color color,
VoidCallback onTap,
) {
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
@@ -116,9 +154,22 @@ class ProjectHubScreen extends ConsumerWidget {
children: [ children: [
Icon(icon, color: color, size: 28), Icon(icon, color: color, size: 28),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 16)), Text(
title,
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(subtitle, style: TextStyle(color: color.withValues(alpha: 0.7), fontSize: 12)), Text(
subtitle,
style: TextStyle(
color: color.withValues(alpha: 0.7),
fontSize: 12,
),
),
], ],
), ),
), ),

View File

@@ -22,7 +22,10 @@ class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Projects', style: TextStyle(fontWeight: FontWeight.bold)), title: const Text(
'Projects',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
@@ -37,12 +40,6 @@ class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search projects...', hintText: 'Search projects...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 14), contentPadding: const EdgeInsets.symmetric(vertical: 14),
), ),
onChanged: (val) { onChanged: (val) {
@@ -53,15 +50,28 @@ class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
Expanded( Expanded(
child: projectsAsync.when( child: projectsAsync.when(
data: (projects) { data: (projects) {
final filteredProjects = projects.where((p) => p.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); final filteredProjects = projects
.where(
(p) => p.name.toLowerCase().contains(
_searchQuery.toLowerCase(),
),
)
.toList();
if (filteredProjects.isEmpty) { if (filteredProjects.isEmpty) {
return Center( return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.folderKanban, size: 48, color: Colors.grey.shade400), Icon(
LucideIcons.folderKanban,
size: 48,
color: Colors.grey.shade400,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('No projects found.', style: TextStyle(color: Colors.grey.shade500)), Text(
'No projects found.',
style: TextStyle(color: Colors.grey.shade500),
),
], ],
), ),
); );
@@ -75,33 +85,56 @@ class _ProjectsScreenState extends ConsumerState<ProjectsScreen> {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
elevation: 0, elevation: 0,
color: Colors.white, color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(
title: Text(p.name, style: const TextStyle(fontWeight: FontWeight.bold)), horizontal: 16,
subtitle: Text(p.description ?? 'No description', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), vertical: 4,
),
title: Text(
p.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
p.description ?? 'No description',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 13,
),
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
icon: Icon(LucideIcons.edit, size: 18, color: Colors.grey.shade600), icon: Icon(
LucideIcons.edit,
size: 18,
color: Colors.grey.shade600,
),
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => AddProjectSheet(project: p), builder: (context) =>
AddProjectSheet(project: p),
); );
}, },
), ),
Icon(Icons.chevron_right, color: Colors.grey.shade400), Icon(
Icons.chevron_right,
color: Colors.grey.shade400,
),
], ],
), ),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ProjectBoardScreen(project: p), builder: (context) =>
ProjectBoardScreen(project: p),
), ),
); );
}, },

View File

@@ -56,131 +56,185 @@ class _AddTaskSheetState extends ConsumerState<AddTaskSheet> {
), ),
child: SafeArea( child: SafeArea(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(_isEditing ? 'Edit Task' : 'New Task', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), Text(
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)), _isEditing ? 'Edit Task' : 'New Task',
], style: const TextStyle(
), fontSize: 20,
const SizedBox(height: 16), fontWeight: FontWeight.bold,
PremiumTextField(
labelText: 'Task Title',
initialValue: _isEditing ? _title : null,
prefixIcon: const Icon(LucideIcons.checkSquare),
validator: (val) => val == null || val.isEmpty ? 'Required' : null,
onSaved: (val) => _title = val!,
),
const SizedBox(height: 16),
PremiumTextField(
maxLines: 3,
labelText: 'Description (Optional)',
initialValue: _isEditing ? _description : null,
prefixIcon: const Icon(LucideIcons.alignLeft),
onSaved: (val) => _description = val ?? '',
),
const SizedBox(height: 16),
Autocomplete<User>(
initialValue: _isEditing && _assigneeName.isNotEmpty
? TextEditingValue(text: _assigneeName)
: null,
optionsBuilder: (TextEditingValue textEditingValue) async {
if (textEditingValue.text.isEmpty) {
return const Iterable<User>.empty();
}
try {
return await ref.read(userSearchProvider(textEditingValue.text).future);
} catch (e) {
return const Iterable<User>.empty();
}
},
displayStringForOption: (User option) => '${option.name} (${option.email})',
onSelected: (User selection) {
_assigneeName = selection.name;
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final User option = options.elementAt(index);
return ListTile(
title: Text(option.name, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(option.email, style: const TextStyle(color: Colors.grey)),
onTap: () => onSelected(option),
);
},
),
), ),
), ),
); IconButton(
}, icon: const Icon(LucideIcons.x),
fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) { onPressed: () => Navigator.pop(context),
return PremiumTextField( ),
controller: textEditingController, ],
focusNode: focusNode, ),
labelText: 'Assignee Name (Optional)',
prefixIcon: const Icon(LucideIcons.user),
onSaved: (val) {
if (_assigneeName.isEmpty) _assigneeName = val ?? '';
},
);
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Is Billable?'),
subtitle: const Text('Task time will be invoiced'),
value: _isBillable,
onChanged: (val) {
setState(() {
_isBillable = val;
});
},
),
if (_isBillable) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
PremiumTextField( PremiumTextField(
keyboardType: const TextInputType.numberWithOptions(decimal: true), labelText: 'Task Title',
labelText: 'Hourly Rate', initialValue: _isEditing ? _title : null,
initialValue: _isEditing && _hourlyRate > 0 ? _hourlyRate.toString() : null, prefixIcon: const Icon(LucideIcons.checkSquare),
prefixIcon: const Icon(LucideIcons.indianRupee), validator: (val) =>
validator: (val) => _isBillable && (val == null || val.isEmpty) ? 'Required for billable tasks' : null, val == null || val.isEmpty ? 'Required' : null,
onSaved: (val) => _hourlyRate = double.tryParse(val ?? '0') ?? 0, onSaved: (val) => _title = val!,
), ),
const SizedBox(height: 16),
PremiumTextField(
maxLines: 3,
labelText: 'Description (Optional)',
initialValue: _isEditing ? _description : null,
prefixIcon: const Icon(LucideIcons.alignLeft),
onSaved: (val) => _description = val ?? '',
),
const SizedBox(height: 16),
Autocomplete<User>(
initialValue: _isEditing && _assigneeName.isNotEmpty
? TextEditingValue(text: _assigneeName)
: null,
optionsBuilder: (TextEditingValue textEditingValue) async {
if (textEditingValue.text.isEmpty) {
return const Iterable<User>.empty();
}
try {
return await ref.read(
userSearchProvider(textEditingValue.text).future,
);
} catch (e) {
return const Iterable<User>.empty();
}
},
displayStringForOption: (User option) =>
'${option.name} (${option.email})',
onSelected: (User selection) {
_assigneeName = selection.name;
},
optionsViewBuilder: (context, onSelected, options) {
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(12),
color: Colors.white,
child: ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 200,
maxWidth: 300,
),
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (BuildContext context, int index) {
final User option = options.elementAt(index);
return ListTile(
title: Text(
option.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
option.email,
style: const TextStyle(color: Colors.grey),
),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
fieldViewBuilder:
(
context,
textEditingController,
focusNode,
onFieldSubmitted,
) {
return PremiumTextField(
controller: textEditingController,
focusNode: focusNode,
labelText: 'Assignee Name (Optional)',
prefixIcon: const Icon(LucideIcons.user),
onSaved: (val) {
if (_assigneeName.isEmpty)
_assigneeName = val ?? '';
},
);
},
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Is Billable?'),
subtitle: const Text('Task time will be invoiced'),
value: _isBillable,
onChanged: (val) {
setState(() {
_isBillable = val;
});
},
),
if (_isBillable) ...[
const SizedBox(height: 16),
PremiumTextField(
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
labelText: 'Hourly Rate',
initialValue: _isEditing && _hourlyRate > 0
? _hourlyRate.toString()
: null,
prefixIcon: const Icon(LucideIcons.indianRupee),
validator: (val) =>
_isBillable && (val == null || val.isEmpty)
? 'Required for billable tasks'
: null,
onSaved: (val) =>
_hourlyRate = double.tryParse(val ?? '0') ?? 0,
),
],
const SizedBox(height: 24),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
_isEditing ? 'Save Changes' : 'Create Task',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 24),
], ],
const SizedBox(height: 24), ),
ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _isLoading ? null : _submit,
child: _isLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(_isEditing ? 'Save Changes' : 'Create Task', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 24),
],
), ),
), ),
), ),
),
); );
} }
@@ -216,7 +270,9 @@ class _AddTaskSheetState extends ConsumerState<AddTaskSheet> {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);

View File

@@ -85,7 +85,10 @@ class TaskCard extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1), color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -121,7 +124,11 @@ class TaskCard extends ConsumerWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
task.description!, task.description!,
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, height: 1.4), style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
height: 1.4,
),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -132,9 +139,13 @@ class TaskCard extends ConsumerWidget {
// Bottom row: assignee + billable badge + invoice button // Bottom row: assignee + billable badge + invoice button
Row( Row(
children: [ children: [
if (task.assigneeName != null && task.assigneeName!.isNotEmpty) ...[ if (task.assigneeName != null &&
task.assigneeName!.isNotEmpty) ...[
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.indigo.withValues(alpha: 0.08), color: Colors.indigo.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -142,11 +153,19 @@ class TaskCard extends ConsumerWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.user, size: 12, color: Colors.indigo.shade400), Icon(
LucideIcons.user,
size: 12,
color: Colors.indigo.shade400,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
task.assigneeName!, task.assigneeName!,
style: TextStyle(fontSize: 11, color: Colors.indigo.shade600, fontWeight: FontWeight.w500), style: TextStyle(
fontSize: 11,
color: Colors.indigo.shade600,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -155,7 +174,10 @@ class TaskCard extends ConsumerWidget {
], ],
if (task.isBillable) if (task.isBillable)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1), color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -163,11 +185,19 @@ class TaskCard extends ConsumerWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(LucideIcons.indianRupee, size: 11, color: Colors.green.shade600), Icon(
LucideIcons.indianRupee,
size: 11,
color: Colors.green.shade600,
),
const SizedBox(width: 2), const SizedBox(width: 2),
Text( Text(
'Billable', 'Billable',
style: TextStyle(color: Colors.green.shade700, fontSize: 11, fontWeight: FontWeight.w500), style: TextStyle(
color: Colors.green.shade700,
fontSize: 11,
fontWeight: FontWeight.w500,
),
), ),
], ],
), ),
@@ -180,10 +210,23 @@ class TaskCard extends ConsumerWidget {
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
backgroundColor: Colors.green.withValues(alpha: 0.1), backgroundColor: Colors.green.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: Icon(
LucideIcons.receipt,
size: 13,
color: Colors.green.shade700,
),
label: Text(
'Invoice',
style: TextStyle(
fontSize: 11,
color: Colors.green.shade700,
fontWeight: FontWeight.w600,
),
), ),
icon: Icon(LucideIcons.receipt, size: 13, color: Colors.green.shade700),
label: Text('Invoice', style: TextStyle(fontSize: 11, color: Colors.green.shade700, fontWeight: FontWeight.w600)),
onPressed: onGenerateInvoice, onPressed: onGenerateInvoice,
), ),
), ),
@@ -219,7 +262,13 @@ class TaskCard extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(task.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), Text(
task.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
leading: const Icon(LucideIcons.eye), leading: const Icon(LucideIcons.eye),
@@ -245,30 +294,45 @@ class TaskCard extends ConsumerWidget {
), ),
ListTile( ListTile(
leading: const Icon(LucideIcons.trash2, color: Colors.red), leading: const Icon(LucideIcons.trash2, color: Colors.red),
title: const Text('Delete Task', style: TextStyle(color: Colors.red)), title: const Text(
'Delete Task',
style: TextStyle(color: Colors.red),
),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final confirm = await showDialog<bool>( final confirm = await showDialog<bool>(
context: context, context: context,
builder: (c) => AlertDialog( builder: (c) => AlertDialog(
title: const Text('Delete Task'), title: const Text('Delete Task'),
content: Text('Are you sure you want to delete "${task.title}"?'), content: Text(
'Are you sure you want to delete "${task.title}"?',
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')), TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel'),
),
TextButton( TextButton(
onPressed: () => Navigator.pop(c, true), onPressed: () => Navigator.pop(c, true),
child: const Text('Delete', style: TextStyle(color: Colors.red)), child: const Text(
'Delete',
style: TextStyle(color: Colors.red),
),
), ),
], ],
), ),
); );
if (confirm == true) { if (confirm == true) {
try { try {
await ref.read(projectRepositoryProvider).deleteTask(task.id); await ref
.read(projectRepositoryProvider)
.deleteTask(task.id);
ref.invalidate(projectTasksProvider(task.projectId)); ref.invalidate(projectTasksProvider(task.projectId));
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} }
} }
@@ -288,18 +352,23 @@ class TaskCard extends ConsumerWidget {
tooltip: 'Change Status', tooltip: 'Change Status',
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
child: Icon(LucideIcons.moreVertical, size: 18, color: Colors.grey.shade500), child: Icon(
LucideIcons.moreVertical,
size: 18,
color: Colors.grey.shade500,
),
onSelected: (newStatus) async { onSelected: (newStatus) async {
if (newStatus != task.status) { if (newStatus != task.status) {
try { try {
await ref.read(projectRepositoryProvider).updateTask( await ref
taskId: task.id, .read(projectRepositoryProvider)
status: newStatus, .updateTask(taskId: task.id, status: newStatus);
);
ref.invalidate(projectTasksProvider(task.projectId)); ref.invalidate(projectTasksProvider(task.projectId));
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update status: $e'))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update status: $e')),
);
} }
} }
} }
@@ -313,7 +382,11 @@ class TaskCard extends ConsumerWidget {
); );
} }
PopupMenuItem<String> _buildStatusMenuItem(String value, String label, Color color) { PopupMenuItem<String> _buildStatusMenuItem(
String value,
String label,
Color color,
) {
return PopupMenuItem<String>( return PopupMenuItem<String>(
value: value, value: value,
child: Row( child: Row(

View File

@@ -63,9 +63,12 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
final bytes = await f.readAsBytes(); final bytes = await f.readAsBytes();
final extension = file.path!.split('.').last.toLowerCase(); final extension = file.path!.split('.').last.toLowerCase();
String contentType = 'application/octet-stream'; String contentType = 'application/octet-stream';
if (['pdf'].contains(extension)) contentType = 'application/pdf'; if (['pdf'].contains(extension))
else if (['doc', 'docx'].contains(extension)) contentType = 'application/msword'; contentType = 'application/pdf';
else if (['png', 'jpg', 'jpeg'].contains(extension)) contentType = 'image/jpeg'; else if (['doc', 'docx'].contains(extension))
contentType = 'application/msword';
else if (['png', 'jpg', 'jpeg'].contains(extension))
contentType = 'image/jpeg';
setState(() { setState(() {
_attachments.add({ _attachments.add({
@@ -98,14 +101,20 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
ref.invalidate(projectTaskCommentsProvider(widget.task.id)); ref.invalidate(projectTaskCommentsProvider(widget.task.id));
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error adding comment: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error adding comment: $e')));
} }
} finally { } finally {
if (mounted) setState(() => _isSubmitting = false); if (mounted) setState(() => _isSubmitting = false);
} }
} }
Future<void> _downloadAttachment(Map<String, dynamic> attMap, int commentId, int attIndex) async { Future<void> _downloadAttachment(
Map<String, dynamic> attMap,
int commentId,
int attIndex,
) async {
final filePath = attMap['filePath']; final filePath = attMap['filePath'];
final fileName = attMap['fileName'] ?? 'attachment'; final fileName = attMap['fileName'] ?? 'attachment';
final contentType = attMap['contentType'] ?? 'application/octet-stream'; final contentType = attMap['contentType'] ?? 'application/octet-stream';
@@ -121,16 +130,21 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
if (_jwtToken != null) { if (_jwtToken != null) {
dio.options.headers['Authorization'] = 'Bearer $_jwtToken'; dio.options.headers['Authorization'] = 'Bearer $_jwtToken';
} }
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=$filePath&contentType=$contentType'; final url =
'https://app.technobeesolutions.in/api/kifi-v2/projects/tasks/attachments/download?filePath=$filePath&contentType=$contentType';
await dio.download(url, savePath); await dio.download(url, savePath);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Downloaded $fileName'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Downloaded $fileName')));
await Share.shareXFiles([XFile(savePath)]); await Share.shareXFiles([XFile(savePath)]);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error downloading: $e'))); ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error downloading: $e')));
} }
} finally { } finally {
if (mounted) { if (mounted) {
@@ -141,7 +155,9 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id)); final commentsAsync = ref.watch(
projectTaskCommentsProvider(widget.task.id),
);
return Container( return Container(
height: MediaQuery.of(context).size.height * 0.9, height: MediaQuery.of(context).size.height * 0.9,
@@ -155,7 +171,8 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
Expanded( Expanded(
child: commentsAsync.when( child: commentsAsync.when(
data: (comments) { data: (comments) {
if (comments.isEmpty) return const Center(child: Text('No comments yet.')); if (comments.isEmpty)
return const Center(child: Text('No comments yet.'));
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: comments.length, itemCount: comments.length,
@@ -170,7 +187,10 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(c.content, style: const TextStyle(fontSize: 14)), Text(
c.content,
style: const TextStyle(fontSize: 14),
),
if (c.images.isNotEmpty) ...[ if (c.images.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
@@ -189,60 +209,111 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
} }
} }
final isImage = isOldBase64 || (parsedData?['contentType']?.startsWith('image/') ?? false); final isImage =
isOldBase64 ||
(parsedData?['contentType']?.startsWith(
'image/',
) ??
false);
if (isImage) { if (isImage) {
ImageProvider provider; ImageProvider provider;
if (isOldBase64 || parsedData?['base64Content'] != null) { if (isOldBase64 ||
final b64 = isOldBase64 ? imgData : parsedData!['base64Content']; parsedData?['base64Content'] != null) {
provider = MemoryImage(base64Decode(b64)); final b64 = isOldBase64
? imgData
: parsedData!['base64Content'];
provider = MemoryImage(base64Decode(b64));
} else { } else {
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=${parsedData!['filePath']}&contentType=${parsedData['contentType']}'; final url =
provider = _jwtToken != null 'https://app.technobeesolutions.in/api/kifi-v2/projects/tasks/attachments/download?filePath=${parsedData!['filePath']}&contentType=${parsedData['contentType']}';
? NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'}) provider = _jwtToken != null
: const AssetImage('assets/images/placeholder.png') as ImageProvider; ? NetworkImage(
url,
headers: {
'Authorization':
'Bearer $_jwtToken',
},
)
: const AssetImage(
'assets/images/placeholder.png',
)
as ImageProvider;
} }
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
// Collect all images for the gallery // Collect all images for the gallery
final allImages = c.images.where((i) { final allImages = c.images
if (!i.startsWith('{')) return true; .where((i) {
try { if (!i.startsWith('{'))
final map = jsonDecode(i);
return map['contentType']?.startsWith('image/') ?? false;
} catch (_) {
return true; return true;
} try {
}).map((i) { final map = jsonDecode(i);
if (!i.startsWith('{')) return MemoryImage(base64Decode(i)) as ImageProvider; return map['contentType']
final map = jsonDecode(i); ?.startsWith(
if (map['base64Content'] != null) return MemoryImage(base64Decode(map['base64Content'])) as ImageProvider; 'image/',
final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=${map['filePath']}&contentType=${map['contentType']}'; ) ??
return NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'}) as ImageProvider; false;
}).toList(); } catch (_) {
return true;
}
})
.map((i) {
if (!i.startsWith('{'))
return MemoryImage(
base64Decode(i),
)
as ImageProvider;
final map = jsonDecode(i);
if (map['base64Content'] != null)
return MemoryImage(
base64Decode(
map['base64Content'],
),
)
as ImageProvider;
final url =
'https://app.technobeesolutions.in/api/kifi-v2/projects/tasks/attachments/download?filePath=${map['filePath']}&contentType=${map['contentType']}';
return NetworkImage(
url,
headers: {
'Authorization':
'Bearer $_jwtToken',
},
)
as ImageProvider;
})
.toList();
// Find index of clicked image among images // Find index of clicked image among images
int realIndex = 0; int realIndex = 0;
for (int i=0; i<=imgIndex; i++) { for (int i = 0; i <= imgIndex; i++) {
final ii = c.images[i]; final ii = c.images[i];
bool img = !ii.startsWith('{'); bool img = !ii.startsWith('{');
if (!img) { if (!img) {
try { try {
final m = jsonDecode(ii); final m = jsonDecode(ii);
img = m['contentType']?.startsWith('image/') ?? false; img =
} catch(_) { img = true; } m['contentType']?.startsWith(
'image/',
) ??
false;
} catch (_) {
img = true;
} }
if (img && i < imgIndex) realIndex++; }
if (img && i < imgIndex) realIndex++;
} }
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => AttachmentGalleryScreen( builder: (context) =>
images: allImages, AttachmentGalleryScreen(
initialIndex: realIndex, images: allImages,
), initialIndex: realIndex,
),
), ),
); );
}, },
@@ -258,14 +329,21 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
); );
} else { } else {
// Non-image file // Non-image file
final fileName = parsedData?['fileName'] ?? 'Document'; final fileName =
parsedData?['fileName'] ?? 'Document';
final uniqueId = c.id * 1000 + imgIndex; final uniqueId = c.id * 1000 + imgIndex;
final isDownloading = _downloadingIds.contains(uniqueId); final isDownloading = _downloadingIds
.contains(uniqueId);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
if (!isDownloading && parsedData != null) { if (!isDownloading &&
_downloadAttachment(parsedData, c.id, imgIndex); parsedData != null) {
_downloadAttachment(
parsedData,
c.id,
imgIndex,
);
} }
}, },
child: Container( child: Container(
@@ -273,21 +351,41 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
height: 80, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(
border: Border.all(color: Colors.grey.shade300), 8,
),
border: Border.all(
color: Colors.grey.shade300,
),
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment:
MainAxisAlignment.center,
children: [ children: [
isDownloading isDownloading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) ? const SizedBox(
: const Icon(LucideIcons.fileText, color: Colors.blue), width: 24,
height: 24,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
LucideIcons.fileText,
color: Colors.blue,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding:
const EdgeInsets.symmetric(
horizontal: 4,
),
child: Text( child: Text(
fileName, fileName,
style: const TextStyle(fontSize: 10), style: const TextStyle(
fontSize: 10,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -298,8 +396,8 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
); );
} }
}).toList(), }).toList(),
) ),
] ],
], ],
), ),
), ),
@@ -320,7 +418,9 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
Widget _buildHeader(BuildContext context) { Widget _buildHeader(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.grey.shade300))), decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -328,13 +428,26 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.task.title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text(
if (widget.task.description != null && widget.task.description!.isNotEmpty) widget.task.title,
Text(widget.task.description!, style: const TextStyle(color: Colors.grey)), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (widget.task.description != null &&
widget.task.description!.isNotEmpty)
Text(
widget.task.description!,
style: const TextStyle(color: Colors.grey),
),
], ],
), ),
), ),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)), IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
], ],
), ),
); );
@@ -362,7 +475,8 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
spacing: 8, spacing: 8,
children: _attachments.asMap().entries.map((entry) { children: _attachments.asMap().entries.map((entry) {
final att = entry.value; final att = entry.value;
final isImg = att['contentType']?.startsWith('image/') ?? false; final isImg =
att['contentType']?.startsWith('image/') ?? false;
return Stack( return Stack(
children: [ children: [
@@ -374,20 +488,29 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: isImg child: isImg
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Image.memory( child: Image.memory(
base64Decode(att['base64Content']!), base64Decode(att['base64Content']!),
fit: BoxFit.cover, fit: BoxFit.cover,
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.fileText,
size: 24,
color: Colors.blue,
),
Text(
att['fileName'] ?? '',
style: const TextStyle(fontSize: 8),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.fileText, size: 24, color: Colors.blue),
Text(att['fileName'] ?? '', style: const TextStyle(fontSize: 8), maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
), ),
Positioned( Positioned(
right: 0, right: 0,
@@ -400,7 +523,11 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
}, },
child: Container( child: Container(
color: Colors.black54, color: Colors.black54,
child: const Icon(LucideIcons.x, size: 16, color: Colors.white), child: const Icon(
LucideIcons.x,
size: 16,
color: Colors.white,
),
), ),
), ),
), ),
@@ -429,7 +556,14 @@ class _TaskDetailsSheetState extends ConsumerState<TaskDetailsSheet> {
), ),
), ),
_isSubmitting _isSubmitting
? const Padding(padding: EdgeInsets.all(12.0), child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))) ? const Padding(
padding: EdgeInsets.all(12.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton( : IconButton(
icon: const Icon(LucideIcons.send, color: Colors.blue), icon: const Icon(LucideIcons.send, color: Colors.blue),
onPressed: _submitComment, onPressed: _submitComment,

View File

@@ -15,12 +15,16 @@ final projectsProvider = FutureProvider<List<Project>>((ref) async {
return repo.getProjects(); return repo.getProjects();
}); });
final projectTasksProvider = FutureProvider.family<List<ProjectTask>, int>((ref, projectId) async { final projectTasksProvider = FutureProvider.family<List<ProjectTask>, int>((
ref,
projectId,
) async {
final repo = ref.watch(projectRepositoryProvider); final repo = ref.watch(projectRepositoryProvider);
return repo.getTasks(projectId); return repo.getTasks(projectId);
}); });
final projectTaskCommentsProvider = FutureProvider.family<List<ProjectTaskComment>, int>((ref, taskId) async { final projectTaskCommentsProvider =
final repo = ref.watch(projectRepositoryProvider); FutureProvider.family<List<ProjectTaskComment>, int>((ref, taskId) async {
return repo.getComments(taskId); final repo = ref.watch(projectRepositoryProvider);
}); return repo.getComments(taskId);
});

View File

@@ -3,11 +3,17 @@ import '../../../../core/network/dio_client.dart';
import '../../../../core/domain/user.dart'; import '../../../../core/domain/user.dart';
import 'dart:async'; import 'dart:async';
final userSearchProvider = FutureProvider.family<List<User>, String>((ref, query) async { final userSearchProvider = FutureProvider.family<List<User>, String>((
ref,
query,
) async {
if (query.isEmpty) return []; if (query.isEmpty) return [];
final dio = DioClient().dio; final dio = DioClient().dio;
final response = await dio.get('/users/search', queryParameters: {'q': query}); final response = await dio.get(
'/users/search',
queryParameters: {'q': query},
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List data = response.data; final List data = response.data;

View File

@@ -48,7 +48,9 @@ class Customer {
fatherName: json['fatherName'], fatherName: json['fatherName'],
gender: json['gender'], gender: json['gender'],
age: json['age'], age: json['age'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
? DateTime.parse(json['createdAt'])
: null,
); );
} }

View File

@@ -164,7 +164,9 @@ class Invoice {
discountTotal: json['discountTotal']?.toDouble() ?? 0.0, discountTotal: json['discountTotal']?.toDouble() ?? 0.0,
totalAmount: json['totalAmount'].toDouble(), totalAmount: json['totalAmount'].toDouble(),
amountPaid: json['amountPaid']?.toDouble() ?? 0.0, amountPaid: json['amountPaid']?.toDouble() ?? 0.0,
nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null, nextPaymentDate: json['nextPaymentDate'] != null
? DateTime.parse(json['nextPaymentDate'])
: null,
paymentMethod: json['paymentMethod'], paymentMethod: json['paymentMethod'],
paymentWalletId: json['paymentWalletId'], paymentWalletId: json['paymentWalletId'],
status: json['status'] ?? 'DRAFT', status: json['status'] ?? 'DRAFT',
@@ -172,9 +174,17 @@ class Invoice {
isEmi: json['isEmi'] ?? false, isEmi: json['isEmi'] ?? false,
emiAmount: json['emiAmount']?.toDouble(), emiAmount: json['emiAmount']?.toDouble(),
emiCycle: json['emiCycle'], emiCycle: json['emiCycle'],
emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) : null, emiStartDate: json['emiStartDate'] != null
items: json['items'] != null ? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList() : [], ? DateTime.parse(json['emiStartDate'])
payments: json['payments'] != null ? (json['payments'] as List).map((i) => InvoicePayment.fromJson(i)).toList() : null, : null,
items: json['items'] != null
? (json['items'] as List).map((i) => InvoiceItem.fromJson(i)).toList()
: [],
payments: json['payments'] != null
? (json['payments'] as List)
.map((i) => InvoicePayment.fromJson(i))
.toList()
: null,
); );
} }
@@ -184,13 +194,17 @@ class Invoice {
if (customerId != null) data['customerId'] = customerId; if (customerId != null) data['customerId'] = customerId;
data['invoiceNumber'] = invoiceNumber; data['invoiceNumber'] = invoiceNumber;
data['issueDate'] = issueDate.toIso8601String().split('T')[0]; data['issueDate'] = issueDate.toIso8601String().split('T')[0];
if (dueDate != null) data['dueDate'] = dueDate!.toIso8601String().split('T')[0]; if (dueDate != null)
data['dueDate'] = dueDate!.toIso8601String().split('T')[0];
data['subtotal'] = subtotal; data['subtotal'] = subtotal;
data['taxTotal'] = taxTotal; data['taxTotal'] = taxTotal;
data['discountTotal'] = discountTotal; data['discountTotal'] = discountTotal;
data['totalAmount'] = totalAmount; data['totalAmount'] = totalAmount;
if (amountPaid > 0) data['amountPaid'] = amountPaid; if (amountPaid > 0) data['amountPaid'] = amountPaid;
if (nextPaymentDate != null) data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split('T')[0]; if (nextPaymentDate != null)
data['nextPaymentDate'] = nextPaymentDate!.toIso8601String().split(
'T',
)[0];
if (paymentMethod != null) data['paymentMethod'] = paymentMethod; if (paymentMethod != null) data['paymentMethod'] = paymentMethod;
if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId; if (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId;
data['status'] = status; data['status'] = status;
@@ -198,9 +212,11 @@ class Invoice {
data['isEmi'] = isEmi; data['isEmi'] = isEmi;
if (emiAmount != null) data['emiAmount'] = emiAmount; if (emiAmount != null) data['emiAmount'] = emiAmount;
if (emiCycle != null) data['emiCycle'] = emiCycle; if (emiCycle != null) data['emiCycle'] = emiCycle;
if (emiStartDate != null) data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0]; if (emiStartDate != null)
data['emiStartDate'] = emiStartDate!.toIso8601String().split('T')[0];
data['items'] = items.map((i) => i.toJson()).toList(); data['items'] = items.map((i) => i.toJson()).toList();
if (payments != null) data['payments'] = payments!.map((i) => i.toJson()).toList(); if (payments != null)
data['payments'] = payments!.map((i) => i.toJson()).toList();
return data; return data;
} }
} }
@@ -229,7 +245,9 @@ class InvoicePayment {
id: json['id'], id: json['id'],
invoiceId: json['invoiceId'], invoiceId: json['invoiceId'],
amount: json['amount'].toDouble(), amount: json['amount'].toDouble(),
paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null, paymentDate: json['paymentDate'] != null
? DateTime.parse(json['paymentDate'])
: null,
paymentMethod: json['paymentMethod'] ?? 'Cash', paymentMethod: json['paymentMethod'] ?? 'Cash',
emiInstallmentNumber: json['emiInstallmentNumber'], emiInstallmentNumber: json['emiInstallmentNumber'],
walletId: json['walletId'], walletId: json['walletId'],
@@ -241,9 +259,11 @@ class InvoicePayment {
if (id != null) data['id'] = id; if (id != null) data['id'] = id;
if (invoiceId != null) data['invoiceId'] = invoiceId; if (invoiceId != null) data['invoiceId'] = invoiceId;
data['amount'] = amount; data['amount'] = amount;
if (paymentDate != null) data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0]; if (paymentDate != null)
data['paymentDate'] = paymentDate!.toIso8601String().split('T')[0];
data['paymentMethod'] = paymentMethod; data['paymentMethod'] = paymentMethod;
if (emiInstallmentNumber != null) data['emiInstallmentNumber'] = emiInstallmentNumber; if (emiInstallmentNumber != null)
data['emiInstallmentNumber'] = emiInstallmentNumber;
if (walletId != null) data['walletId'] = walletId; if (walletId != null) data['walletId'] = walletId;
return data; return data;
} }

View File

@@ -7,7 +7,8 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; import 'package:flutter_secure_storage/flutter_secure_storage.dart'
as import_storage;
import '../../../core/widgets/premium_text_field.dart'; import '../../../core/widgets/premium_text_field.dart';
import '../../../core/widgets/smart_search_dropdown.dart'; import '../../../core/widgets/smart_search_dropdown.dart';
import '../../business/providers/indian_states_provider.dart'; import '../../business/providers/indian_states_provider.dart';
@@ -45,9 +46,15 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
_emailCtrl = TextEditingController(text: widget.customer?.email ?? ''); _emailCtrl = TextEditingController(text: widget.customer?.email ?? '');
_addressCtrl = TextEditingController(text: widget.customer?.address ?? ''); _addressCtrl = TextEditingController(text: widget.customer?.address ?? '');
_gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? ''); _gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? '');
_idNumberCtrl = TextEditingController(text: widget.customer?.idNumber ?? ''); _idNumberCtrl = TextEditingController(
_fatherNameCtrl = TextEditingController(text: widget.customer?.fatherName ?? ''); text: widget.customer?.idNumber ?? '',
_ageCtrl = TextEditingController(text: widget.customer?.age?.toString() ?? ''); );
_fatherNameCtrl = TextEditingController(
text: widget.customer?.fatherName ?? '',
);
_ageCtrl = TextEditingController(
text: widget.customer?.age?.toString() ?? '',
);
_selectedGender = widget.customer?.gender; _selectedGender = widget.customer?.gender;
_selectedStateId = widget.customer?.stateId; _selectedStateId = widget.customer?.stateId;
import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) {
@@ -79,7 +86,9 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
void _showImagePickerModal() { void _showImagePickerModal() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea( builder: (ctx) => SafeArea(
child: Wrap( child: Wrap(
children: [ children: [
@@ -126,13 +135,23 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
try { try {
if (widget.customer == null) { if (widget.customer == null) {
await ref.read(customersProvider.notifier).addCustomer(customer, photo: _photo); await ref
.read(customersProvider.notifier)
.addCustomer(customer, photo: _photo);
} else { } else {
await ref.read(customersProvider.notifier).updateCustomer(widget.customer!.id!, customer, photo: _photo); await ref
.read(customersProvider.notifier)
.updateCustomer(widget.customer!.id!, customer, photo: _photo);
} }
if (mounted) { if (mounted) {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(widget.customer == null ? 'Customer added' : 'Customer updated'))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
widget.customer == null ? 'Customer added' : 'Customer updated',
),
),
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@@ -142,9 +161,12 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
title: const Text('Error'), title: const Text('Error'),
content: Text(e.toString()), content: Text(e.toString()),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK')) TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('OK'),
),
], ],
) ),
); );
} }
} finally { } finally {
@@ -181,7 +203,13 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text(widget.customer == null ? "New Customer" : "Edit Customer", style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), Text(
widget.customer == null ? "New Customer" : "Edit Customer",
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
], ],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -195,14 +223,22 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
backgroundColor: Colors.grey[200], backgroundColor: Colors.grey[200],
backgroundImage: _photo != null backgroundImage: _photo != null
? FileImage(File(_photo!.path)) as ImageProvider ? FileImage(File(_photo!.path)) as ImageProvider
: (widget.customer?.photoUrl != null && _token != null : (widget.customer?.photoUrl != null &&
? NetworkImage( _token != null
'${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content', ? NetworkImage(
headers: {'Authorization': 'Bearer $_token'}, '${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content',
) headers: {
: null), 'Authorization': 'Bearer $_token',
child: _photo == null && widget.customer?.photoUrl == null },
? const Icon(LucideIcons.user, size: 50, color: Colors.grey) )
: null),
child:
_photo == null && widget.customer?.photoUrl == null
? const Icon(
LucideIcons.user,
size: 50,
color: Colors.grey,
)
: null, : null,
), ),
Positioned( Positioned(
@@ -214,7 +250,11 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
color: Colors.blue, color: Colors.blue,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Icon(LucideIcons.camera, color: Colors.white, size: 16), child: const Icon(
LucideIcons.camera,
color: Colors.white,
size: 16,
),
), ),
), ),
], ],
@@ -226,7 +266,8 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
controller: _nameCtrl, controller: _nameCtrl,
labelText: 'Customer Name *', labelText: 'Customer Name *',
prefixIcon: const Icon(LucideIcons.user), prefixIcon: const Icon(LucideIcons.user),
validator: (val) => val == null || val.isEmpty ? 'Required' : null, validator: (val) =>
val == null || val.isEmpty ? 'Required' : null,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
PremiumTextField( PremiumTextField(
@@ -255,7 +296,10 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey[100], color: Colors.grey[100],
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -266,9 +310,13 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
isExpanded: true, isExpanded: true,
hint: const Text('Gender'), hint: const Text('Gender'),
items: ['Male', 'Female', 'Other'] items: ['Male', 'Female', 'Other']
.map((g) => DropdownMenuItem(value: g, child: Text(g))) .map(
(g) =>
DropdownMenuItem(value: g, child: Text(g)),
)
.toList(), .toList(),
onChanged: (val) => setState(() => _selectedGender = val), onChanged: (val) =>
setState(() => _selectedGender = val),
), ),
), ),
), ),
@@ -290,7 +338,9 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
validator: (val) { validator: (val) {
if (val == null || val.isEmpty) return null; // Optional if (val == null || val.isEmpty) return null; // Optional
final RegExp gstRegExp = RegExp(r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$'); final RegExp gstRegExp = RegExp(
r'^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$',
);
if (!gstRegExp.hasMatch(val.toUpperCase())) { if (!gstRegExp.hasMatch(val.toUpperCase())) {
return 'Invalid GSTIN format'; return 'Invalid GSTIN format';
} }
@@ -314,13 +364,14 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
labelText: 'State', labelText: 'State',
hintText: 'Select State', hintText: 'Select State',
value: _selectedStateId, value: _selectedStateId,
fillColor: Colors.grey[100],
items: states.map((s) => s.id).toList(), items: states.map((s) => s.id).toList(),
itemAsString: (id) { itemAsString: (id) {
final s = states.firstWhere((st) => st.id == id); final s = states.where((st) => st.id == id).firstOrNull;
if (s == null) return 'Unknown';
return '${s.name} (${s.gstCode})'; return '${s.name} (${s.gstCode})';
}, },
onChanged: (val) => setState(() => _selectedStateId = val), onChanged: (val) =>
setState(() => _selectedStateId = val),
); );
}, },
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator(),
@@ -344,11 +395,24 @@ class _AddCustomerSheetState extends ConsumerState<AddCustomerSheet> {
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
child: _isLoading child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: Text(widget.customer == null ? 'Save Customer' : 'Update Customer'), width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: Text(
widget.customer == null
? 'Save Customer'
: 'Update Customer',
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -11,7 +11,8 @@ class CustomerLedgerScreen extends ConsumerStatefulWidget {
const CustomerLedgerScreen({super.key, required this.customer}); const CustomerLedgerScreen({super.key, required this.customer});
@override @override
ConsumerState<CustomerLedgerScreen> createState() => _CustomerLedgerScreenState(); ConsumerState<CustomerLedgerScreen> createState() =>
_CustomerLedgerScreenState();
} }
class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> { class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
@@ -31,10 +32,14 @@ class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
data: (invoices) { data: (invoices) {
final customerInvoices = invoices.where((i) => i.customerId == widget.customer.id).toList(); final customerInvoices = invoices
.where((i) => i.customerId == widget.customer.id)
.toList();
if (customerInvoices.isEmpty) { if (customerInvoices.isEmpty) {
return const Center(child: Text('No invoices found for this customer.')); return const Center(
child: Text('No invoices found for this customer.'),
);
} }
// Sort chronological // Sort chronological
@@ -48,51 +53,126 @@ class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
for (var inv in customerInvoices) { for (var inv in customerInvoices) {
// Add row for Invoice (Debit) // Add row for Invoice (Debit)
runningBalance += inv.totalAmount; runningBalance += inv.totalAmount;
rows.add(DataRow( rows.add(
cells: [ DataRow(
DataCell(Text('${index++}')), cells: [
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))), DataCell(Text('${index++}')),
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))), DataCell(
const DataCell(Text('Invoice')), Text(DateFormat('dd MMM yyyy').format(inv.issueDate)),
const DataCell(Text('-')), ),
const DataCell(Text('-')), DataCell(
DataCell(Text(formatCurrency.format(inv.totalAmount), style: TextStyle(color: Colors.red.shade700, fontWeight: FontWeight.bold))), Text(
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))), inv.invoiceNumber,
], style: const TextStyle(fontWeight: FontWeight.w600),
)); ),
),
const DataCell(Text('Invoice')),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(
Text(
formatCurrency.format(inv.totalAmount),
style: TextStyle(
color: Colors.red.shade700,
fontWeight: FontWeight.bold,
),
),
),
DataCell(
Text(
formatCurrency.format(runningBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
// Add row for Payment (Credit) // Add row for Payment (Credit)
if (inv.payments != null && inv.payments!.isNotEmpty) { if (inv.payments != null && inv.payments!.isNotEmpty) {
for (var p in inv.payments!) { for (var p in inv.payments!) {
runningBalance -= p.amount; runningBalance -= p.amount;
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name; final walletName = wallets
rows.add(DataRow( .firstWhere(
cells: [ (w) => w.id == p.walletId,
DataCell(Text('${index++}')), orElse: () => wallets.first,
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : DateFormat('dd MMM yyyy').format(inv.issueDate))), )
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))), .name;
const DataCell(Text('Payment')), rows.add(
DataCell(Text(p.paymentMethod)), DataRow(
DataCell(Text(p.walletId != null ? walletName : '-')), cells: [
DataCell(Text(formatCurrency.format(p.amount), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))), DataCell(Text('${index++}')),
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))), DataCell(
], Text(
)); p.paymentDate != null
? DateFormat('dd MMM yyyy').format(p.paymentDate!)
: DateFormat('dd MMM yyyy').format(inv.issueDate),
),
),
DataCell(
Text(
inv.invoiceNumber,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const DataCell(Text('Payment')),
DataCell(Text(p.paymentMethod)),
DataCell(Text(p.walletId != null ? walletName : '-')),
DataCell(
Text(
formatCurrency.format(p.amount),
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
DataCell(
Text(
formatCurrency.format(runningBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
} }
} else if (inv.amountPaid > 0) { } else if (inv.amountPaid > 0) {
runningBalance -= inv.amountPaid; runningBalance -= inv.amountPaid;
rows.add(DataRow( rows.add(
cells: [ DataRow(
DataCell(Text('${index++}')), cells: [
DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))), DataCell(Text('${index++}')),
DataCell(Text(inv.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.w600))), DataCell(
const DataCell(Text('Payment')), Text(DateFormat('dd MMM yyyy').format(inv.issueDate)),
const DataCell(Text('-')), ),
const DataCell(Text('-')), DataCell(
DataCell(Text(formatCurrency.format(inv.amountPaid), style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))), Text(
DataCell(Text(formatCurrency.format(runningBalance), style: const TextStyle(fontWeight: FontWeight.bold))), inv.invoiceNumber,
], style: const TextStyle(fontWeight: FontWeight.w600),
)); ),
),
const DataCell(Text('Payment')),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(
Text(
formatCurrency.format(inv.amountPaid),
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
DataCell(
Text(
formatCurrency.format(runningBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
} }
} }
@@ -104,14 +184,19 @@ class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
children: [ children: [
const Text('Outstanding Balance', style: TextStyle(color: Colors.grey, fontSize: 14)), const Text(
'Outstanding Balance',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
formatCurrency.format(runningBalance), formatCurrency.format(runningBalance),
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: runningBalance > 0 ? Colors.red.shade700 : Colors.green.shade700, color: runningBalance > 0
? Colors.red.shade700
: Colors.green.shade700,
), ),
), ),
], ],
@@ -123,17 +208,59 @@ class _CustomerLedgerScreenState extends ConsumerState<CustomerLedgerScreen> {
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), headingRowColor: WidgetStateProperty.resolveWith(
(states) => Colors.grey.shade100,
),
columnSpacing: 24, columnSpacing: 24,
columns: const [ columns: const [
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), label: Text(
DataColumn(label: Text('Invoice #', style: TextStyle(fontWeight: FontWeight.bold))), 'S.No',
DataColumn(label: Text('Type', style: TextStyle(fontWeight: FontWeight.bold))), style: TextStyle(fontWeight: FontWeight.bold),
DataColumn(label: Text('Mode', style: TextStyle(fontWeight: FontWeight.bold))), ),
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))), ),
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))), label: Text(
'Date',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Invoice #',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Type',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Mode',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Wallet',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Amount',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Balance',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
], ],
rows: rows, rows: rows,
), ),

View File

@@ -7,7 +7,8 @@ import '../../../core/widgets/shimmer_loading.dart';
import 'add_customer_sheet.dart'; import 'add_customer_sheet.dart';
import 'customer_ledger_screen.dart'; import 'customer_ledger_screen.dart';
import '../providers/invoices_provider.dart'; import '../providers/invoices_provider.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; import 'package:flutter_secure_storage/flutter_secure_storage.dart'
as import_storage;
import '../../../core/network/dio_client.dart'; import '../../../core/network/dio_client.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -15,7 +16,8 @@ class CustomersListScreen extends ConsumerStatefulWidget {
const CustomersListScreen({super.key}); const CustomersListScreen({super.key});
@override @override
ConsumerState<CustomersListScreen> createState() => _CustomersListScreenState(); ConsumerState<CustomersListScreen> createState() =>
_CustomersListScreenState();
} }
class _CustomersListScreenState extends ConsumerState<CustomersListScreen> { class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
@@ -53,7 +55,10 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Customers', style: TextStyle(fontWeight: FontWeight.bold)), title: const Text(
'Customers',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
@@ -69,12 +74,6 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search by name or phone...', hintText: 'Search by name or phone...',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 14), contentPadding: const EdgeInsets.symmetric(vertical: 14),
), ),
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
@@ -99,7 +98,10 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
children: [ children: [
Icon(LucideIcons.users, size: 64, color: Colors.grey), Icon(LucideIcons.users, size: 64, color: Colors.grey),
SizedBox(height: 16), SizedBox(height: 16),
Text('No customers found.', style: TextStyle(color: Colors.grey, fontSize: 16)), Text(
'No customers found.',
style: TextStyle(color: Colors.grey, fontSize: 16),
),
], ],
), ),
); );
@@ -107,7 +109,9 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await ref.read(customersProvider.notifier).refresh(_searchCtrl.text); await ref
.read(customersProvider.notifier)
.refresh(_searchCtrl.text);
}, },
child: ListView.builder( child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -122,8 +126,12 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
double outstandingBalance = 0.0; double outstandingBalance = 0.0;
if (invoicesState.hasValue) { if (invoicesState.hasValue) {
for (var inv in invoicesState.value!) { for (var inv in invoicesState.value!) {
if (inv.customerId == customer.id && inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.status != 'DRAFT') { if (inv.customerId == customer.id &&
outstandingBalance += (inv.totalAmount - inv.amountPaid); inv.status != 'PAID' &&
inv.status != 'CANCELLED' &&
inv.status != 'DRAFT') {
outstandingBalance +=
(inv.totalAmount - inv.amountPaid);
} }
} }
} }
@@ -134,7 +142,11 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [ boxShadow: [
BoxShadow(color: Colors.grey.withOpacity(0.08), blurRadius: 10, offset: const Offset(0, 4)), BoxShadow(
color: Colors.grey.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
], ],
), ),
child: Material( child: Material(
@@ -143,12 +155,20 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => CustomerLedgerScreen(customer: customer))); Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CustomerLedgerScreen(
customer: customer,
),
),
);
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
// Avatar // Avatar
Hero( Hero(
@@ -156,14 +176,29 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
child: CircleAvatar( child: CircleAvatar(
radius: 30, radius: 30,
backgroundColor: Colors.blue.shade50, backgroundColor: Colors.blue.shade50,
backgroundImage: (customer.photoUrl != null && _token != null) backgroundImage:
(customer.photoUrl != null &&
_token != null)
? NetworkImage( ? NetworkImage(
'${DioClient().dio.options.baseUrl}/customers/${customer.id}/photo/content', '${DioClient().dio.options.baseUrl}/customers/${customer.id}/photo/content',
headers: {'Authorization': 'Bearer $_token'}, headers: {
'Authorization':
'Bearer $_token',
},
) )
: null, : null,
child: (customer.photoUrl == null) child: (customer.photoUrl == null)
? Text(customer.name.isNotEmpty ? customer.name[0].toUpperCase() : '?', style: TextStyle(color: Colors.blue.shade700, fontWeight: FontWeight.bold, fontSize: 24)) ? Text(
customer.name.isNotEmpty
? customer.name[0]
.toUpperCase()
: '?',
style: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
fontSize: 24,
),
)
: null, : null,
), ),
), ),
@@ -171,44 +206,102 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
// Details // Details
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
CrossAxisAlignment.start,
children: [ children: [
Text(customer.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black87)), Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
color: Colors.black87,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
if (customer.phone != null && customer.phone!.isNotEmpty) if (customer.phone != null &&
customer.phone!.isNotEmpty)
Row( Row(
children: [ children: [
Icon(LucideIcons.phone, size: 14, color: Colors.grey.shade600), Icon(
LucideIcons.phone,
size: 14,
color: Colors.grey.shade600,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(customer.phone!, style: TextStyle(color: Colors.grey.shade700, fontSize: 14)), Text(
customer.phone!,
style: TextStyle(
color:
Colors.grey.shade700,
fontSize: 14,
),
),
], ],
), ),
if (customer.gstin != null && customer.gstin!.isNotEmpty) if (customer.gstin != null &&
customer.gstin!.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(
top: 4,
),
child: Row( child: Row(
children: [ children: [
Icon(LucideIcons.building, size: 14, color: Colors.grey.shade600), Icon(
LucideIcons.building,
size: 14,
color:
Colors.grey.shade600,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(customer.gstin!, style: TextStyle(color: Colors.grey.shade700, fontSize: 13, fontWeight: FontWeight.w600)), Text(
customer.gstin!,
style: TextStyle(
color: Colors
.grey
.shade700,
fontSize: 13,
fontWeight:
FontWeight.w600,
),
),
], ],
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), padding:
const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: outstandingBalance > 0 ? Colors.red.shade50 : Colors.green.shade50, color:
borderRadius: BorderRadius.circular(8), outstandingBalance > 0
? Colors.red.shade50
: Colors.green.shade50,
borderRadius:
BorderRadius.circular(
8,
),
), ),
child: Text( child: Text(
outstandingBalance > 0 ? 'Pending: ${formatCurrency.format(outstandingBalance)}' : 'Settled', outstandingBalance > 0
? 'Pending: ${formatCurrency.format(outstandingBalance)}'
: 'Settled',
style: TextStyle( style: TextStyle(
color: outstandingBalance > 0 ? Colors.red.shade700 : Colors.green.shade700, color:
fontWeight: FontWeight.bold, outstandingBalance > 0
? Colors.red.shade700
: Colors
.green
.shade700,
fontWeight:
FontWeight.bold,
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -216,39 +309,87 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
Row( Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(LucideIcons.edit2, size: 20, color: Colors.blue), icon: const Icon(
visualDensity: VisualDensity.compact, LucideIcons.edit2,
constraints: const BoxConstraints(), size: 20,
color: Colors.blue,
),
visualDensity:
VisualDensity.compact,
constraints:
const BoxConstraints(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled:
backgroundColor: Colors.transparent, true,
builder: (context) => AddCustomerSheet(customer: customer), backgroundColor:
Colors
.transparent,
builder: (context) =>
AddCustomerSheet(
customer:
customer,
),
); );
}, },
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
IconButton( IconButton(
icon: const Icon(LucideIcons.trash2, size: 20, color: Colors.redAccent), icon: const Icon(
visualDensity: VisualDensity.compact, LucideIcons.trash2,
constraints: const BoxConstraints(), size: 20,
color: Colors.redAccent,
),
visualDensity:
VisualDensity.compact,
constraints:
const BoxConstraints(),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () { onPressed: () {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Delete Customer'), title: const Text(
content: Text('Are you sure you want to delete ${customer.name}? This action cannot be undone.'), 'Delete Customer',
),
content: Text(
'Are you sure you want to delete ${customer.name}? This action cannot be undone.',
),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), TextButton(
onPressed: () =>
Navigator.pop(
ctx,
),
child:
const Text(
'Cancel',
),
),
TextButton( TextButton(
onPressed: () { onPressed: () {
ref.read(customersProvider.notifier).deleteCustomer(customer.id!); ref
Navigator.pop(ctx); .read(
customersProvider
.notifier,
)
.deleteCustomer(
customer
.id!,
);
Navigator.pop(
ctx,
);
}, },
child: const Text('Delete', style: TextStyle(color: Colors.red)), child: const Text(
'Delete',
style: TextStyle(
color: Colors
.red,
),
),
), ),
], ],
), ),
@@ -288,7 +429,10 @@ class _CustomersListScreenState extends ConsumerState<CustomersListScreen> {
); );
}, },
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: const Text('Add Customer', style: TextStyle(fontWeight: FontWeight.bold)), label: const Text(
'Add Customer',
style: TextStyle(fontWeight: FontWeight.bold),
),
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
), ),
); );

View File

@@ -32,31 +32,52 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
Color _getStatusColor(Invoice invoice) { Color _getStatusColor(Invoice invoice) {
if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple; if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple;
switch (invoice.status) { switch (invoice.status) {
case 'DRAFT': return Colors.grey; case 'DRAFT':
case 'FINALIZED': return Colors.orange; return Colors.grey;
case 'PAID': return Colors.green; case 'FINALIZED':
case 'PARTIAL': return Colors.blue; return Colors.orange;
case 'OVERDUE': return Colors.red; case 'PAID':
case 'CANCELLED': return Colors.black; return Colors.green;
default: return Colors.grey; case 'PARTIAL':
return Colors.blue;
case 'OVERDUE':
return Colors.red;
case 'CANCELLED':
return Colors.black;
default:
return Colors.grey;
} }
} }
void _showPaymentHistory(BuildContext context, WidgetRef ref, Invoice invoice) { void _showPaymentHistory(
BuildContext context,
WidgetRef ref,
Invoice invoice,
) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) { builder: (ctx) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('Payment History: ${invoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), Text(
'Payment History: ${invoice.invoiceNumber}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
FutureBuilder<List<InvoicePayment>>( FutureBuilder<List<InvoicePayment>>(
future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(invoice.id!), future: ref
.read(invoicesProvider.notifier)
.fetchPaymentsForInvoice(invoice.id!),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@@ -76,22 +97,67 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: DataTable( child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), headingRowColor: WidgetStateProperty.resolveWith(
(states) => Colors.grey.shade100,
),
columnSpacing: 24, columnSpacing: 24,
columns: const [ columns: const [
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), DataColumn(
DataColumn(label: Text('Wallet', style: TextStyle(fontWeight: FontWeight.bold))), label: Text(
DataColumn(label: Text('Method', style: TextStyle(fontWeight: FontWeight.bold))), 'Date',
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Wallet',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Method',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
DataColumn(
label: Text(
'Amount',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
], ],
rows: payments.map((p) { rows: payments.map((p) {
final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name; final walletName = wallets
.firstWhere(
(w) => w.id == p.walletId,
orElse: () => wallets.first,
)
.name;
return DataRow( return DataRow(
cells: [ cells: [
DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')), DataCell(
DataCell(Text(p.walletId != null ? walletName : '-')), Text(
p.paymentDate != null
? DateFormat(
'dd MMM yyyy',
).format(p.paymentDate!)
: '-',
),
),
DataCell(
Text(p.walletId != null ? walletName : '-'),
),
DataCell(Text(p.paymentMethod ?? '-')), DataCell(Text(p.paymentMethod ?? '-')),
DataCell(Text('${p.amount.toStringAsFixed(2)}', style: TextStyle(color: Colors.green.shade700, fontWeight: FontWeight.bold))), DataCell(
Text(
'${p.amount.toStringAsFixed(2)}',
style: TextStyle(
color: Colors.green.shade700,
fontWeight: FontWeight.bold,
),
),
),
], ],
); );
}).toList(), }).toList(),
@@ -138,28 +204,26 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey[100], backgroundColor: Colors.grey[100],
appBar: AppBar( appBar: AppBar(
title: const Text('Invoices'), title: const Text(
'Invoices',
style: TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0, elevation: 0,
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
centerTitle: true,
), ),
body: Column( body: Column(
children: [ children: [
Container( Container(
color: Colors.white, color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search by Invoice # or Customer', hintText: 'Search by Invoice # or Customer',
prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), prefixIcon: const Icon(LucideIcons.search, color: Colors.grey),
filled: true, contentPadding: const EdgeInsets.symmetric(vertical: 14),
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(vertical: 12),
suffixIcon: _searchQuery.isNotEmpty suffixIcon: _searchQuery.isNotEmpty
? IconButton( ? IconButton(
icon: const Icon(LucideIcons.x, size: 20), icon: const Icon(LucideIcons.x, size: 20),
@@ -192,9 +256,16 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
error: (err, stack) => Center(child: Text('Error: $err')), error: (err, stack) => Center(child: Text('Error: $err')),
data: (allInvoices) { data: (allInvoices) {
final invoices = allInvoices.where((invoice) { final invoices = allInvoices.where((invoice) {
final matchesInvoice = invoice.invoiceNumber.toLowerCase().contains(_searchQuery); final matchesInvoice = invoice.invoiceNumber
final customer = customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first); .toLowerCase()
final customerName = invoice.customerId != null ? customer.name.toLowerCase() : ''; .contains(_searchQuery);
final customer = customers.firstWhere(
(c) => c.id == invoice.customerId,
orElse: () => customers.first,
);
final customerName = invoice.customerId != null
? customer.name.toLowerCase()
: '';
final matchesCustomer = customerName.contains(_searchQuery); final matchesCustomer = customerName.contains(_searchQuery);
return matchesInvoice || matchesCustomer; return matchesInvoice || matchesCustomer;
}).toList(); }).toList();
@@ -208,7 +279,26 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: const [ children: const [
SizedBox(height: 100), SizedBox(height: 100),
Center(child: Text('No invoices found.', style: TextStyle(color: Colors.grey))), Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.receipt,
size: 64,
color: Colors.grey,
),
SizedBox(height: 16),
Text(
'No invoices found.',
style: TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
],
),
),
], ],
), ),
); );
@@ -224,139 +314,270 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
itemCount: invoices.length, itemCount: invoices.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final invoice = invoices[index]; final invoice = invoices[index];
double remaining = invoice.totalAmount - invoice.amountPaid; double remaining =
invoice.totalAmount - invoice.amountPaid;
final customer = invoice.customerId != null final customer = invoice.customerId != null
? customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first) ? customers.firstWhere(
(c) => c.id == invoice.customerId,
orElse: () => customers.first,
)
: null; : null;
return Card( return Container(
elevation: 0, margin: const EdgeInsets.only(bottom: 16),
margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), color: Colors.white,
child: InkWell( borderRadius: BorderRadius.circular(20),
onTap: () { boxShadow: [
Navigator.push( BoxShadow(
context, color: Colors.grey.withOpacity(0.08),
MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)), blurRadius: 10,
); offset: const Offset(0, 4),
}, ),
borderRadius: BorderRadius.circular(16), ],
child: Padding( ),
padding: const EdgeInsets.all(16), child: Material(
child: Column( color: Colors.transparent,
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(20),
children: [ child: InkWell(
Row( onTap: () {
mainAxisAlignment: MainAxisAlignment.spaceBetween, Navigator.push(
children: [ context,
Column( MaterialPageRoute(
crossAxisAlignment: CrossAxisAlignment.start, builder: (_) =>
children: [ InvoiceDetailsScreen(invoice: invoice),
Text(invoice.invoiceNumber, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), ),
if (customer != null) );
Text(customer.name, style: TextStyle(color: Colors.grey[700], fontSize: 13, fontWeight: FontWeight.w600)), },
], borderRadius: BorderRadius.circular(20),
), child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
invoice.invoiceNumber,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (customer != null)
Text(
customer.name,
style: TextStyle(
color: Colors.grey[700],
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getStatusColor(
invoice,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(
12,
),
),
child: Text(
_getStatusLabel(invoice),
style: TextStyle(
color: _getStatusColor(invoice),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'Issued: ${formatDate.format(invoice.issueDate)}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
),
if (invoice.dueDate != null)
Text(
'Due: ${formatDate.format(invoice.dueDate!)}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
),
if (invoice.nextPaymentDate != null &&
remaining > 0)
Text(
'Next Pmt: ${formatDate.format(invoice.nextPaymentDate!)}',
style: TextStyle(
color: Colors.orange.shade700,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
],
),
Column(
crossAxisAlignment:
CrossAxisAlignment.end,
children: [
Text(
formatCurrency.format(
invoice.totalAmount,
),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
if (invoice.amountPaid > 0)
Text(
'Paid: ${formatCurrency.format(invoice.amountPaid)}',
style: TextStyle(
color: Colors.green.shade700,
fontSize: 12,
),
),
if (remaining > 0 &&
invoice.status != 'DRAFT')
Text(
'Bal: ${formatCurrency.format(remaining)}',
style: TextStyle(
color: Colors.red.shade700,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
if (invoice.isEmi &&
invoice.emiAmount != null) ...[
const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getStatusColor(invoice).withOpacity(0.1), color: Colors.purple.withOpacity(0.05),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
), border: Border.all(
child: Text( color: Colors.purple.withOpacity(0.2),
_getStatusLabel(invoice),
style: TextStyle(color: _getStatusColor(invoice), fontSize: 12, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Issued: ${formatDate.format(invoice.issueDate)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
if (invoice.dueDate != null)
Text('Due: ${formatDate.format(invoice.dueDate!)}', style: TextStyle(color: Colors.grey[600], fontSize: 13)),
if (invoice.nextPaymentDate != null && remaining > 0)
Text('Next Pmt: ${formatDate.format(invoice.nextPaymentDate!)}', style: TextStyle(color: Colors.orange.shade700, fontSize: 13, fontWeight: FontWeight.bold)),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
formatCurrency.format(invoice.totalAmount),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
), ),
if (invoice.amountPaid > 0) ),
Text( child: Row(
'Paid: ${formatCurrency.format(invoice.amountPaid)}', children: [
style: TextStyle(color: Colors.green.shade700, fontSize: 12), const Icon(
LucideIcons.calendarClock,
size: 16,
color: Colors.purple,
), ),
if (remaining > 0 && invoice.status != 'DRAFT') const SizedBox(width: 8),
Text( Text(
'Bal: ${formatCurrency.format(remaining)}', 'EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}',
style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(
color: Colors.purple,
fontSize: 12,
),
), ),
], ],
),
), ),
], ],
), const Divider(height: 24),
if (invoice.isEmi && invoice.emiAmount != null) ...[ Row(
const SizedBox(height: 8), children: [
Container( if (invoice.amountPaid > 0)
padding: const EdgeInsets.all(8), Expanded(
decoration: BoxDecoration( child: OutlinedButton.icon(
color: Colors.purple.withOpacity(0.05), onPressed: () =>
borderRadius: BorderRadius.circular(8), _showPaymentHistory(
border: Border.all(color: Colors.purple.withOpacity(0.2)), context,
), ref,
child: Row( invoice,
children: [ ),
const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple), icon: const Icon(
LucideIcons.history,
size: 14,
),
label: const Text(
'History',
style: TextStyle(fontSize: 13),
),
style: OutlinedButton.styleFrom(
foregroundColor:
Colors.blue.shade700,
side: BorderSide(
color: Colors.blue.shade200,
),
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
),
),
),
if (invoice.amountPaid > 0 &&
remaining > 0)
const SizedBox(width: 8), const SizedBox(width: 8),
Text('EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}', style: const TextStyle(color: Colors.purple, fontSize: 12)), if (remaining > 0)
], Expanded(
), child: ElevatedButton.icon(
) onPressed: () =>
_showReceivePaymentSheet(
context,
invoice,
),
icon: const Icon(
LucideIcons.indianRupee,
size: 14,
),
label: const Text(
'Receive',
style: TextStyle(fontSize: 13),
),
style: ElevatedButton.styleFrom(
backgroundColor:
Colors.blue.shade50,
foregroundColor:
Colors.blue.shade700,
elevation: 0,
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
),
),
),
],
),
], ],
const Divider(height: 24), ),
Row(
children: [
if (invoice.amountPaid > 0)
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showPaymentHistory(context, ref, invoice),
icon: const Icon(LucideIcons.history, size: 14),
label: const Text('History', style: TextStyle(fontSize: 13)),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.blue.shade700,
side: BorderSide(color: Colors.blue.shade200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
if (invoice.amountPaid > 0 && remaining > 0) const SizedBox(width: 8),
if (remaining > 0)
Expanded(
child: ElevatedButton.icon(
onPressed: () => _showReceivePaymentSheet(context, invoice),
icon: const Icon(LucideIcons.indianRupee, size: 14),
label: const Text('Receive', style: TextStyle(fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue.shade50,
foregroundColor: Colors.blue.shade700,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
),
),
),
],
),
],
), ),
), ),
), ),
@@ -371,7 +592,10 @@ class _InvoicesListScreenState extends ConsumerState<InvoicesListScreen> {
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () { onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen())); Navigator.push(
context,
MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()),
);
}, },
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: const Text('Create Invoice'), label: const Text('Create Invoice'),

View File

@@ -12,7 +12,8 @@ class ReceivePaymentSheet extends ConsumerStatefulWidget {
const ReceivePaymentSheet({super.key, required this.invoice}); const ReceivePaymentSheet({super.key, required this.invoice});
@override @override
ConsumerState<ReceivePaymentSheet> createState() => _ReceivePaymentSheetState(); ConsumerState<ReceivePaymentSheet> createState() =>
_ReceivePaymentSheetState();
} }
class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> { class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
@@ -41,7 +42,9 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
} }
}).toList(); }).toList();
if (wallets.isNotEmpty && (_selectedWalletId == null || !wallets.any((w) => w.id == _selectedWalletId))) { if (wallets.isNotEmpty &&
(_selectedWalletId == null ||
!wallets.any((w) => w.id == _selectedWalletId))) {
_selectedWalletId = wallets.first.id; _selectedWalletId = wallets.first.id;
} }
@@ -63,8 +66,14 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text('Receive Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const Text(
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)), 'Receive Payment',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () => Navigator.pop(context),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -74,7 +83,10 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Receive Into Wallet', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)), const Text(
'Receive Into Wallet',
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -88,14 +100,19 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
isExpanded: true, isExpanded: true,
hint: const Text('Select Wallet'), hint: const Text('Select Wallet'),
items: wallets items: wallets
.map((w) => DropdownMenuItem(value: w.id, child: Text(w.name))) .map(
(w) => DropdownMenuItem(value: w.id, child: Text(w.name)),
)
.toList(), .toList(),
onChanged: (val) => setState(() => _selectedWalletId = val), onChanged: (val) => setState(() => _selectedWalletId = val),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Payment Method', style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey)), const Text(
'Payment Method',
style: TextStyle(fontWeight: FontWeight.w600, color: Colors.grey),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
@@ -129,36 +146,64 @@ class _ReceivePaymentSheetState extends ConsumerState<ReceivePaymentSheet> {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
onPressed: _isLoading ? null : () async { onPressed: _isLoading
final amount = double.tryParse(_amountCtrl.text) ?? 0; ? null
if (amount <= 0) return; : () async {
final amount = double.tryParse(_amountCtrl.text) ?? 0;
if (amount <= 0) return;
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await ref.read(invoicesProvider.notifier).addPayment( await ref
widget.invoice.id!, .read(invoicesProvider.notifier)
InvoicePayment( .addPayment(
amount: amount, widget.invoice.id!,
paymentMethod: _paymentMethod, InvoicePayment(
walletId: _selectedWalletId, amount: amount,
), paymentMethod: _paymentMethod,
); walletId: _selectedWalletId,
if (mounted) { ),
Navigator.pop(context, true); // true indicates success );
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment Received Successfully!'))); if (mounted) {
} Navigator.pop(
} catch (e) { context,
if (mounted) { true,
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); ); // true indicates success
setState(() => _isLoading = false); ScaffoldMessenger.of(context).showSnackBar(
} const SnackBar(
} content: Text('Payment Received Successfully!'),
}, ),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toString())));
setState(() => _isLoading = false);
}
}
},
child: _isLoading child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) ? const SizedBox(
: const Text('Confirm Payment', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Confirm Payment',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -17,7 +17,9 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
try { try {
final response = await DioClient().dio.get( final response = await DioClient().dio.get(
'/customers', '/customers',
queryParameters: search != null && search.isNotEmpty ? {'search': search} : null, queryParameters: search != null && search.isNotEmpty
? {'search': search}
: null,
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
@@ -55,7 +57,9 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
await refresh(); await refresh();
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception('Failed to add customer: ${e.response?.statusCode} - ${e.response?.data}'); throw Exception(
'Failed to add customer: ${e.response?.statusCode} - ${e.response?.data}',
);
} }
throw Exception('Failed to add customer: $e'); throw Exception('Failed to add customer: $e');
} }
@@ -63,10 +67,7 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
Future<void> updateCustomer(int id, Customer customer, {XFile? photo}) async { Future<void> updateCustomer(int id, Customer customer, {XFile? photo}) async {
try { try {
await DioClient().dio.put( await DioClient().dio.put('/customers/$id', data: customer.toJson());
'/customers/$id',
data: customer.toJson(),
);
if (photo != null) { if (photo != null) {
await _uploadPhoto(id, photo); await _uploadPhoto(id, photo);
@@ -75,7 +76,9 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
await refresh(); await refresh();
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception('Failed to update customer: ${e.response?.statusCode} - ${e.response?.data}'); throw Exception(
'Failed to update customer: ${e.response?.statusCode} - ${e.response?.data}',
);
} }
throw Exception('Failed to update customer: $e'); throw Exception('Failed to update customer: $e');
} }
@@ -94,10 +97,7 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name), 'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name),
}); });
await DioClient().dio.post( await DioClient().dio.post('/customers/$customerId/photo', data: formData);
'/customers/$customerId/photo',
data: formData,
);
} }
Future<void> deleteCustomer(int id) async { Future<void> deleteCustomer(int id) async {
@@ -110,6 +110,7 @@ class CustomersNotifier extends AsyncNotifier<List<Customer>> {
} }
} }
final customersProvider = AsyncNotifierProvider<CustomersNotifier, List<Customer>>(() { final customersProvider =
return CustomersNotifier(); AsyncNotifierProvider<CustomersNotifier, List<Customer>>(() {
}); return CustomersNotifier();
});

View File

@@ -36,14 +36,13 @@ class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
Future<void> createInvoice(Invoice invoice) async { Future<void> createInvoice(Invoice invoice) async {
try { try {
await DioClient().dio.post( await DioClient().dio.post('/invoices', data: invoice.toJson());
'/invoices',
data: invoice.toJson(),
);
await refresh(); await refresh();
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception('Failed to create invoice: ${e.response?.statusCode} - ${e.response?.data}'); throw Exception(
'Failed to create invoice: ${e.response?.statusCode} - ${e.response?.data}',
);
} }
throw Exception('Failed to create invoice: $e'); throw Exception('Failed to create invoice: $e');
} }
@@ -67,7 +66,9 @@ class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
await refresh(); await refresh();
} catch (e) { } catch (e) {
if (e is DioException) { if (e is DioException) {
throw Exception('Failed to add payment: ${e.response?.statusCode} - ${e.response?.data}'); throw Exception(
'Failed to add payment: ${e.response?.statusCode} - ${e.response?.data}',
);
} }
throw Exception('Failed to add payment: $e'); throw Exception('Failed to add payment: $e');
} }
@@ -75,7 +76,9 @@ class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
Future<List<InvoicePayment>> fetchPaymentsForInvoice(int invoiceId) async { Future<List<InvoicePayment>> fetchPaymentsForInvoice(int invoiceId) async {
try { try {
final response = await DioClient().dio.get('/invoices/$invoiceId/payments'); final response = await DioClient().dio.get(
'/invoices/$invoiceId/payments',
);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final List<dynamic> data = response.data; final List<dynamic> data = response.data;
return data.map((e) => InvoicePayment.fromJson(e)).toList(); return data.map((e) => InvoicePayment.fromJson(e)).toList();
@@ -88,6 +91,8 @@ class InvoicesNotifier extends AsyncNotifier<List<Invoice>> {
} }
} }
final invoicesProvider = AsyncNotifierProvider<InvoicesNotifier, List<Invoice>>(() { final invoicesProvider = AsyncNotifierProvider<InvoicesNotifier, List<Invoice>>(
return InvoicesNotifier(); () {
}); return InvoicesNotifier();
},
);

View File

@@ -970,7 +970,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
image: DecorationImage( image: DecorationImage(
image: _jwtToken != null image: _jwtToken != null
? NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}) ? NetworkImage('https://app.technobeesolutions.in/api/kifi-v2/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})
: const AssetImage('assets/images/placeholder.png') as ImageProvider, : const AssetImage('assets/images/placeholder.png') as ImageProvider,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
@@ -1087,7 +1087,7 @@ class _AddTransactionScreenState extends ConsumerState<AddTransactionScreen> {
// Add existing attachments // Add existing attachments
for (final att in existingAttachments) { for (final att in existingAttachments) {
if (_jwtToken != null) { if (_jwtToken != null) {
allImages.add(NetworkImage('https://app.technobeesolutions.in/api/kifi/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'})); allImages.add(NetworkImage('https://app.technobeesolutions.in/api/kifi-v2/transactions/${widget.transaction!.id}/attachments/${att.id}/content', headers: {'Authorization': 'Bearer $_jwtToken'}));
} else { } else {
allImages.add(const AssetImage('assets/images/placeholder.png')); allImages.add(const AssetImage('assets/images/placeholder.png'));
} }

View File

@@ -10,6 +10,9 @@ class PurchaseOrder {
final DateTime? dueDate; final DateTime? dueDate;
final double subtotal; final double subtotal;
final double taxTotal; final double taxTotal;
final double? cgstTotal;
final double? sgstTotal;
final double? igstTotal;
final double discountTotal; final double discountTotal;
final double totalAmount; final double totalAmount;
final String status; final String status;
@@ -32,6 +35,9 @@ class PurchaseOrder {
this.dueDate, this.dueDate,
this.subtotal = 0.0, this.subtotal = 0.0,
this.taxTotal = 0.0, this.taxTotal = 0.0,
this.cgstTotal,
this.sgstTotal,
this.igstTotal,
this.discountTotal = 0.0, this.discountTotal = 0.0,
this.totalAmount = 0.0, this.totalAmount = 0.0,
this.status = 'PENDING', this.status = 'PENDING',
@@ -52,21 +58,36 @@ class PurchaseOrder {
userId: json['userId'], userId: json['userId'],
vendorId: json['vendorId'], vendorId: json['vendorId'],
poNumber: json['poNumber'] ?? '', poNumber: json['poNumber'] ?? '',
issueDate: json['issueDate'] != null ? DateTime.parse(json['issueDate']) : DateTime.now(), issueDate: json['issueDate'] != null
? DateTime.parse(json['issueDate'])
: DateTime.now(),
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null, dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
subtotal: (json['subtotal'] ?? 0.0).toDouble(), subtotal: (json['subtotal'] ?? 0.0).toDouble(),
taxTotal: (json['taxTotal'] ?? 0.0).toDouble(), taxTotal: (json['taxTotal'] ?? 0.0).toDouble(),
cgstTotal: json['cgstTotal'] != null ? (json['cgstTotal']).toDouble() : null,
sgstTotal: json['sgstTotal'] != null ? (json['sgstTotal']).toDouble() : null,
igstTotal: json['igstTotal'] != null ? (json['igstTotal']).toDouble() : null,
discountTotal: (json['discountTotal'] ?? 0.0).toDouble(), discountTotal: (json['discountTotal'] ?? 0.0).toDouble(),
totalAmount: (json['totalAmount'] ?? 0.0).toDouble(), totalAmount: (json['totalAmount'] ?? 0.0).toDouble(),
status: json['status'] ?? 'PENDING', status: json['status'] ?? 'PENDING',
notes: json['notes'], notes: json['notes'],
createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, createdAt: json['createdAt'] != null
updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, ? DateTime.parse(json['createdAt'])
: null,
updatedAt: json['updatedAt'] != null
? DateTime.parse(json['updatedAt'])
: null,
poDate: json['poDate'] != null ? DateTime.parse(json['poDate']) : null, poDate: json['poDate'] != null ? DateTime.parse(json['poDate']) : null,
amountPaid: (json['amountPaid'] ?? 0.0).toDouble(), amountPaid: (json['amountPaid'] ?? 0.0).toDouble(),
nextPaymentDate: json['nextPaymentDate'] != null ? DateTime.parse(json['nextPaymentDate']) : null, nextPaymentDate: json['nextPaymentDate'] != null
? DateTime.parse(json['nextPaymentDate'])
: null,
vendorInvoiceUrl: json['vendorInvoiceUrl'], vendorInvoiceUrl: json['vendorInvoiceUrl'],
items: json['items'] != null ? (json['items'] as List).map((i) => PurchaseOrderItem.fromJson(i)).toList() : [], items: json['items'] != null
? (json['items'] as List)
.map((i) => PurchaseOrderItem.fromJson(i))
.toList()
: [],
vendor: json['vendor'] != null ? Vendor.fromJson(json['vendor']) : null, vendor: json['vendor'] != null ? Vendor.fromJson(json['vendor']) : null,
); );
} }
@@ -81,6 +102,9 @@ class PurchaseOrder {
'dueDate': dueDate?.toIso8601String(), 'dueDate': dueDate?.toIso8601String(),
'subtotal': subtotal, 'subtotal': subtotal,
'taxTotal': taxTotal, 'taxTotal': taxTotal,
'cgstTotal': cgstTotal,
'sgstTotal': sgstTotal,
'igstTotal': igstTotal,
'discountTotal': discountTotal, 'discountTotal': discountTotal,
'totalAmount': totalAmount, 'totalAmount': totalAmount,
'status': status, 'status': status,
@@ -102,6 +126,9 @@ class PurchaseOrder {
DateTime? dueDate, DateTime? dueDate,
double? subtotal, double? subtotal,
double? taxTotal, double? taxTotal,
double? cgstTotal,
double? sgstTotal,
double? igstTotal,
double? discountTotal, double? discountTotal,
double? totalAmount, double? totalAmount,
String? status, String? status,
@@ -122,6 +149,9 @@ class PurchaseOrder {
dueDate: dueDate ?? this.dueDate, dueDate: dueDate ?? this.dueDate,
subtotal: subtotal ?? this.subtotal, subtotal: subtotal ?? this.subtotal,
taxTotal: taxTotal ?? this.taxTotal, taxTotal: taxTotal ?? this.taxTotal,
cgstTotal: cgstTotal ?? this.cgstTotal,
sgstTotal: sgstTotal ?? this.sgstTotal,
igstTotal: igstTotal ?? this.igstTotal,
discountTotal: discountTotal ?? this.discountTotal, discountTotal: discountTotal ?? this.discountTotal,
totalAmount: totalAmount ?? this.totalAmount, totalAmount: totalAmount ?? this.totalAmount,
status: status ?? this.status, status: status ?? this.status,

View File

@@ -5,6 +5,9 @@ class PurchaseOrderItem {
final double quantity; final double quantity;
final double unitPrice; final double unitPrice;
final double taxRate; final double taxRate;
final double? cgstRate;
final double? sgstRate;
final double? igstRate;
final double discount; final double discount;
final double total; final double total;
final String? description; final String? description;
@@ -12,6 +15,11 @@ class PurchaseOrderItem {
final double otherCharges; final double otherCharges;
final String? sku; final String? sku;
final String? huid;
final double? weight;
final String? photoUrl;
final String? localPhotoPath;
PurchaseOrderItem({ PurchaseOrderItem({
this.id, this.id,
this.poId, this.poId,
@@ -19,12 +27,20 @@ class PurchaseOrderItem {
this.quantity = 1.0, this.quantity = 1.0,
this.unitPrice = 0.0, this.unitPrice = 0.0,
this.taxRate = 0.0, this.taxRate = 0.0,
this.cgstRate,
this.sgstRate,
this.igstRate,
this.discount = 0.0, this.discount = 0.0,
this.total = 0.0, this.total = 0.0,
this.description, this.description,
this.makingCharge = 0.0, this.makingCharge = 0.0,
this.otherCharges = 0.0, this.otherCharges = 0.0,
this.sku, this.sku,
this.huid,
this.weight,
this.photoUrl,
this.localPhotoPath,
}); });
factory PurchaseOrderItem.fromJson(Map<String, dynamic> json) { factory PurchaseOrderItem.fromJson(Map<String, dynamic> json) {
@@ -35,12 +51,19 @@ class PurchaseOrderItem {
quantity: (json['quantity'] ?? 1.0).toDouble(), quantity: (json['quantity'] ?? 1.0).toDouble(),
unitPrice: (json['unitPrice'] ?? 0.0).toDouble(), unitPrice: (json['unitPrice'] ?? 0.0).toDouble(),
taxRate: (json['taxRate'] ?? 0.0).toDouble(), taxRate: (json['taxRate'] ?? 0.0).toDouble(),
cgstRate: json['cgstRate'] != null ? (json['cgstRate']).toDouble() : null,
sgstRate: json['sgstRate'] != null ? (json['sgstRate']).toDouble() : null,
igstRate: json['igstRate'] != null ? (json['igstRate']).toDouble() : null,
discount: (json['discount'] ?? 0.0).toDouble(), discount: (json['discount'] ?? 0.0).toDouble(),
total: (json['total'] ?? 0.0).toDouble(), total: (json['total'] ?? 0.0).toDouble(),
description: json['description'], description: json['description'],
makingCharge: (json['makingCharge'] ?? 0.0).toDouble(), makingCharge: (json['makingCharge'] ?? 0.0).toDouble(),
otherCharges: (json['otherCharges'] ?? 0.0).toDouble(), otherCharges: (json['otherCharges'] ?? 0.0).toDouble(),
sku: json['sku'], sku: json['sku'],
huid: json['huid'],
weight: json['weight'] != null ? (json['weight'] as num).toDouble() : null,
photoUrl: json['photoUrl'],
); );
} }
@@ -52,12 +75,19 @@ class PurchaseOrderItem {
'quantity': quantity, 'quantity': quantity,
'unitPrice': unitPrice, 'unitPrice': unitPrice,
'taxRate': taxRate, 'taxRate': taxRate,
'cgstRate': cgstRate,
'sgstRate': sgstRate,
'igstRate': igstRate,
'discount': discount, 'discount': discount,
'total': total, 'total': total,
'description': description, 'description': description,
'makingCharge': makingCharge, 'makingCharge': makingCharge,
'otherCharges': otherCharges, 'otherCharges': otherCharges,
'sku': sku, 'sku': sku,
'huid': huid,
'weight': weight,
'photoUrl': photoUrl,
}; };
} }
@@ -68,12 +98,20 @@ class PurchaseOrderItem {
double? quantity, double? quantity,
double? unitPrice, double? unitPrice,
double? taxRate, double? taxRate,
double? cgstRate,
double? sgstRate,
double? igstRate,
double? discount, double? discount,
double? total, double? total,
String? description, String? description,
double? makingCharge, double? makingCharge,
double? otherCharges, double? otherCharges,
String? sku, String? sku,
String? huid,
double? weight,
String? photoUrl,
String? localPhotoPath,
}) { }) {
return PurchaseOrderItem( return PurchaseOrderItem(
id: id ?? this.id, id: id ?? this.id,
@@ -82,12 +120,20 @@ class PurchaseOrderItem {
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
unitPrice: unitPrice ?? this.unitPrice, unitPrice: unitPrice ?? this.unitPrice,
taxRate: taxRate ?? this.taxRate, taxRate: taxRate ?? this.taxRate,
cgstRate: cgstRate ?? this.cgstRate,
sgstRate: sgstRate ?? this.sgstRate,
igstRate: igstRate ?? this.igstRate,
discount: discount ?? this.discount, discount: discount ?? this.discount,
total: total ?? this.total, total: total ?? this.total,
description: description ?? this.description, description: description ?? this.description,
makingCharge: makingCharge ?? this.makingCharge, makingCharge: makingCharge ?? this.makingCharge,
otherCharges: otherCharges ?? this.otherCharges, otherCharges: otherCharges ?? this.otherCharges,
sku: sku ?? this.sku, sku: sku ?? this.sku,
huid: huid ?? this.huid,
weight: weight ?? this.weight,
photoUrl: photoUrl ?? this.photoUrl,
localPhotoPath: localPhotoPath ?? this.localPhotoPath,
); );
} }
} }

Some files were not shown because too many files have changed in this diff Show More