diff --git a/analyze_output.txt b/analyze_output.txt new file mode 100644 index 0000000..b9f1e17 --- /dev/null +++ b/analyze_output.txt @@ -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 + diff --git a/kifi-api/TestJwt.java b/kifi-api/TestJwt.java new file mode 100644 index 0000000..a92d818 --- /dev/null +++ b/kifi-api/TestJwt.java @@ -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 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(); + } + } +} diff --git a/kifi-api/build_n_push.sh b/kifi-api/build_n_push.sh index 70dd073..5553306 100755 --- a/kifi-api/build_n_push.sh +++ b/kifi-api/build_n_push.sh @@ -6,7 +6,9 @@ USERNAME="technobee_admin" PASSWORD='M@tr!x#149@dm!N' IMAGE_NAME="kifi-api" TAG="latest" +TAG_ONE="kifi-v2" FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG" +FULL_IMAGE_NAME_ONE="$REGISTRY/$IMAGE_NAME:$TAG_ONE" # Stop on any error set -e @@ -14,7 +16,7 @@ set -e echo "Logging into Docker registry: $REGISTRY..." echo "$PASSWORD" | docker login "$REGISTRY" -u "$USERNAME" --password-stdin -echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME..." -docker buildx build --platform linux/amd64 -t "$FULL_IMAGE_NAME" --push . +echo "Building and pushing Docker image for linux/amd64: $FULL_IMAGE_NAME and $FULL_IMAGE_NAME_ONE..." +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." diff --git a/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java index 8b5a320..ffafd1d 100644 --- a/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java +++ b/kifi-api/src/main/java/com/kifi/api/config/SecurityConfig.java @@ -45,10 +45,11 @@ public class SecurityConfig { .securityContextRepository(securityContextRepository) .authorizeExchange(exchange -> exchange .pathMatchers(HttpMethod.OPTIONS).permitAll() - .pathMatchers("/api/kifi-v2/auth/**").permitAll() - .pathMatchers("/api/kifi-v2/health/**").permitAll() + .pathMatchers("/api/kifi-v2/auth/**", "/api/kifi-v2/categories/**", "/api/kifi-v2/purchase-orders/test/**").permitAll() + .pathMatchers("/api/kifi-v2/health/**", "/api/kifi-v2/upload/view**").permitAll() .pathMatchers("/api/kifi-v2/wallets/test/**").permitAll() .pathMatchers("/public/legal/**").permitAll() + .pathMatchers("/test/**").permitAll() .anyExchange().authenticated() ) .build(); diff --git a/kifi-api/src/main/java/com/kifi/api/controller/UploadController.java b/kifi-api/src/main/java/com/kifi/api/controller/UploadController.java new file mode 100644 index 0000000..4092771 --- /dev/null +++ b/kifi-api/src/main/java/com/kifi/api/controller/UploadController.java @@ -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> upload(@RequestPart("file") Mono 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> 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); + }); + } +} diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/InventoryItemController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/InventoryItemController.java index 805b980..3cedc59 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/inventory/InventoryItemController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/InventoryItemController.java @@ -24,6 +24,12 @@ public class InventoryItemController { return inventoryItemService.getItemsByUserId(userId); } + @GetMapping("/product/{productId}") + public Flux getItemsByProductId(@PathVariable Long productId, Authentication authentication) { + Long userId = Long.valueOf(authentication.getDetails().toString()); + return inventoryItemService.getItemsByProductId(userId, productId); + } + @GetMapping("/total-value") public Mono> getTotalInventoryValue(Authentication authentication) { Long userId = Long.valueOf(authentication.getDetails().toString()); diff --git a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java index b1914d4..b435021 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/inventory/ProductCategoryController.java @@ -46,12 +46,12 @@ public class ProductCategoryController { existing.setName(category.getName()); existing.setParentCategoryId(category.getParentCategoryId()); existing.setHasChild(category.getHasChild()); + existing.setCommodityCode(category.getCommodityCode()); existing.setDefaultHsn(category.getDefaultHsn()); existing.setDefaultGst(category.getDefaultGst()); existing.setHuidRequired(category.getHuidRequired()); existing.setDefaultMakingCharge(category.getDefaultMakingCharge()); existing.setMakingChargeType(category.getMakingChargeType()); - existing.setCalculationMethod(category.getCalculationMethod()); existing.setBaseUnit(category.getBaseUnit()); existing.setIsActive(category.getIsActive()); existing.setSortOrder(category.getSortOrder()); diff --git a/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java b/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java index a908cae..38260e6 100644 --- a/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java +++ b/kifi-api/src/main/java/com/kifi/api/controller/vendor/PurchaseOrderController.java @@ -24,6 +24,17 @@ public class PurchaseOrderController { return Mono.just(ResponseEntity.ok(purchaseOrderService.getPurchaseOrders(userId))); } + @GetMapping("/test/{id}") + public Mono> 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}") public Mono> getPurchaseOrder(@PathVariable Long id, Authentication authentication) { Long userId = Long.valueOf(authentication.getDetails().toString()); @@ -31,6 +42,7 @@ public class PurchaseOrderController { .flatMap(po -> purchaseOrderService.getPurchaseOrderItems(id) .collectList() .map(items -> { + System.out.println("Fetched PO ID " + id + ", Items count: " + items.size()); po.setItems(items); return ResponseEntity.ok(po); })) @@ -40,6 +52,8 @@ public class PurchaseOrderController { @PostMapping public Mono> createPurchaseOrder(Authentication authentication, @RequestBody PurchaseOrder po) { 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()) .map(ResponseEntity::ok); } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovementItem.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovementItem.java index feace81..ab94fa7 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovementItem.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/InventoryMovementItem.java @@ -23,4 +23,8 @@ public class InventoryMovementItem { private BigDecimal quantity; private BigDecimal unitPrice; private LocalDateTime createdAt; + private String huid; + private BigDecimal weight; + private String photoUrl; + private BigDecimal purchaseAmount; } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java index 952e094..702ecb6 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/Product.java @@ -28,11 +28,9 @@ public class Product { private String barcode; private String hsnCode; private String description; - private BigDecimal purchasePrice; private BigDecimal sellingPrice; private BigDecimal gstRate; private String dimensions; - private BigDecimal weight; private String color; private String size; private String priceCalcRule; @@ -40,9 +38,6 @@ public class Product { private Double makingCharges; private String makingChargesType; private Double wastagePercentage; - private BigDecimal minStock; - private BigDecimal reorderLevel; - private Boolean trackInventory; private Boolean isActive; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java b/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java index c236102..73123d6 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/inventory/ProductCategory.java @@ -5,6 +5,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import org.springframework.data.annotation.Id; +import org.springframework.data.relational.core.mapping.Column; import org.springframework.data.relational.core.mapping.Table; import java.time.LocalDateTime; @@ -22,6 +23,7 @@ public class ProductCategory { private Long parentCategoryId; @Builder.Default private Boolean hasChild = false; + private String commodityCode; private String defaultHsn; private java.math.BigDecimal defaultGst; @Builder.Default @@ -29,7 +31,6 @@ public class ProductCategory { private Double defaultMakingCharge; private String makingChargeType; - private String calculationMethod; private String baseUnit; @Builder.Default diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java index 741b766..bc9c299 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrder.java @@ -29,6 +29,9 @@ public class PurchaseOrder { private LocalDate dueDate; private BigDecimal subtotal; private BigDecimal taxTotal; + private BigDecimal cgstTotal; + private BigDecimal sgstTotal; + private BigDecimal igstTotal; private BigDecimal discountTotal; private BigDecimal totalAmount; private String status; // DRAFT, RECEIVED, CANCELLED @@ -41,5 +44,16 @@ public class PurchaseOrder { private String vendorInvoiceUrl; @Transient + @com.fasterxml.jackson.annotation.JsonProperty(value = "items", access = com.fasterxml.jackson.annotation.JsonProperty.Access.READ_WRITE) private List items; + + @com.fasterxml.jackson.annotation.JsonProperty("items") + public List getItems() { + return items; + } + + @com.fasterxml.jackson.annotation.JsonProperty("items") + public void setItems(List items) { + this.items = items; + } } diff --git a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java index 3d86380..1af8e0c 100644 --- a/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java +++ b/kifi-api/src/main/java/com/kifi/api/entity/vendor/PurchaseOrderItem.java @@ -22,10 +22,16 @@ public class PurchaseOrderItem { private BigDecimal quantity; private BigDecimal unitPrice; private BigDecimal taxRate; + private BigDecimal cgstRate; + private BigDecimal sgstRate; + private BigDecimal igstRate; private BigDecimal discount; private BigDecimal total; private String description; private BigDecimal makingCharge; private BigDecimal otherCharges; private String sku; + private String huid; + private BigDecimal weight; + private String photoUrl; } diff --git a/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryItemRepository.java b/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryItemRepository.java index 080ffcc..f319a4f 100644 --- a/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryItemRepository.java +++ b/kifi-api/src/main/java/com/kifi/api/repository/inventory/InventoryItemRepository.java @@ -7,6 +7,7 @@ import reactor.core.publisher.Mono; public interface InventoryItemRepository extends ReactiveCrudRepository { Flux findByUserId(Long userId); + Flux findByUserIdAndProductId(Long userId, Long productId); Flux findByProductId(Long productId); Mono findByTagNumber(String tagNumber); Mono findByHuid(String huid); diff --git a/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java b/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java index 7b1e5a1..5ae6084 100644 --- a/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java +++ b/kifi-api/src/main/java/com/kifi/api/security/AuthenticationManager.java @@ -25,7 +25,8 @@ public class AuthenticationManager implements ReactiveAuthenticationManager { if (jwtUtil.validateToken(authToken)) { Claims claims = jwtUtil.getAllClaimsFromToken(authToken); 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( email, diff --git a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java index 9a35f7b..c6511f5 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/business/BusinessService.java @@ -4,6 +4,7 @@ import com.kifi.api.entity.business.BusinessProfile; import com.kifi.api.entity.business.BusinessFeature; import com.kifi.api.repository.business.BusinessProfileRepository; import com.kifi.api.repository.business.BusinessFeatureRepository; +import com.kifi.api.util.CryptoUtils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -15,9 +16,15 @@ import java.time.LocalDateTime; public class BusinessService { private final BusinessProfileRepository businessProfileRepository; private final BusinessFeatureRepository businessFeatureRepository; + private final CryptoUtils cryptoUtils; public Mono 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 getFeaturesByUserId(Long userId) { @@ -76,6 +83,8 @@ public class BusinessService { profile.setUserId(userId); profile.setCreatedAt(LocalDateTime.now()); profile.setUpdatedAt(LocalDateTime.now()); + profile.setPanNumber(profile.getPanNumber()); + profile.setGstin(profile.getGstin()); return businessProfileRepository.save(profile); })); } diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java index 89d1fc2..19fd8b7 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/InventoryItemService.java @@ -18,6 +18,10 @@ public class InventoryItemService { return inventoryItemRepository.findByUserId(userId); } + public Flux getItemsByProductId(Long userId, Long productId) { + return inventoryItemRepository.findByUserIdAndProductId(userId, productId); + } + public Mono createItem(Long userId, InventoryItem item) { item.setUserId(userId); item.setCreatedAt(LocalDateTime.now()); diff --git a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java index 3bdf6d2..f6e94d8 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/inventory/ProductService.java @@ -67,7 +67,6 @@ public class ProductService { product.setCreatedAt(LocalDateTime.now()); product.setUpdatedAt(LocalDateTime.now()); if (product.getIsActive() == null) product.setIsActive(true); - if (product.getTrackInventory() == null) product.setTrackInventory(true); if (product.getPriceCalcRule() == null) product.setPriceCalcRule("MANUAL"); if (product.getPurityFactor() == null) product.setPurityFactor(1.0); return productRepository.save(product); @@ -81,10 +80,9 @@ public class ProductService { existingProduct.setSku(updatedProduct.getSku()); existingProduct.setCategoryId(updatedProduct.getCategoryId()); existingProduct.setUomId(updatedProduct.getUomId()); - existingProduct.setPurchasePrice(updatedProduct.getPurchasePrice()); existingProduct.setSellingPrice(updatedProduct.getSellingPrice()); existingProduct.setGstRate(updatedProduct.getGstRate()); - existingProduct.setWeight(updatedProduct.getWeight()); + existingProduct.setHsnCode(updatedProduct.getHsnCode()); existingProduct.setColor(updatedProduct.getColor()); existingProduct.setSize(updatedProduct.getSize()); existingProduct.setDimensions(updatedProduct.getDimensions()); @@ -93,9 +91,6 @@ public class ProductService { existingProduct.setMakingCharges(updatedProduct.getMakingCharges()); existingProduct.setMakingChargesType(updatedProduct.getMakingChargesType()); existingProduct.setWastagePercentage(updatedProduct.getWastagePercentage()); - existingProduct.setMinStock(updatedProduct.getMinStock()); - existingProduct.setReorderLevel(updatedProduct.getReorderLevel()); - existingProduct.setTrackInventory(updatedProduct.getTrackInventory()); existingProduct.setIsActive(updatedProduct.getIsActive()); existingProduct.setUpdatedAt(LocalDateTime.now()); return productRepository.save(existingProduct); diff --git a/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java index 05b34d4..19de482 100644 --- a/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java +++ b/kifi-api/src/main/java/com/kifi/api/service/vendor/PurchaseOrderService.java @@ -35,14 +35,27 @@ public class PurchaseOrderService { private final LedgerService ledgerService; private final TransactionService transactionService; private final VendorRepository vendorRepository; + private final com.kifi.api.repository.inventory.InventoryBalanceRepository inventoryBalanceRepository; public Flux getPurchaseOrders(Long 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())); } public Mono 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 getPurchaseOrderItems(Long poId) { @@ -64,13 +77,21 @@ public class PurchaseOrderService { return purchaseOrderRepository.save(po) .flatMap(savedPo -> { + if (items == null || items.isEmpty()) { + savedPo.setItems(List.of()); + return Mono.just(savedPo); + } return Flux.fromIterable(items) .map(item -> { item.setPoId(savedPo.getId()); return item; }) .flatMap(purchaseOrderItemRepository::save) - .then(Mono.just(savedPo)); + .collectList() + .map(savedItems -> { + savedPo.setItems(savedItems); + return savedPo; + }); }); } @@ -78,6 +99,9 @@ public class PurchaseOrderService { public Mono updatePurchaseOrder(Long userId, Long id, PurchaseOrder po, List items) { return purchaseOrderRepository.findByUserIdAndId(userId, id) .flatMap(existingPo -> { + if (items == null) { + return Mono.error(new IllegalArgumentException("Items cannot be null")); + } existingPo.setVendorId(po.getVendorId()); existingPo.setPoNumber(po.getPoNumber()); existingPo.setIssueDate(po.getIssueDate()); @@ -94,11 +118,16 @@ public class PurchaseOrderService { .flatMap(savedPo -> purchaseOrderItemRepository.deleteByPoId(id) .thenMany(Flux.fromIterable(items) .map(item -> { + item.setId(null); item.setPoId(id); return item; }) .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) .thenMany(Flux.fromIterable(receivedItems) .map(item -> { + item.setId(null); item.setPoId(id); return item; }) @@ -126,21 +156,34 @@ public class PurchaseOrderService { // Update inventory for received items return Flux.fromIterable(savedItems) .flatMap(item -> { - if (item.getProductId() != null && item.getQuantity() != null && item.getQuantity().compareTo(BigDecimal.ZERO) > 0) { - int quantity = item.getQuantity().intValue(); - return Flux.range(0, quantity) - .flatMap(i -> { - InventoryItem invItem = InventoryItem.builder() - .userId(userId) - .productId(item.getProductId()) - .vendorId(savedPo.getVendorId()) - .purchaseRef(savedPo.getPoNumber()) - .purchaseCost(item.getUnitPrice()) - .makingCharges(item.getMakingCharge()) - .sku(item.getSku() != null ? item.getSku() + "-" + (i + 1) : null) - .build(); - return inventoryItemService.createItem(userId, invItem); - }) + if (item.getProductId() != null) { + InventoryItem invItem = InventoryItem.builder() + .userId(userId) + .productId(item.getProductId()) + .vendorId(savedPo.getVendorId()) + .purchaseRef(savedPo.getPoNumber()) + .purchaseCost(item.getUnitPrice()) + .makingCharges(item.getMakingCharge()) + .sku(item.getSku()) + .huid(item.getHuid()) + .grossWeight(item.getWeight()) + .netWeight(item.getWeight()) + .build(); + 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)); } return Mono.just(item); @@ -162,7 +205,11 @@ public class PurchaseOrderService { .build(); return transactionService.addTransaction(userId, tx); }) - .thenReturn(savedPo); + .thenReturn(savedPo) + .map(poResult -> { + poResult.setItems(savedItems); + return poResult; + }); })); }); } diff --git a/kifi-api/src/main/resources/schema.sql b/kifi-api/src/main/resources/schema.sql index 1fafd74..a8d8144 100644 --- a/kifi-api/src/main/resources/schema.sql +++ b/kifi-api/src/main/resources/schema.sql @@ -202,8 +202,7 @@ CREATE TABLE IF NOT EXISTS product_categories ( huid_required BOOLEAN DEFAULT FALSE, default_making_charge DECIMAL(15, 2), making_charge_type VARCHAR(50), - calculation_method VARCHAR(50) DEFAULT 'UNIT', - base_unit VARCHAR(50) DEFAULT 'pcs', + base_unit VARCHAR(20) DEFAULT 'pcs', is_active BOOLEAN DEFAULT TRUE, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/kifi-api/src/test/java/RunSchema.java b/kifi-api/src/test/java/RunSchema.java new file mode 100644 index 0000000..d9fcddd --- /dev/null +++ b/kifi-api/src/test/java/RunSchema.java @@ -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(); + } + } +} diff --git a/kifi-api/src/test/java/com/kifi/api/PurchaseOrderJacksonTest.java b/kifi-api/src/test/java/com/kifi/api/PurchaseOrderJacksonTest.java new file mode 100644 index 0000000..828f553 --- /dev/null +++ b/kifi-api/src/test/java/com/kifi/api/PurchaseOrderJacksonTest.java @@ -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"); + } + } +} diff --git a/kifi-api/src/test/java/com/kifi/api/TestJackson.java b/kifi-api/src/test/java/com/kifi/api/TestJackson.java new file mode 100644 index 0000000..b558780 --- /dev/null +++ b/kifi-api/src/test/java/com/kifi/api/TestJackson.java @@ -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)); + } +} diff --git a/kifi-api/src/test/java/com/kifi/api/TestService.java b/kifi-api/src/test/java/com/kifi/api/TestService.java new file mode 100644 index 0000000..bad4820 --- /dev/null +++ b/kifi-api/src/test/java/com/kifi/api/TestService.java @@ -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()); + } +} diff --git a/kifi-api/src/test/java/com/kifi/api/TestUserMapping.java b/kifi-api/src/test/java/com/kifi/api/TestUserMapping.java new file mode 100644 index 0000000..27a2a49 --- /dev/null +++ b/kifi-api/src/test/java/com/kifi/api/TestUserMapping.java @@ -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()); + } +} diff --git a/kifi-api/test_flux.java b/kifi-api/test_flux.java new file mode 100644 index 0000000..1a558a9 --- /dev/null +++ b/kifi-api/test_flux.java @@ -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()); + } + } +} diff --git a/kifi-api/test_flux2.class b/kifi-api/test_flux2.class new file mode 100644 index 0000000..9bfd8aa Binary files /dev/null and b/kifi-api/test_flux2.class differ diff --git a/kifi-api/test_flux2.java b/kifi-api/test_flux2.java new file mode 100644 index 0000000..c5ee2a8 --- /dev/null +++ b/kifi-api/test_flux2.java @@ -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()); + } + } +} diff --git a/kifi-app/lib/core/network/dio_client.dart b/kifi-app/lib/core/network/dio_client.dart index 0733ab7..be93690 100644 --- a/kifi-app/lib/core/network/dio_client.dart +++ b/kifi-app/lib/core/network/dio_client.dart @@ -8,11 +8,22 @@ class DioClient { static final DioClient _instance = DioClient._internal(); final Dio dio; final FlutterSecureStorage storage; + String? _cachedToken; factory DioClient() { return _instance; } + Future saveToken(String token) async { + _cachedToken = token; + await storage.write(key: 'jwt_token', value: token); + } + + Future clearToken() async { + _cachedToken = null; + await storage.delete(key: 'jwt_token'); + } + DioClient._internal() : dio = Dio(BaseOptions( baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2', @@ -23,18 +34,26 @@ class DioClient { storage = const FlutterSecureStorage() { dio.interceptors.add(InterceptorsWrapper( 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) { options.headers['Authorization'] = 'Bearer $token'; + print('DioClient sending token: ${token.substring(0, 10)}...'); + } else { + print('DioClient sending request WITHOUT token!'); } return handler.next(options); }, onError: (DioException error, handler) async { if (error.response?.statusCode == 401) { // Token is invalid or expired + _cachedToken = null; await storage.delete(key: 'jwt_token'); - if (navigatorKey.currentContext != null) { - Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil( + if (navigatorKey.currentState != null) { + navigatorKey.currentState!.pushAndRemoveUntil( MaterialPageRoute(builder: (_) => const AuthScreen()), (route) => false, ); diff --git a/kifi-app/lib/core/theme/app_theme.dart b/kifi-app/lib/core/theme/app_theme.dart index e94b6d5..d5aa3ff 100644 --- a/kifi-app/lib/core/theme/app_theme.dart +++ b/kifi-app/lib/core/theme/app_theme.dart @@ -38,15 +38,15 @@ class AppTheme { fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primaryColor, width: 2), ), hintStyle: const TextStyle(color: textSecondaryColor), @@ -94,15 +94,15 @@ class AppTheme { fillColor: darkSurface, contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.withOpacity(0.3)), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primaryColor, width: 2), ), hintStyle: const TextStyle(color: darkTextSecondary), diff --git a/kifi-app/lib/core/widgets/premium_text_field.dart b/kifi-app/lib/core/widgets/premium_text_field.dart index b396fbc..3ea5352 100644 --- a/kifi-app/lib/core/widgets/premium_text_field.dart +++ b/kifi-app/lib/core/widgets/premium_text_field.dart @@ -12,6 +12,7 @@ class PremiumTextField extends StatelessWidget { final TextCapitalization textCapitalization; final int maxLines; final bool obscureText; + final bool readOnly; final String? Function(String?)? validator; final void Function(String)? onChanged; final void Function(String?)? onSaved; @@ -30,6 +31,7 @@ class PremiumTextField extends StatelessWidget { this.textCapitalization = TextCapitalization.none, this.maxLines = 1, this.obscureText = false, + this.readOnly = false, this.validator, this.onChanged, this.onSaved, @@ -49,20 +51,12 @@ class PremiumTextField extends StatelessWidget { suffixIcon: suffixIcon, 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), - 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, textCapitalization: textCapitalization, maxLines: maxLines, obscureText: obscureText, + readOnly: readOnly, onChanged: onChanged, onSaved: onSaved, validator: validator, diff --git a/kifi-app/lib/core/widgets/smart_search_dropdown.dart b/kifi-app/lib/core/widgets/smart_search_dropdown.dart index dab9671..383994f 100644 --- a/kifi-app/lib/core/widgets/smart_search_dropdown.dart +++ b/kifi-app/lib/core/widgets/smart_search_dropdown.dart @@ -10,6 +10,8 @@ class SmartSearchDropdown extends StatefulWidget { final bool Function(T, String)? filterFn; final Color? fillColor; final String? labelText; + final String? emptyActionText; + final VoidCallback? onEmptyActionPressed; const SmartSearchDropdown({ super.key, @@ -22,6 +24,8 @@ class SmartSearchDropdown extends StatefulWidget { this.filterFn, this.fillColor, this.labelText, + this.emptyActionText, + this.onEmptyActionPressed, }); @override @@ -135,9 +139,24 @@ class _SmartSearchDropdownState extends State> { mainAxisSize: MainAxisSize.min, children: [ if (displayItems.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Text('No matches found'), + Padding( + padding: const EdgeInsets.all(16.0), + 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 Flexible( diff --git a/kifi-app/lib/features/auth/data/auth_repository.dart b/kifi-app/lib/features/auth/data/auth_repository.dart index fb4042f..9a6300c 100644 --- a/kifi-app/lib/features/auth/data/auth_repository.dart +++ b/kifi-app/lib/features/auth/data/auth_repository.dart @@ -15,10 +15,13 @@ class AuthRepository { Future signup(String email, String password) async { try { await _ensureCryptoReady(); - await dio.post('/auth/signup', data: { - 'email': _crypto.encrypt(email), - 'password': _crypto.encrypt(password), - }); + await dio.post( + '/auth/signup', + data: { + 'email': _crypto.encrypt(email), + 'password': _crypto.encrypt(password), + }, + ); } on DioException catch (e) { final data = e.response?.data; final errorMsg = data is Map ? data['error'] : data; @@ -30,7 +33,9 @@ class AuthRepository { try { return await _attemptLogin(email, password); } 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(); try { return await _attemptLogin(email, password); @@ -46,21 +51,27 @@ class AuthRepository { } } - Future> _attemptLogin(String email, String password) async { + Future> _attemptLogin( + String email, + String password, + ) async { await _ensureCryptoReady(); - final response = await dio.post('/auth/login', data: { - 'email': _crypto.encrypt(email), - 'password': _crypto.encrypt(password), - }); + final response = await dio.post( + '/auth/login', + data: { + 'email': _crypto.encrypt(email), + 'password': _crypto.encrypt(password), + }, + ); return response.data; } Future> verifyOtp(String email, String otp) async { try { - final response = await dio.post('/auth/verify-otp', data: { - 'email': email, - 'otp': otp, - }); + final response = await dio.post( + '/auth/verify-otp', + data: {'email': email, 'otp': otp}, + ); return response.data; } on DioException catch (e) { final data = e.response?.data; @@ -71,9 +82,7 @@ class AuthRepository { Future forgotPassword(String email) async { try { - await dio.post('/auth/forgot-password', data: { - 'email': email, - }); + await dio.post('/auth/forgot-password', data: {'email': email}); } on DioException catch (e) { final data = e.response?.data; final errorMsg = data is Map ? data['error'] : data; @@ -81,14 +90,21 @@ class AuthRepository { } } - Future resetPassword(String email, String otp, String newPassword) async { + Future resetPassword( + String email, + String otp, + String newPassword, + ) async { try { await _ensureCryptoReady(); - await dio.post('/auth/reset-password', data: { - 'email': email, - 'otp': otp, - 'newPassword': _crypto.encrypt(newPassword), - }); + await dio.post( + '/auth/reset-password', + data: { + 'email': email, + 'otp': otp, + 'newPassword': _crypto.encrypt(newPassword), + }, + ); } on DioException catch (e) { final data = e.response?.data; final errorMsg = data is Map ? data['error'] : data; diff --git a/kifi-app/lib/features/auth/presentation/auth_screen.dart b/kifi-app/lib/features/auth/presentation/auth_screen.dart index 7a5af74..fcef6b7 100644 --- a/kifi-app/lib/features/auth/presentation/auth_screen.dart +++ b/kifi-app/lib/features/auth/presentation/auth_screen.dart @@ -16,9 +16,10 @@ class AuthScreen extends ConsumerStatefulWidget { ConsumerState createState() => _AuthScreenState(); } -class _AuthScreenState extends ConsumerState with SingleTickerProviderStateMixin { +class _AuthScreenState extends ConsumerState + with SingleTickerProviderStateMixin { bool isLogin = true; - final emailController = TextEditingController(text: 'maddy23285@gmail.com'); + final emailController = TextEditingController(text: 'nmadaswamy@gmail.com'); final passwordController = TextEditingController(text: 'Algorithm@123'); void toggleMode(bool login) { @@ -35,25 +36,49 @@ class _AuthScreenState extends ConsumerState with SingleTickerProvid if (email.isEmpty || password.isEmpty) return; 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) { try { + await Future.delayed(const Duration(milliseconds: 500)); // Allow secure storage to settle final dio = DioClient().dio; final res = await dio.get('/account/setup/status'); + print('Setup status response: ${res.data}'); final status = res.data['status']; + print('Status is: $status'); if (status == 'COMPLETED' && mounted) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen())); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const DashboardScreen()), + ); } 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 { - final success = await ref.read(authControllerProvider.notifier).signup(email, password); + final success = await ref + .read(authControllerProvider.notifier) + .signup(email, password); 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 with SingleTickerProvid Widget build(BuildContext context) { ref.listen>(authControllerProvider, (previous, next) { if (next.hasError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${next.error}')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${next.error}'))); } }); @@ -107,23 +132,36 @@ class _AuthScreenState extends ConsumerState with SingleTickerProvid crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - width: 80, height: 80, + width: 80, + height: 80, decoration: const BoxDecoration( color: Colors.transparent, 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), Text( '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, ), const SizedBox(height: 8), Text( '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, ), const SizedBox(height: 32), @@ -143,11 +181,31 @@ class _AuthScreenState extends ConsumerState with SingleTickerProvid duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: isLogin ? Colors.white : Colors.transparent, + color: isLogin + ? Colors.white + : Colors.transparent, 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 with SingleTickerProvid duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: !isLogin ? Colors.white : Colors.transparent, + color: !isLogin + ? Colors.white + : Colors.transparent, 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 with SingleTickerProvid TextField( controller: emailController, keyboardType: TextInputType.emailAddress, - style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.black87, + ), 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), TextField( controller: passwordController, obscureText: true, - style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87), + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.black87, + ), textInputAction: TextInputAction.done, onSubmitted: (_) => submit(), - decoration: _buildInputDecoration('Password', LucideIcons.lock), + decoration: _buildInputDecoration( + 'Password', + LucideIcons.lock, + ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 200), @@ -202,14 +292,32 @@ class _AuthScreenState extends ConsumerState with SingleTickerProvid child: Padding( padding: const EdgeInsets.only(top: 12), child: TextButton( - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen())), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).primaryColor, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const ForgotPasswordScreen(), + ), + ), + 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 with SingleTickerProvid backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), elevation: 2, ), child: isLoading - ? const SizedBox(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)), + ? const SizedBox( + 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, + ), + ), ), ), ], diff --git a/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart b/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart index 52d7895..21f2f22 100644 --- a/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart +++ b/kifi-app/lib/features/auth/presentation/forgot_password_screen.dart @@ -8,7 +8,8 @@ class ForgotPasswordScreen extends ConsumerStatefulWidget { const ForgotPasswordScreen({super.key}); @override - ConsumerState createState() => _ForgotPasswordScreenState(); + ConsumerState createState() => + _ForgotPasswordScreenState(); } class _ForgotPasswordScreenState extends ConsumerState { @@ -34,7 +35,9 @@ class _ForgotPasswordScreenState extends ConsumerState { _errorMessage = null; }); - final success = await ref.read(authControllerProvider.notifier).forgotPassword(email); + final success = await ref + .read(authControllerProvider.notifier) + .forgotPassword(email); if (mounted) { setState(() => _isLoading = false); @@ -44,7 +47,10 @@ class _ForgotPasswordScreenState extends ConsumerState { MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)), ); } 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 { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - width: 80, height: 80, + width: 80, + height: 80, decoration: BoxDecoration( color: Theme.of(context).primaryColor.withOpacity(0.1), 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), Text( '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, ), const SizedBox(height: 8), Text( 'Enter your email address and we\'ll send you\na verification code to reset your password.', 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), TextField( @@ -125,7 +145,10 @@ class _ForgotPasswordScreenState extends ConsumerState { textInputAction: TextInputAction.done, onSubmitted: (_) => _sendOtp(), style: const TextStyle(fontWeight: FontWeight.w500), - decoration: _buildInputDecoration('Email Address', LucideIcons.mail), + decoration: _buildInputDecoration( + 'Email Address', + LucideIcons.mail, + ), ), AnimatedSize( duration: const Duration(milliseconds: 300), @@ -135,17 +158,35 @@ class _ForgotPasswordScreenState extends ConsumerState { ? Padding( padding: const EdgeInsets.only(top: 16), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.red.shade200), + border: Border.all( + color: Colors.red.shade200, + ), ), child: Row( children: [ - Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20), + Icon( + LucideIcons.alertCircle, + color: Colors.red.shade400, + size: 20, + ), 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 { backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), elevation: 2, ), child: _isLoading - ? const SizedBox(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)), + ? const SizedBox( + 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, + ), + ), ), ), ], diff --git a/kifi-app/lib/features/auth/presentation/otp_screen.dart b/kifi-app/lib/features/auth/presentation/otp_screen.dart index e5e9c2d..9f776ca 100644 --- a/kifi-app/lib/features/auth/presentation/otp_screen.dart +++ b/kifi-app/lib/features/auth/presentation/otp_screen.dart @@ -19,10 +19,15 @@ class _OtpScreenState extends ConsumerState { final otp = otpController.text.trim(); 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) { Navigator.pushAndRemoveUntil( - context, MaterialPageRoute(builder: (_) => const DashboardScreen()), (route) => false); + context, + MaterialPageRoute(builder: (_) => const DashboardScreen()), + (route) => false, + ); } } @@ -30,9 +35,9 @@ class _OtpScreenState extends ConsumerState { Widget build(BuildContext context) { ref.listen>(authControllerProvider, (previous, next) { if (next.hasError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${next.error}')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${next.error}'))); } }); @@ -65,23 +70,37 @@ class _OtpScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - width: 80, height: 80, + width: 80, + height: 80, decoration: BoxDecoration( color: Theme.of(context).primaryColor.withOpacity(0.1), 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), Text( '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, ), const SizedBox(height: 8), Text( '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, ), const SizedBox(height: 48), @@ -90,17 +109,39 @@ class _OtpScreenState extends ConsumerState { keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 6, - style: const TextStyle(fontSize: 24, letterSpacing: 16, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 24, + letterSpacing: 16, + fontWeight: FontWeight.bold, + ), decoration: InputDecoration( hintText: '000000', - hintStyle: TextStyle(letterSpacing: 16, color: Colors.grey.shade300), + hintStyle: TextStyle( + letterSpacing: 16, + color: Colors.grey.shade300, + ), counterText: '', filled: true, fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - 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)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + 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), @@ -112,12 +153,28 @@ class _OtpScreenState extends ConsumerState { backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), elevation: 2, ), child: isLoading - ? const SizedBox(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)), + ? const SizedBox( + 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, + ), + ), ), ), ], diff --git a/kifi-app/lib/features/auth/presentation/profile_screen.dart b/kifi-app/lib/features/auth/presentation/profile_screen.dart index 05fda2b..5aa1b61 100644 --- a/kifi-app/lib/features/auth/presentation/profile_screen.dart +++ b/kifi-app/lib/features/auth/presentation/profile_screen.dart @@ -23,7 +23,8 @@ class ProfileScreen extends ConsumerStatefulWidget { ConsumerState createState() => _ProfileScreenState(); } -class _ProfileScreenState extends ConsumerState with SingleTickerProviderStateMixin { +class _ProfileScreenState extends ConsumerState + with SingleTickerProviderStateMixin { bool _isExporting = false; String? _profileType; String _userName = 'Loading...'; @@ -34,8 +35,14 @@ class _ProfileScreenState extends ConsumerState with SingleTicker @override void initState() { super.initState(); - _animController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800)); - _fadeAnim = CurvedAnimation(parent: _animController, curve: Curves.easeOutCubic); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + _fadeAnim = CurvedAnimation( + parent: _animController, + curve: Curves.easeOutCubic, + ); _animController.forward(); _fetchProfileType(); } @@ -68,7 +75,8 @@ class _ProfileScreenState extends ConsumerState with SingleTicker } 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(' '); if (parts.length > 1 && parts[1].isNotEmpty) { return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); @@ -76,11 +84,9 @@ class _ProfileScreenState extends ConsumerState with SingleTicker return name.substring(0, name.length >= 2 ? 2 : 1).toUpperCase(); } - Future _logout(BuildContext context) async { - const storage = FlutterSecureStorage(); - await storage.delete(key: 'jwt_token'); - - if (context.mounted) { + Future _logout() async { + await DioClient().clearToken(); + if (mounted) { Navigator.pushAndRemoveUntil( context, MaterialPageRoute(builder: (_) => const AuthScreen()), @@ -97,9 +103,14 @@ class _ProfileScreenState extends ConsumerState with SingleTicker final file = File('${tempDir.path}/transactions_export.csv'); await file.writeAsBytes(bytes); 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) { - 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 { if (mounted) setState(() => _isExporting = false); } @@ -115,14 +126,19 @@ class _ProfileScreenState extends ConsumerState with SingleTicker backgroundColor: Theme.of(context).scaffoldBackgroundColor, extendBodyBehindAppBar: true, 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, elevation: 0, centerTitle: true, flexibleSpace: ClipRect( child: BackdropFilter( 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 with SingleTicker children: [ // Background Decorative Elements Positioned( - top: -50, right: -50, + top: -50, + right: -50, child: Container( - width: 200, height: 200, + width: 200, + height: 200, decoration: BoxDecoration( shape: BoxShape.circle, color: primaryColor.withOpacity(isDark ? 0.2 : 0.1), @@ -140,9 +158,11 @@ class _ProfileScreenState extends ConsumerState with SingleTicker ), ), Positioned( - bottom: -100, left: -50, + bottom: -100, + left: -50, child: Container( - width: 300, height: 300, + width: 300, + height: 300, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.purple.withOpacity(isDark ? 0.15 : 0.08), @@ -158,32 +178,45 @@ class _ProfileScreenState extends ConsumerState with SingleTicker constraints: const BoxConstraints(maxWidth: 600), child: ListView( physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0), + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 24.0, + ), children: [ // Avatar Section Center( child: Hero( tag: 'profile_avatar', child: Container( - width: 120, height: 120, + width: 120, + height: 120, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ primaryColor.withOpacity(0.8), - primaryColor.withOpacity(0.5) + primaryColor.withOpacity(0.5), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), 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: Text( _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 with SingleTicker const SizedBox(height: 24), Text( _userName, - style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), - if (_userEmail.isNotEmpty && _userEmail != 'Loading...') ...[ + if (_userEmail.isNotEmpty && + _userEmail != 'Loading...') ...[ const SizedBox(height: 8), Text( _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, ), ], @@ -212,9 +252,16 @@ class _ProfileScreenState extends ConsumerState with SingleTicker filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( 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), - 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( children: [ @@ -223,9 +270,19 @@ class _ProfileScreenState extends ConsumerState with SingleTicker icon: LucideIcons.downloadCloud, iconColor: Colors.blue, title: 'Export Data to CSV', - trailing: _isExporting - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), + trailing: _isExporting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon( + LucideIcons.chevronRight, + size: 20, + color: Colors.grey, + ), onTap: _isExporting ? null : _exportData, ), _buildDivider(context, isDark), @@ -236,31 +293,56 @@ class _ProfileScreenState extends ConsumerState with SingleTicker title: 'Appearance', trailing: SegmentedButton( segments: const [ - ButtonSegment(value: ThemeMode.light, 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)), + ButtonSegment( + value: ThemeMode.light, + 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}, - onSelectionChanged: (Set newSelection) { - ref.read(themeProvider.notifier).setTheme(newSelection.first); - }, + onSelectionChanged: + (Set newSelection) { + ref + .read(themeProvider.notifier) + .setTheme(newSelection.first); + }, showSelectedIcon: false, style: ButtonStyle( visualDensity: VisualDensity.compact, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - backgroundColor: WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.selected)) { - return primaryColor.withOpacity(0.2); - } - return null; - }), + tapTargetSize: + MaterialTapTargetSize.shrinkWrap, + backgroundColor: + WidgetStateProperty.resolveWith< + Color? + >((states) { + if (states.contains( + WidgetState.selected, + )) { + return primaryColor.withOpacity( + 0.2, + ); + } + return null; + }), ), ), ), _buildDivider(context, isDark), Consumer( builder: (context, ref, child) { - final isBusinessMode = ref.watch(businessModeProvider); + final isBusinessMode = ref.watch( + businessModeProvider, + ); return Column( children: [ if (_profileType == 'BUSINESS') ...[ @@ -271,7 +353,11 @@ class _ProfileScreenState extends ConsumerState with SingleTicker title: 'Business Mode', subtitle: 'Inventory and Sales Hub', value: isBusinessMode, - onChanged: (val) => ref.read(businessModeProvider.notifier).toggleMode(), + onChanged: (val) => ref + .read( + businessModeProvider.notifier, + ) + .toggleMode(), ), if (isBusinessMode) ...[ _buildDivider(context, isDark), @@ -280,16 +366,27 @@ class _ProfileScreenState extends ConsumerState with SingleTicker icon: LucideIcons.settings2, iconColor: Colors.grey.shade600, title: 'Business Settings', - subtitle: 'Configure Taxes, Barcodes, etc.', - trailing: const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), - onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BusinessSettingsScreen())), + subtitle: + 'Configure Taxes, Barcodes, etc.', + trailing: const Icon( + LucideIcons.chevronRight, + size: 20, + color: Colors.grey, + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const BusinessSettingsScreen(), + ), + ), ), ], _buildDivider(context, isDark), ], ], ); - } + }, ), _buildDivider(context, isDark), _buildSettingsTile( @@ -297,7 +394,11 @@ class _ProfileScreenState extends ConsumerState with SingleTicker icon: LucideIcons.helpCircle, iconColor: Colors.green, title: 'Help & Support', - trailing: const Icon(LucideIcons.chevronRight, size: 20, color: Colors.grey), + trailing: const Icon( + LucideIcons.chevronRight, + size: 20, + color: Colors.grey, + ), onTap: () {}, ), ], @@ -306,7 +407,7 @@ class _ProfileScreenState extends ConsumerState with SingleTicker ), ), const SizedBox(height: 48), - + // Logout Button Container( width: double.infinity, @@ -316,20 +417,33 @@ class _ProfileScreenState extends ConsumerState with SingleTicker colors: [Colors.red.shade400, Colors.red.shade600], ), 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( - onPressed: () => _logout(context), + onPressed: () => _logout(), style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, foregroundColor: Colors.white, shadowColor: Colors.transparent, 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), - 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), @@ -364,8 +478,19 @@ class _ProfileScreenState extends ConsumerState with SingleTicker ), child: Icon(icon, color: iconColor, size: 24), ), - title: Text(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, + title: Text( + 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, onTap: onTap, ); diff --git a/kifi-app/lib/features/auth/presentation/reset_password_screen.dart b/kifi-app/lib/features/auth/presentation/reset_password_screen.dart index 180be14..433f955 100644 --- a/kifi-app/lib/features/auth/presentation/reset_password_screen.dart +++ b/kifi-app/lib/features/auth/presentation/reset_password_screen.dart @@ -9,7 +9,8 @@ class ResetPasswordScreen extends ConsumerStatefulWidget { const ResetPasswordScreen({super.key, required this.email}); @override - ConsumerState createState() => _ResetPasswordScreenState(); + ConsumerState createState() => + _ResetPasswordScreenState(); } class _ResetPasswordScreenState extends ConsumerState { @@ -52,7 +53,9 @@ class _ResetPasswordScreenState extends ConsumerState { _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) { setState(() => _isLoading = false); @@ -69,15 +72,24 @@ class _ResetPasswordScreenState extends ConsumerState { (route) => false, ); } 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( hintText: hint, - prefixIcon: prefixIcon != null ? Icon(prefixIcon, color: Colors.grey.shade500) : null, + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, color: Colors.grey.shade500) + : null, suffixIcon: suffixIcon, filled: true, fillColor: Colors.white, @@ -125,24 +137,38 @@ class _ResetPasswordScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - width: 80, height: 80, + width: 80, + height: 80, decoration: BoxDecoration( color: Theme.of(context).primaryColor.withOpacity(0.1), 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), Text( '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, ), const SizedBox(height: 8), Text( 'We\'ve sent a 6-digit code to\n${widget.email}', 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), @@ -152,18 +178,40 @@ class _ResetPasswordScreenState extends ConsumerState { keyboardType: TextInputType.number, textInputAction: TextInputAction.next, 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, decoration: InputDecoration( hintText: '000000', - hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 16), + hintStyle: TextStyle( + color: Colors.grey.shade300, + letterSpacing: 16, + ), counterText: '', filled: true, fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - 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)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + 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), @@ -178,8 +226,15 @@ class _ResetPasswordScreenState extends ConsumerState { 'New Password', prefixIcon: LucideIcons.lock, suffixIcon: IconButton( - icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + icon: Icon( + _obscurePassword + ? LucideIcons.eyeOff + : LucideIcons.eye, + color: Colors.grey.shade500, + ), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), ), ), ), @@ -196,8 +251,15 @@ class _ResetPasswordScreenState extends ConsumerState { 'Confirm Password', prefixIcon: LucideIcons.lock, suffixIcon: IconButton( - icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500), - onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm), + icon: Icon( + _obscureConfirm + ? LucideIcons.eyeOff + : LucideIcons.eye, + color: Colors.grey.shade500, + ), + onPressed: () => setState( + () => _obscureConfirm = !_obscureConfirm, + ), ), ), ), @@ -210,17 +272,35 @@ class _ResetPasswordScreenState extends ConsumerState { ? Padding( padding: const EdgeInsets.only(top: 16), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: Colors.red.shade50, borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.red.shade200), + border: Border.all( + color: Colors.red.shade200, + ), ), child: Row( children: [ - Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 20), + Icon( + LucideIcons.alertCircle, + color: Colors.red.shade400, + size: 20, + ), 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 { backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), elevation: 2, ), child: _isLoading - ? const SizedBox(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)), + ? const SizedBox( + 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, + ), + ), ), ), ], diff --git a/kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart b/kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart index d82680a..f26f012 100644 --- a/kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart +++ b/kifi-app/lib/features/auth/presentation/setup_wizard_screen.dart @@ -12,7 +12,8 @@ class SetupWizardScreen extends StatefulWidget { State createState() => _SetupWizardScreenState(); } -class _SetupWizardScreenState extends State with SingleTickerProviderStateMixin { +class _SetupWizardScreenState extends State + with SingleTickerProviderStateMixin { bool _isLoading = false; int _currentStep = 0; String _accountType = 'INDIVIDUAL'; @@ -43,7 +44,10 @@ class _SetupWizardScreenState extends State with SingleTicker if (username.length < 3) return; setState(() => _checkingUsername = true); 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) { setState(() { _isUsernameAvailable = res.data['available'] == true; @@ -70,7 +74,9 @@ class _SetupWizardScreenState extends State with SingleTicker if (_accountType == 'BUSINESS') { data.addAll({ - 'businessName': _businessNameController.text.isNotEmpty ? _businessNameController.text : _nameController.text, + 'businessName': _businessNameController.text.isNotEmpty + ? _businessNameController.text + : _nameController.text, 'natureOfBusiness': _natureOfBusiness!, 'address': _addressController.text, 'emailId': _emailController.text, @@ -82,12 +88,17 @@ class _SetupWizardScreenState extends State with SingleTicker await DioClient().dio.post('/account/setup', data: data); if (mounted) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const DashboardScreen())); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const DashboardScreen()), + ); } } catch (e) { if (mounted) setState(() => _isLoading = false); 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 with SingleTicker void _nextStep() { if (_currentStep == 1) { 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; } 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; } 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; } } if (_accountType == 'BUSINESS' && _currentStep == 2) { 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; } } @@ -163,7 +184,11 @@ class _SetupWizardScreenState extends State with SingleTicker children: [ Text( '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), ClipRRect( @@ -172,7 +197,9 @@ class _SetupWizardScreenState extends State with SingleTicker value: (_currentStep + 1) / _totalSteps, minHeight: 6, backgroundColor: Theme.of(context).primaryColor.withOpacity(0.1), - valueColor: AlwaysStoppedAnimation(Theme.of(context).primaryColor), + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), ), ), ], @@ -185,18 +212,39 @@ class _SetupWizardScreenState extends State with SingleTicker key: const ValueKey(0), crossAxisAlignment: CrossAxisAlignment.stretch, 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 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), - _buildAccountTypeCard('INDIVIDUAL', 'Individual', 'For personal use and expenses', LucideIcons.user), + _buildAccountTypeCard( + 'INDIVIDUAL', + 'Individual', + 'For personal use and expenses', + LucideIcons.user, + ), 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; return InkWell( onTap: () => setState(() => _accountType = type), @@ -205,40 +253,67 @@ class _SetupWizardScreenState extends State with SingleTicker duration: const Duration(milliseconds: 200), padding: const EdgeInsets.all(20), 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), 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, ), - boxShadow: isSelected ? [] : [ - BoxShadow(color: Colors.black.withOpacity(0.03), blurRadius: 10, offset: const Offset(0, 4)), - ], + boxShadow: isSelected + ? [] + : [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isSelected ? Theme.of(context).primaryColor : Colors.grey.shade100, + color: isSelected + ? Theme.of(context).primaryColor + : Colors.grey.shade100, 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), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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), - Text(subtitle, style: TextStyle(color: Colors.grey[600], fontSize: 14)), + Text( + subtitle, + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), ], ), ), Icon( 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, ), ], @@ -252,9 +327,15 @@ class _SetupWizardScreenState extends State with SingleTicker key: const ValueKey(1), crossAxisAlignment: CrossAxisAlignment.stretch, 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 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), TextFormField( controller: _nameController, @@ -266,17 +347,28 @@ class _SetupWizardScreenState extends State with SingleTicker controller: _usernameController, decoration: _buildInputDecoration('Username *').copyWith( suffixIcon: SizedBox( - width: 48, height: 48, + width: 48, + height: 48, child: Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), - child: _checkingUsername - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : Icon( - _isUsernameAvailable ? LucideIcons.checkCircle2 : LucideIcons.xCircle, - key: ValueKey(_isUsernameAvailable), - color: _usernameController.text.isEmpty ? Colors.transparent : (_isUsernameAvailable ? Colors.green : Colors.red), - ), + child: _checkingUsername + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : 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 with SingleTicker key: const ValueKey(2), crossAxisAlignment: CrossAxisAlignment.stretch, 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 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), DropdownButtonFormField( decoration: _buildInputDecoration('Nature of Business *'), dropdownColor: Colors.white, value: _natureOfBusiness, items: const [ - DropdownMenuItem(value: 'JEWELLERY', 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))), + DropdownMenuItem( + value: 'JEWELLERY', + 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), ), @@ -322,35 +435,35 @@ class _SetupWizardScreenState extends State with SingleTicker ), const SizedBox(height: 20), TextFormField( - controller: _addressController, + controller: _addressController, decoration: _buildInputDecoration('Business Address (Optional)'), maxLines: 3, textInputAction: TextInputAction.newline, ), const SizedBox(height: 20), TextFormField( - controller: _emailController, + controller: _emailController, decoration: _buildInputDecoration('Official Email (Optional)'), keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, ), const SizedBox(height: 20), TextFormField( - controller: _gstController, + controller: _gstController, decoration: _buildInputDecoration('GST Number (Optional)'), textCapitalization: TextCapitalization.characters, textInputAction: TextInputAction.next, ), const SizedBox(height: 20), TextFormField( - controller: _panController, + controller: _panController, decoration: _buildInputDecoration('PAN (Optional)'), textCapitalization: TextCapitalization.characters, textInputAction: TextInputAction.next, ), const SizedBox(height: 20), TextFormField( - controller: _msmeController, + controller: _msmeController, decoration: _buildInputDecoration('MSME/Udyam Number (Optional)'), textCapitalization: TextCapitalization.characters, textInputAction: TextInputAction.done, @@ -364,9 +477,15 @@ class _SetupWizardScreenState extends State with SingleTicker key: const ValueKey(3), crossAxisAlignment: CrossAxisAlignment.stretch, 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 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), Container( decoration: BoxDecoration( @@ -374,7 +493,11 @@ class _SetupWizardScreenState extends State with SingleTicker borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade300), 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( @@ -382,25 +505,49 @@ class _SetupWizardScreenState extends State with SingleTicker CheckboxListTile( title: InkWell( 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, controlAffinity: ListTileControlAffinity.leading, activeColor: Theme.of(context).primaryColor, - onChanged: (val) => setState(() => _termsAccepted = val ?? false), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + onChanged: (val) => + setState(() => _termsAccepted = val ?? false), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), ), Divider(height: 1, indent: 56, color: Colors.grey.shade200), CheckboxListTile( title: InkWell( 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, controlAffinity: ListTileControlAffinity.leading, activeColor: Theme.of(context).primaryColor, - onChanged: (val) => setState(() => _privacyAccepted = val ?? false), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + onChanged: (val) => + setState(() => _privacyAccepted = val ?? false), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), ), ], ), @@ -412,7 +559,8 @@ class _SetupWizardScreenState extends State with SingleTicker Widget _buildCurrentStep() { if (_currentStep == 0) return _buildStep0AccountType(); 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) } @@ -424,7 +572,10 @@ class _SetupWizardScreenState extends State with SingleTicker elevation: 0, backgroundColor: Colors.transparent, leading: _currentStep > 0 - ? IconButton(icon: const Icon(LucideIcons.chevronLeft), onPressed: _prevStep) + ? IconButton( + icon: const Icon(LucideIcons.chevronLeft), + onPressed: _prevStep, + ) : null, ), body: SafeArea( @@ -446,16 +597,16 @@ class _SetupWizardScreenState extends State with SingleTicker begin: const Offset(0.05, 0), end: Offset.zero, ).animate(animation), - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ); }, child: SingleChildScrollView( key: ValueKey(_currentStep), 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(), ), ), @@ -464,7 +615,10 @@ class _SetupWizardScreenState extends State with SingleTicker ), Container( 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( color: Colors.white, border: Border(top: BorderSide(color: Colors.grey.shade200)), @@ -481,14 +635,29 @@ class _SetupWizardScreenState extends State with SingleTicker backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), - child: _isLoading - ? const SizedBox(height: 24, width: 24, 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), - ), + child: _isLoading + ? const SizedBox( + height: 24, + width: 24, + 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, + ), + ), ), ), ), diff --git a/kifi-app/lib/features/auth/providers/auth_provider.dart b/kifi-app/lib/features/auth/providers/auth_provider.dart index dedd0f3..8e42fd4 100644 --- a/kifi-app/lib/features/auth/providers/auth_provider.dart +++ b/kifi-app/lib/features/auth/providers/auth_provider.dart @@ -17,7 +17,7 @@ class AuthController extends AsyncNotifier { state = const AsyncValue.loading(); try { 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); return true; } catch (e, st) { @@ -42,7 +42,7 @@ class AuthController extends AsyncNotifier { state = const AsyncValue.loading(); try { 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); return true; } catch (e, st) { @@ -63,7 +63,11 @@ class AuthController extends AsyncNotifier { } } - Future resetPassword(String email, String otp, String newPassword) async { + Future resetPassword( + String email, + String otp, + String newPassword, + ) async { state = const AsyncValue.loading(); try { await _repository.resetPassword(email, otp, newPassword); diff --git a/kifi-app/lib/features/business/domain/business_feature.dart b/kifi-app/lib/features/business/domain/business_feature.dart index 7b0e13b..a07c6c9 100644 --- a/kifi-app/lib/features/business/domain/business_feature.dart +++ b/kifi-app/lib/features/business/domain/business_feature.dart @@ -34,7 +34,9 @@ class BusinessFeature { bomReductionStrategy: json['bomReductionStrategy'] ?? 'COMPONENTS_ONLY', stockDeductionOnInvoice: json['stockDeductionOnInvoice'] ?? true, 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, multiLocation: multiLocation ?? this.multiLocation, bomReductionStrategy: bomReductionStrategy ?? this.bomReductionStrategy, - stockDeductionOnInvoice: stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, + stockDeductionOnInvoice: + stockDeductionOnInvoice ?? this.stockDeductionOnInvoice, barcodeSource: barcodeSource ?? this.barcodeSource, createdAt: createdAt, ); diff --git a/kifi-app/lib/features/business/domain/indian_state.dart b/kifi-app/lib/features/business/domain/indian_state.dart index 0faf936..32a2193 100644 --- a/kifi-app/lib/features/business/domain/indian_state.dart +++ b/kifi-app/lib/features/business/domain/indian_state.dart @@ -3,11 +3,7 @@ class IndianState { final String name; final String gstCode; - IndianState({ - required this.id, - required this.name, - required this.gstCode, - }); + IndianState({required this.id, required this.name, required this.gstCode}); factory IndianState.fromJson(Map json) { return IndianState( @@ -18,10 +14,6 @@ class IndianState { } Map toJson() { - return { - 'id': id, - 'name': name, - 'gstCode': gstCode, - }; + return {'id': id, 'name': name, 'gstCode': gstCode}; } } diff --git a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart index 73a23f5..3e51d70 100644 --- a/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart +++ b/kifi-app/lib/features/business/presentation/hub/business_hub_screen.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/theme/nature_colors.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/uoms_list_screen.dart'; import '../../../inventory/presentation/category_management_screen.dart'; import '../../../sales/presentation/customers_list_screen.dart'; import '../../../sales/presentation/invoices_list_screen.dart'; @@ -22,12 +22,13 @@ class BusinessHubScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final businessState = ref.watch(businessProfileProvider); final profile = businessState.value; - - final title = (profile?.businessName != null && profile!.businessName.isNotEmpty) - ? profile.businessName + + final title = + (profile?.businessName != null && profile!.businessName.isNotEmpty) + ? profile.businessName : 'Business Overview'; - final subtitle = (profile?.address != null && profile!.address!.isNotEmpty) - ? profile.address! + final subtitle = (profile?.address != null && profile!.address!.isNotEmpty) + ? profile.address! : 'Manage your inventory and stock'; final featureState = ref.watch(businessFeatureProvider).value; @@ -35,236 +36,337 @@ class BusinessHubScreen extends ConsumerWidget { final bool showSales = featureState?.salesManagement ?? false; final bool showPurchase = featureState?.purchaseManagement ?? false; - return SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - InkWell( - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => const BusinessProfileFormSheet(), - ); - }, - 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)), + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(businessProfileProvider); + ref.invalidate(businessFeatureProvider); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const BusinessProfileFormSheet(), + ); + }, + 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: [ - 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)), - ], + ), + const SizedBox(height: 24), + GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: 1.2, + 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()), + ), + ), + ], ), - ), - const SizedBox(height: 24), - GridView.count( - crossAxisCount: 2, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - childAspectRatio: 1.2, - children: [ - if (showInventory) ...[ - _buildActionCard( - context, - 'Products Catalog', - '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())), + if (showInventory) ...[ + const SizedBox(height: 32), + Text( + 'Low Stock Alerts', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), - ], - ), - if (showInventory) ...[ - const SizedBox(height: 32), - Text('Low Stock Alerts', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), - Consumer( - builder: (context, ref, child) { - final productsState = ref.watch(productsProvider); - return productsState.when( - data: (products) { - final lowStockProducts = products.where((p) { - if (!(p.trackInventory ?? false)) return false; - if (p.currentStock == null) return false; - final minStock = p.minStock ?? 0; - return p.currentStock! <= minStock; - }).toList(); + Consumer( + builder: (context, ref, child) { + final productsState = ref.watch(productsProvider); + return productsState.when( + data: (products) { + final lowStockProducts = products.where((p) { + if (p.currentStock == null) return false; + if (p.currentStock == null) return false; + final minStock = 0; + return p.currentStock! <= minStock; + }).toList(); - 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; + if (lowStockProducts.isEmpty) { return Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), 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)), + color: Colors.grey.withOpacity(0.05), + borderRadius: BorderRadius.circular(16), ), - 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( - 'Min: ${product.minStock ?? 0}', - style: TextStyle(fontSize: 12, color: Colors.grey.shade600), - ), - ], - ), - ], + child: const Center( + child: Text( + 'All products are adequately stocked.', + style: TextStyle(color: Colors.grey), + ), ), ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center(child: Text('Error: $error')), - ); - }, - ), + } + + 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( + 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( onTap: onTap, child: Container( @@ -280,9 +382,19 @@ class BusinessHubScreen extends ConsumerWidget { children: [ Icon(icon, color: color, size: 28), 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), - Text(subtitle, style: TextStyle(color: color.withOpacity(0.7), fontSize: 12)), + Text( + subtitle, + style: TextStyle(color: color.withOpacity(0.7), fontSize: 12), + ), ], ), ), diff --git a/kifi-app/lib/features/business/presentation/reports/reports_screen.dart b/kifi-app/lib/features/business/presentation/reports/reports_screen.dart index 501f794..b20529f 100644 --- a/kifi-app/lib/features/business/presentation/reports/reports_screen.dart +++ b/kifi-app/lib/features/business/presentation/reports/reports_screen.dart @@ -13,9 +13,7 @@ class ReportsScreen extends ConsumerWidget { final formatCurrency = NumberFormat.currency(symbol: '₹'); return Scaffold( - appBar: AppBar( - title: const Text('Business Reports'), - ), + appBar: AppBar(title: const Text('Business Reports')), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -29,7 +27,10 @@ class ReportsScreen extends ConsumerWidget { content: valuationState.when( data: (val) => Text( '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(), error: (e, s) => Text('Error: $e'), @@ -41,7 +42,9 @@ class ReportsScreen extends ConsumerWidget { title: 'GST Report (Coming Soon)', icon: LucideIcons.fileText, 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), _buildReportCard( @@ -57,7 +60,8 @@ class ReportsScreen extends ConsumerWidget { ); } - Widget _buildReportCard(BuildContext context, { + Widget _buildReportCard( + BuildContext context, { required String title, required IconData icon, required Color color, @@ -81,7 +85,13 @@ class ReportsScreen extends ConsumerWidget { child: Icon(icon, color: color), ), 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), diff --git a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart index f1ed6d5..bed463e 100644 --- a/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart +++ b/kifi-app/lib/features/business/presentation/settings/business_settings_screen.dart @@ -7,10 +7,12 @@ class BusinessSettingsScreen extends ConsumerStatefulWidget { const BusinessSettingsScreen({super.key}); @override - ConsumerState createState() => _BusinessSettingsScreenState(); + ConsumerState createState() => + _BusinessSettingsScreenState(); } -class _BusinessSettingsScreenState extends ConsumerState { +class _BusinessSettingsScreenState + extends ConsumerState { bool _taxIncludedInPrice = false; bool _salesEnabled = true; bool _inventoryEnabled = true; @@ -48,11 +50,20 @@ class _BusinessSettingsScreenState extends ConsumerState body: ListView( padding: const EdgeInsets.all(24.0), 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), SwitchListTile( 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, onChanged: (val) { setState(() => _inventoryEnabled = val); @@ -62,7 +73,9 @@ class _BusinessSettingsScreenState extends ConsumerState ), SwitchListTile( 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, onChanged: (val) { setState(() => _salesEnabled = val); @@ -72,7 +85,9 @@ class _BusinessSettingsScreenState extends ConsumerState ), SwitchListTile( 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, onChanged: (val) { setState(() => _purchaseEnabled = val); @@ -81,64 +96,105 @@ class _BusinessSettingsScreenState extends ConsumerState secondary: const Icon(LucideIcons.truck), ), 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), Consumer( builder: (context, ref, child) { final featureState = ref.watch(businessFeatureProvider); - final currentStrategy = featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY'; - final deductParentStock = currentStrategy == 'PARENT_AND_COMPONENTS'; - final stockDeductionOnInvoice = featureState.value?.stockDeductionOnInvoice ?? true; - + final currentStrategy = + featureState.value?.bomReductionStrategy ?? 'COMPONENTS_ONLY'; + final deductParentStock = + currentStrategy == 'PARENT_AND_COMPONENTS'; + final stockDeductionOnInvoice = + featureState.value?.stockDeductionOnInvoice ?? true; + return Column( children: [ SwitchListTile( 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, secondary: const Icon(LucideIcons.gitMerge), onChanged: (val) { if (featureState.value != null) { - final newStrategy = val ? 'PARENT_AND_COMPONENTS' : 'COMPONENTS_ONLY'; - final updated = featureState.value!.copyWith(bomReductionStrategy: newStrategy); - ref.read(businessFeatureProvider.notifier).updateFeatures(updated); + final newStrategy = val + ? 'PARENT_AND_COMPONENTS' + : 'COMPONENTS_ONLY'; + final updated = featureState.value!.copyWith( + bomReductionStrategy: newStrategy, + ); + ref + .read(businessFeatureProvider.notifier) + .updateFeatures(updated); } }, ), SwitchListTile( 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, secondary: const Icon(LucideIcons.boxes), onChanged: (val) { if (featureState.value != null) { - final updated = featureState.value!.copyWith(stockDeductionOnInvoice: val); - ref.read(businessFeatureProvider.notifier).updateFeatures(updated); + final updated = featureState.value!.copyWith( + stockDeductionOnInvoice: val, + ); + ref + .read(businessFeatureProvider.notifier) + .updateFeatures(updated); } }, ), SwitchListTile( title: const Text('Use EAN/Barcode for Scanner'), - subtitle: const Text('If disabled, the barcode scanner will search using the SKU field instead.'), - value: (featureState.value?.barcodeSource ?? 'SKU') == 'BARCODE', + subtitle: const Text( + 'If disabled, the barcode scanner will search using the SKU field instead.', + ), + value: + (featureState.value?.barcodeSource ?? 'SKU') == + 'BARCODE', secondary: const Icon(LucideIcons.scanLine), onChanged: (val) { if (featureState.value != null) { - final updated = featureState.value!.copyWith(barcodeSource: val ? 'BARCODE' : 'SKU'); - ref.read(businessFeatureProvider.notifier).updateFeatures(updated); + final updated = featureState.value!.copyWith( + barcodeSource: val ? 'BARCODE' : 'SKU', + ); + ref + .read(businessFeatureProvider.notifier) + .updateFeatures(updated); } }, ), ], ); - } + }, ), 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), SwitchListTile( 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, onChanged: (val) { setState(() => _taxIncludedInPrice = val); @@ -154,7 +210,9 @@ class _BusinessSettingsScreenState extends ConsumerState void _saveSettings() { final profileState = ref.read(businessProfileProvider).value; if (profileState != null) { - final updated = profileState.copyWith(taxIncludedInPrice: _taxIncludedInPrice); + final updated = profileState.copyWith( + taxIncludedInPrice: _taxIncludedInPrice, + ); ref.read(businessProfileProvider.notifier).updateProfile(updated); } } diff --git a/kifi-app/lib/features/business/providers/business_provider.dart b/kifi-app/lib/features/business/providers/business_provider.dart index 67c58ca..03885ae 100644 --- a/kifi-app/lib/features/business/providers/business_provider.dart +++ b/kifi-app/lib/features/business/providers/business_provider.dart @@ -25,7 +25,7 @@ class BusinessProfileNotifier extends AsyncNotifier { '/business/profile', data: profile.toJson(), ); - + if (response.statusCode == 200) { state = AsyncValue.data(BusinessProfile.fromJson(response.data)); } @@ -35,9 +35,10 @@ class BusinessProfileNotifier extends AsyncNotifier { } } -final businessProfileProvider = AsyncNotifierProvider(() { - return BusinessProfileNotifier(); -}); +final businessProfileProvider = + AsyncNotifierProvider(() { + return BusinessProfileNotifier(); + }); class BusinessFeatureNotifier extends AsyncNotifier { @override @@ -64,7 +65,7 @@ class BusinessFeatureNotifier extends AsyncNotifier { '/business/features', data: feature.toJson(), ); - + if (response.statusCode == 200) { state = AsyncValue.data(BusinessFeature.fromJson(response.data)); } @@ -74,6 +75,7 @@ class BusinessFeatureNotifier extends AsyncNotifier { } } -final businessFeatureProvider = AsyncNotifierProvider(() { - return BusinessFeatureNotifier(); -}); +final businessFeatureProvider = + AsyncNotifierProvider(() { + return BusinessFeatureNotifier(); + }); diff --git a/kifi-app/lib/features/business/providers/indian_states_provider.dart b/kifi-app/lib/features/business/providers/indian_states_provider.dart index 9bbb048..78dc1d5 100644 --- a/kifi-app/lib/features/business/providers/indian_states_provider.dart +++ b/kifi-app/lib/features/business/providers/indian_states_provider.dart @@ -9,17 +9,27 @@ class IndianStatesNotifier extends AsyncNotifier> { } Future> _fetchStates() async { - final response = await DioClient().dio.get('/master/states').timeout( - const Duration(seconds: 5), - onTimeout: () => throw Exception('Connection timed out'), - ); + final response = await DioClient().dio + .get('/master/states') + .timeout( + const Duration(seconds: 5), + onTimeout: () => throw Exception('Connection timed out'), + ); if (response.data == null || response.data.toString().isEmpty) { 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 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>(() { - return IndianStatesNotifier(); -}); +final indianStatesProvider = + AsyncNotifierProvider>(() { + return IndianStatesNotifier(); + }); diff --git a/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart b/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart index 4ef084d..9ea754b 100644 --- a/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart +++ b/kifi-app/lib/features/dashboard/presentation/wallet_ledger_screen.dart @@ -21,14 +21,19 @@ class _WalletLedgerScreenState extends ConsumerState { void _showFilterSheet() { showModalBottomSheet( context: context, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), builder: (ctx) { return Padding( padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), child: Column( mainAxisSize: MainAxisSize.min, 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), ListTile( title: const Text('Today'), @@ -95,8 +100,14 @@ class _WalletLedgerScreenState extends ConsumerState { error: (err, stack) => Center(child: Text('Error: $err')), data: (transactions) { // 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 walletTxs.sort((a, b) => a.date.compareTo(b.date)); @@ -110,15 +121,23 @@ class _WalletLedgerScreenState extends ConsumerState { } else if (_selectedFilter == 'Current Month') { startDate = DateTime(now.year, now.month, 1); 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; - 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) double openingBalance = 0.0; List visibleTxs = []; - + for (var t in walletTxs) { double impact = 0; if (t.toWalletId == widget.wallet.id) { @@ -139,28 +158,44 @@ class _WalletLedgerScreenState extends ConsumerState { // 4. Generate Ledger Rows double runningBalance = openingBalance; List rows = []; - + // Add Opening Balance Row if we have a date filter if (startDate != null) { - rows.add(DataRow( - cells: [ - const DataCell(Text('-')), - DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))), - DataCell(Text('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))), - ], - )); + rows.add( + DataRow( + cells: [ + const DataCell(Text('-')), + DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))), + DataCell( + Text( + '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++) { final t = visibleTxs[i]; - - bool isIncome = t.toWalletId == widget.wallet.id && t.fromWalletId == null; - bool isExpense = 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; + + bool isIncome = + t.toWalletId == widget.wallet.id && t.fromWalletId == null; + bool isExpense = + 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; if (isExpense || isTransferOut) { @@ -171,10 +206,15 @@ class _WalletLedgerScreenState extends ConsumerState { String fromName = '-'; String toName = '-'; - + if (isIncome || isTransferIn) { 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 { fromName = 'External'; } @@ -182,29 +222,53 @@ class _WalletLedgerScreenState extends ConsumerState { } else if (isExpense || isTransferOut) { fromName = widget.wallet.name; 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 { toName = 'External'; } } - Color amountColor = (isExpense || isTransferOut) ? Colors.red : Colors.green.shade700; + Color amountColor = (isExpense || isTransferOut) + ? Colors.red + : Colors.green.shade700; String amountPrefix = (isExpense || isTransferOut) ? '-' : '+'; - rows.add(DataRow( - cells: [ - DataCell(Text('${startDate != null ? i + 1 : i + 1}')), - DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))), - DataCell(Text('$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))), - ], - )); + rows.add( + DataRow( + cells: [ + DataCell(Text('${startDate != null ? i + 1 : i + 1}')), + DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))), + DataCell( + Text( + '$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) { - return const Center(child: Text('No transactions found for this period.')); + return const Center( + child: Text('No transactions found for this period.'), + ); } return Column( @@ -214,8 +278,17 @@ class _WalletLedgerScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Filter: $_selectedFilter', style: TextStyle(color: Colors.grey.shade600)), - Text('Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + Text( + '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 { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( - headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Colors.grey.shade100, + ), columnSpacing: 24, columns: const [ - DataColumn(label: Text('S.No', 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))), + DataColumn( + label: Text( + 'S.No', + 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, ), diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart b/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart index 498f58f..b9a564e 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/budget_status_card.dart @@ -23,22 +23,30 @@ class BudgetStatusCard extends ConsumerWidget { } 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; for (var b in budgets) { 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; } } 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; - if (progress > 0.9) progressColor = Colors.red; - else if (progress > 0.7) progressColor = Colors.orange; + if (progress > 0.9) + progressColor = Colors.red; + else if (progress > 0.7) + progressColor = Colors.orange; return Card( child: InkWell( @@ -55,7 +63,10 @@ class BudgetStatusCard extends ConsumerWidget { children: [ Icon(LucideIcons.target, size: 18), 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), @@ -70,10 +81,16 @@ class BudgetStatusCard extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Spent: Rs. ${totalSpent.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)), - Text('Limit: Rs. ${totalLimit.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold)), + Text( + 'Spent: Rs. ${totalSpent.toStringAsFixed(0)}', + style: TextStyle(color: Colors.grey.shade700), + ), + Text( + 'Limit: Rs. ${totalLimit.toStringAsFixed(0)}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), ], - ) + ), ], ), ), diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart b/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart index 705e53d..808c545 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/category_spending_chart.dart @@ -9,8 +9,8 @@ class CategorySpendingChart extends StatefulWidget { final String selectedNature; const CategorySpendingChart({ - super.key, - required this.transactions, + super.key, + required this.transactions, required this.categories, required this.selectedNature, }); @@ -28,9 +28,16 @@ class _CategorySpendingChartState extends State { final Map categoryTotals = {}; for (var t in widget.transactions) { bool matches = false; - if (widget.selectedNature == 'INCOME') matches = 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 (widget.selectedNature == 'INCOME') + matches = + 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) { final catId = t.categoryId ?? -1; @@ -42,8 +49,9 @@ class _CategorySpendingChartState extends State { return const SizedBox(); } - final List> sortedTotals = categoryTotals.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); + final List> sortedTotals = + categoryTotals.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); final List colors = NatureColors.getPalette(widget.selectedNature); @@ -77,9 +85,20 @@ class _CategorySpendingChartState extends State { decoration: BoxDecoration( color: Theme.of(context).cardColor, 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, badgePositionPercentageOffset: 1.1, @@ -89,23 +108,41 @@ class _CategorySpendingChartState extends State { return Card( 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( decoration: BoxDecoration( borderRadius: BorderRadius.circular(32), gradient: LinearGradient( begin: Alignment.topLeft, 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), child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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), - + SizedBox( height: 220, child: Stack( @@ -116,11 +153,15 @@ class _CategorySpendingChartState extends State { pieTouchData: PieTouchData( touchCallback: (FlTouchEvent event, pieTouchResponse) { setState(() { - if (!event.isInterestedForInteractions || pieTouchResponse == null || pieTouchResponse.touchedSection == null) { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { touchedIndex = -1; return; } - touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex; + touchedIndex = pieTouchResponse + .touchedSection! + .touchedSectionIndex; }); }, ), @@ -134,47 +175,85 @@ class _CategorySpendingChartState extends State { Column( mainAxisSize: MainAxisSize.min, children: [ - Text('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)), + Text( + '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, + ), + ), ], ), ], ), ), - + 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), - + // Modern List for Legends ...List.generate(sortedTotals.length, (i) { String categoryName = 'Uncategorized'; 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; } final amount = sortedTotals[i].value; - final percentage = (amount / totalAmount * 100).toStringAsFixed(1); + final percentage = (amount / totalAmount * 100).toStringAsFixed( + 1, + ); final isTouched = i == touchedIndex; return AnimatedContainer( duration: const Duration(milliseconds: 200), margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), 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), 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, ), 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, offset: const Offset(0, 4), - ) + ), ], ), child: Row( @@ -184,10 +263,16 @@ class _CategorySpendingChartState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: colors[i % colors.length].withValues(alpha: 0.15), + color: colors[i % colors.length].withValues( + alpha: 0.15, + ), 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), Expanded( @@ -196,19 +281,45 @@ class _CategorySpendingChartState extends State { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, 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), - 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( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( color: colors[i % colors.length].withValues(alpha: 0.1), 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], + ), + ), ), ], ), diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart b/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart index ffb7440..357935c 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/day_wise_spending_chart.dart @@ -23,31 +23,50 @@ class DayWiseSpendingChart extends StatelessWidget { Widget build(BuildContext context) { // 1. Determine date range length 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 final Map dailyTotals = {}; 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 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)) { bool matches = false; - if (selectedNature == 'INCOME') matches = t.type == 'INCOME' || (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 (selectedNature == 'INCOME') + matches = + t.type == 'INCOME' || + (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) { final w = wallets.where((w) => w.id == t.toWalletId); if (w.isNotEmpty) { final nature = w.first.nature; - if (selectedNature == 'PAYABLES' && (nature == 'PAYABLES' || nature == 'LOAN')) matches = true; - if (selectedNature == 'RECEIVABLES' && (nature == 'RECEIVABLES' || nature == 'LENDING')) matches = true; + if (selectedNature == 'PAYABLES' && + (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 final screenWidth = MediaQuery.of(context).size.width; double chartWidth = (screenWidth / 7) * displayDays; - if (chartWidth < screenWidth - 80) { // minimum width + if (chartWidth < screenWidth - 80) { + // minimum width chartWidth = screenWidth - 80; } return Card( 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( padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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), SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -100,51 +126,73 @@ class DayWiseSpendingChart extends StatelessWidget { showTitles: true, getTitlesWidget: (value, meta) { 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 text = "${date.day} ${_monthStr(date.month)}"; - + // To prevent overcrowding on large ranges - if (displayDays > 14 && index % 2 != 0) return const SizedBox(); - if (displayDays > 30 && index % 5 != 0) return const SizedBox(); + if (displayDays > 14 && index % 2 != 0) + return const SizedBox(); + if (displayDays > 30 && index % 5 != 0) + return const SizedBox(); return Padding( 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, ), ), - leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), - topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), ), gridData: FlGridData( show: true, drawVerticalLine: false, 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), - barGroups: dailyTotals.entries.toList().asMap().entries.map((entry) { - return BarChartGroupData( - x: entry.key, - barRods: [ - BarChartRodData( - toY: entry.value.value, - color: barColor, - width: 16, - borderRadius: BorderRadius.circular(8), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: maxY * 1.2, - color: Colors.grey.shade100, + barGroups: dailyTotals.entries.toList().asMap().entries.map( + (entry) { + return BarChartGroupData( + x: entry.key, + barRods: [ + BarChartRodData( + toY: entry.value.value, + color: barColor, + width: 16, + borderRadius: BorderRadius.circular(8), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: maxY * 1.2, + color: Colors.grey.shade100, + ), ), - ), - ], - ); - }).toList(), + ], + ); + }, + ).toList(), ), ), ), @@ -156,7 +204,20 @@ class DayWiseSpendingChart extends StatelessWidget { } 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]; } } diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart b/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart index 60a39c2..2900b59 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/statistics_tab.dart @@ -38,11 +38,21 @@ class StatisticsTab extends StatefulWidget { class _StatisticsTabState extends State { 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; return Card( 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( padding: const EdgeInsets.all(16.0), child: Column( @@ -51,13 +61,35 @@ class _StatisticsTabState extends State { children: [ Row( 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), - 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), - 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,13 +99,27 @@ class _StatisticsTabState extends State { @override Widget build(BuildContext context) { // 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 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); - + final totalIncome = widget.transactions + .where( + (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 totalReceivablesPeriod = 0.0; - + for (var t in widget.transactions) { if (t.toWalletId != null) { final w = widget.wallets.where((w) => w.id == t.toWalletId); @@ -124,13 +170,19 @@ class _StatisticsTabState extends State { ), if (widget.selectedFilter == 'Custom') Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), child: Text( '${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, + ), ), ), - + Padding( padding: const EdgeInsets.all(16.0), child: Column( @@ -139,9 +191,18 @@ class _StatisticsTabState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Analytics', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + const Text( + 'Analytics', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 0, + ), decoration: BoxDecoration( color: barColor.withOpacity(0.1), borderRadius: BorderRadius.circular(20), @@ -150,13 +211,31 @@ class _StatisticsTabState extends State { value: _selectedNature, underline: const SizedBox(), icon: Icon(Icons.arrow_drop_down, color: barColor), - style: TextStyle(color: barColor, fontWeight: FontWeight.bold), + style: TextStyle( + color: barColor, + fontWeight: FontWeight.bold, + ), items: const [ - DropdownMenuItem(value: 'EXPENSE', child: Text('Expense')), - DropdownMenuItem(value: 'INCOME', child: Text('Income')), - DropdownMenuItem(value: 'PAYABLES', child: Text('Payables')), - DropdownMenuItem(value: 'RECEIVABLES', child: Text('Receivables')), - DropdownMenuItem(value: 'INVESTMENTS', child: Text('Investments')), + DropdownMenuItem( + value: 'EXPENSE', + child: Text('Expense'), + ), + 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) { if (val != null) { @@ -168,19 +247,33 @@ class _StatisticsTabState extends State { ], ), const SizedBox(height: 24), - + if (widget.transactions.isEmpty) Card( 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( 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 ...[ DayWiseSpendingChart( - transactions: widget.transactions, + transactions: widget.transactions, wallets: widget.wallets, selectedNature: _selectedNature, startDate: widget.startDate, @@ -188,16 +281,19 @@ class _StatisticsTabState extends State { ), const SizedBox(height: 16), CategorySpendingChart( - transactions: widget.transactions, + transactions: widget.transactions, categories: widget.categories, selectedNature: _selectedNature, ), ], - + 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), - + GridView.count( crossAxisCount: 2, crossAxisSpacing: 12, @@ -206,11 +302,36 @@ class _StatisticsTabState extends State { physics: const NeverScrollableScrollPhysics(), childAspectRatio: 1.5, children: [ - _buildTotalCard('Income', totalIncome, NatureColors.getColor('INCOME'), 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), + _buildTotalCard( + 'Income', + totalIncome, + NatureColors.getColor('INCOME'), + 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), diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart b/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart index 02c3e11..a75a07e 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/swipeable_account_card.dart @@ -22,10 +22,7 @@ class CarouselItemData { class SwipeableAccountCard extends StatefulWidget { final List items; - const SwipeableAccountCard({ - super.key, - required this.items, - }); + const SwipeableAccountCard({super.key, required this.items}); @override State createState() => _SwipeableAccountCardState(); @@ -71,14 +68,25 @@ class _SwipeableAccountCardState extends State { return GestureDetector( onTap: item.onTap, 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), decoration: BoxDecoration( color: Theme.of(context).cardColor, 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(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( @@ -89,11 +97,7 @@ class _SwipeableAccountCardState extends State { color: item.color.withOpacity(0.1), shape: BoxShape.circle, ), - child: Icon( - item.icon, - color: item.color, - size: 20, - ), + child: Icon(item.icon, color: item.color, size: 20), ), const SizedBox(width: 16), Expanded( @@ -102,8 +106,11 @@ class _SwipeableAccountCardState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - item.title, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + item.title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -111,17 +118,24 @@ class _SwipeableAccountCardState extends State { const SizedBox(height: 4), Text( item.subtitle, - style: TextStyle(color: Colors.grey.shade600, fontSize: 12), + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - ] + ], ], ), ), Text( '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, + ), ), ], ), @@ -142,8 +156,8 @@ class _SwipeableAccountCardState extends State { height: 8.0, decoration: BoxDecoration( shape: BoxShape.circle, - color: _currentPage == index - ? widget.items[index].color + color: _currentPage == index + ? widget.items[index].color : Colors.grey.withOpacity(0.3), ), ), diff --git a/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart b/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart index ad45166..fd5999c 100644 --- a/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart +++ b/kifi-app/lib/features/dashboard/presentation/widgets/upcoming_dues_widget.dart @@ -13,30 +13,30 @@ class UpcomingDuesWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isDark = Theme.of(context).brightness == Brightness.dark; final walletsState = ref.watch(walletProvider); - + if (!walletsState.hasValue || walletsState.value == null) { return const SizedBox.shrink(); } - + final wallets = walletsState.value!; - + // Filter for payables that have a due date/amount final payables = wallets.where((w) => w.nature == 'PAYABLES').toList(); - + if (payables.isEmpty) { return const SizedBox.shrink(); } - + final now = DateTime.now(); - + // Calculate dues final List> dues = []; - + for (var w in payables) { double dueAmount = 0; DateTime? dueDate; String dueLabel = 'Upcoming Due'; - + if (w.subNature == 'CREDIT_CARD' || w.subNature == 'OD_LIMIT') { // Balance is negative if we owe money if (w.balance < 0) { @@ -45,11 +45,11 @@ class UpcomingDuesWidget extends ConsumerWidget { } else if (w.subNature == 'LOAN_EMI' || w.subNature == 'POLICY_PREMIUM') { dueAmount = w.fixedAmount ?? 0; } - + if (dueAmount > 0 && w.cycleDate != null) { int year = now.year; int month = now.month; - + // Find next due date based on cycle if (w.paymentCycle == 'MONTHLY' || w.paymentCycle == null) { if (now.day > w.cycleDate!) { @@ -63,18 +63,20 @@ class UpcomingDuesWidget extends ConsumerWidget { } else if (w.paymentCycle == 'YEARLY') { // Assume cycleDate is day of current month, this is simplistic if (now.day > w.cycleDate!) { - year++; + year++; } } - + // Handle end of month issues (e.g. Feb 30th) int maxDays = DateTime(year, month + 1, 0).day; int day = w.cycleDate! > maxDays ? maxDays : w.cycleDate!; - + 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) { dueLabel = 'Due Today'; } else if (daysLeft == 1) { @@ -82,7 +84,7 @@ class UpcomingDuesWidget extends ConsumerWidget { } else { dueLabel = 'Due in $daysLeft days'; } - + dues.add({ 'wallet': w, 'amount': dueAmount, @@ -92,25 +94,34 @@ class UpcomingDuesWidget extends ConsumerWidget { }); } } - + if (dues.isEmpty) { return const SizedBox.shrink(); } - + // Sort by nearest due date dues.sort((a, b) => (a['daysLeft'] as int).compareTo(b['daysLeft'] as int)); - + // Only show top 3 dues final displayDues = dues.take(3).toList(); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ - const Icon(LucideIcons.calendarClock, color: Color(0xFF6C63FF), size: 20), + const Icon( + LucideIcons.calendarClock, + color: Color(0xFF6C63FF), + size: 20, + ), 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), @@ -120,18 +131,33 @@ class UpcomingDuesWidget extends ConsumerWidget { final dueDate = due['dueDate'] as DateTime; final label = due['label'] as String; final daysLeft = due['daysLeft'] as int; - + final isUrgent = daysLeft <= 3; - + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), 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), - 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: [ - 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( @@ -139,13 +165,22 @@ class UpcomingDuesWidget extends ConsumerWidget { Container( padding: const EdgeInsets.all(10), 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, ), child: Icon( - w.subNature == 'CREDIT_CARD' ? LucideIcons.creditCard : - (w.subNature == 'LOAN_EMI' ? LucideIcons.home : LucideIcons.fileText), - color: isUrgent ? Colors.red : NatureColors.getColor('PAYABLES'), + w.subNature == 'CREDIT_CARD' + ? LucideIcons.creditCard + : (w.subNature == 'LOAN_EMI' + ? LucideIcons.home + : LucideIcons.fileText), + color: isUrgent + ? Colors.red + : NatureColors.getColor('PAYABLES'), size: 20, ), ), @@ -154,14 +189,25 @@ class UpcomingDuesWidget extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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), Text( '$label • ${DateFormat('MMM dd').format(dueDate)}', style: TextStyle( - color: isUrgent ? Colors.red.shade700 : Colors.grey.shade600, + color: isUrgent + ? Colors.red.shade700 + : Colors.grey.shade600, fontSize: 12, - fontWeight: isUrgent ? FontWeight.bold : FontWeight.normal + fontWeight: isUrgent + ? FontWeight.bold + : FontWeight.normal, ), ), ], @@ -170,9 +216,11 @@ class UpcomingDuesWidget extends ConsumerWidget { Text( 'Rs. ${amount.toStringAsFixed(0)}', style: TextStyle( - fontWeight: FontWeight.bold, + fontWeight: FontWeight.bold, 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, ), ), ], diff --git a/kifi-app/lib/features/dashboard/providers/insight_provider.dart b/kifi-app/lib/features/dashboard/providers/insight_provider.dart index 06fbda5..b395175 100644 --- a/kifi-app/lib/features/dashboard/providers/insight_provider.dart +++ b/kifi-app/lib/features/dashboard/providers/insight_provider.dart @@ -15,7 +15,9 @@ final insightProvider = Provider((ref) { final budgetsState = ref.watch(budgetProvider); final categoriesState = ref.watch(categoryProvider); - if (!transState.hasValue || !budgetsState.hasValue || !categoriesState.hasValue) { + if (!transState.hasValue || + !budgetsState.hasValue || + !categoriesState.hasValue) { return null; } @@ -24,17 +26,23 @@ final insightProvider = Provider((ref) { final categories = categoriesState.value!; 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 totalExpense = currentMonthTxs.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount); + final totalIncome = currentMonthTxs + .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 if (totalExpense > totalIncome && totalIncome > 0) { return Insight( - 'Spending Alert', + 'Spending Alert', 'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})', - 'WARNING' + 'WARNING', ); } @@ -43,20 +51,30 @@ final insightProvider = Provider((ref) { final spent = currentMonthTxs .where((t) => t.type == 'EXPENSE' && t.categoryId == budget.categoryId) .fold(0.0, (s, t) => s + t.amount); - + 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( 'Budget Exceeded', 'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.', - 'WARNING' + 'WARNING', ); } 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( 'Nearing Budget Limit', 'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.', - 'WARNING' + 'WARNING', ); } } @@ -66,18 +84,26 @@ final insightProvider = Provider((ref) { final Map spentByCategory = {}; for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) { 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) { - 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) { - 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( 'High Concentration', '${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.', - 'INFO' + 'INFO', ); } } @@ -88,13 +114,13 @@ final insightProvider = Provider((ref) { return Insight( 'Great Job!', 'You have saved over 50% of your income this month. Keep it up!', - 'SUCCESS' + 'SUCCESS', ); } return Insight( 'On Track', 'Your finances are looking stable this month.', - 'SUCCESS' + 'SUCCESS', ); }); diff --git a/kifi-app/lib/features/inventory/domain/category_rate_history.dart b/kifi-app/lib/features/inventory/domain/category_rate_history.dart index 830f10f..a1bf791 100644 --- a/kifi-app/lib/features/inventory/domain/category_rate_history.dart +++ b/kifi-app/lib/features/inventory/domain/category_rate_history.dart @@ -31,7 +31,8 @@ class CategoryRateHistory { 'id': id, 'categoryId': categoryId, '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(), 'updatedAt': updatedAt.toIso8601String(), }; diff --git a/kifi-app/lib/features/inventory/domain/inventory_item.dart b/kifi-app/lib/features/inventory/domain/inventory_item.dart index 0af2153..6cb3251 100644 --- a/kifi-app/lib/features/inventory/domain/inventory_item.dart +++ b/kifi-app/lib/features/inventory/domain/inventory_item.dart @@ -22,6 +22,7 @@ class InventoryItem { final int? branchId; final String? purchaseRef; final String? status; + final DateTime? createdAt; InventoryItem({ this.id, @@ -47,6 +48,7 @@ class InventoryItem { this.branchId, this.purchaseRef, this.status, + this.createdAt, }); factory InventoryItem.fromJson(Map json) { @@ -74,6 +76,7 @@ class InventoryItem { branchId: json['branchId'], purchaseRef: json['purchaseRef'], status: json['status'], + createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, ); } } diff --git a/kifi-app/lib/features/inventory/domain/product.dart b/kifi-app/lib/features/inventory/domain/product.dart index a01984b..a2697b5 100644 --- a/kifi-app/lib/features/inventory/domain/product.dart +++ b/kifi-app/lib/features/inventory/domain/product.dart @@ -9,13 +9,9 @@ class Product { final String? sku; final String? barcode; final String? description; - final double? purchasePrice; final double? sellingPrice; - final double? minStock; - final double? reorderLevel; final double? gstRate; final String? dimensions; - final double? weight; final String? color; final String? size; final String priceCalcRule; @@ -24,7 +20,6 @@ class Product { final double? makingCharges; final String? makingChargesType; final double? wastagePercentage; - final bool trackInventory; final bool isActive; final List imageIds; final double? currentStock; @@ -39,13 +34,9 @@ class Product { this.sku, this.barcode, this.description, - this.purchasePrice, this.sellingPrice, - this.minStock, - this.reorderLevel, this.gstRate, this.dimensions, - this.weight, this.color, this.size, this.priceCalcRule = 'MANUAL', @@ -54,7 +45,6 @@ class Product { this.makingCharges = 0.0, this.makingChargesType = 'FLAT', this.wastagePercentage = 0.0, - this.trackInventory = true, this.isActive = true, this.imageIds = const [], this.currentStock = 0.0, @@ -71,27 +61,36 @@ class Product { sku: json['sku'], barcode: json['barcode'], description: json['description'], - purchasePrice: (json['purchasePrice'] ?? json['purchase_price'] as num?)?.toDouble(), - sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?)?.toDouble(), - minStock: (json['minStock'] ?? json['min_stock'] as num?)?.toDouble(), - reorderLevel: (json['reorderLevel'] ?? json['reorder_level'] as num?)?.toDouble(), + sellingPrice: (json['sellingPrice'] ?? json['selling_price'] as num?) + ?.toDouble(), gstRate: (json['gstRate'] ?? json['gst_rate'] as num?)?.toDouble(), dimensions: json['dimensions'], - weight: (json['weight'] as num?)?.toDouble(), color: json['color'], size: json['size'], - priceCalcRule: json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', - autoCalculatePrice: json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, - purityFactor: (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? 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, - trackInventory: json['trackInventory'] ?? json['track_inventory'] ?? true, + priceCalcRule: + json['priceCalcRule'] ?? json['price_calc_rule'] ?? 'MANUAL', + autoCalculatePrice: + json['autoCalculatePrice'] ?? json['auto_calculate_price'] ?? false, + purityFactor: + (json['purityFactor'] ?? json['purity_factor'] as num?)?.toDouble() ?? + 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, - imageIds: json['images'] != null + imageIds: json['images'] != null ? (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, 'barcode': barcode, 'description': description, - 'purchasePrice': purchasePrice, 'sellingPrice': sellingPrice, - 'minStock': minStock, - 'reorderLevel': reorderLevel, 'gstRate': gstRate, 'dimensions': dimensions, - 'weight': weight, 'color': color, 'size': size, 'priceCalcRule': priceCalcRule, @@ -121,7 +116,6 @@ class Product { 'makingCharges': makingCharges, 'makingChargesType': makingChargesType, 'wastagePercentage': wastagePercentage, - 'trackInventory': trackInventory, 'isActive': isActive, }; } diff --git a/kifi-app/lib/features/inventory/domain/product_bom.dart b/kifi-app/lib/features/inventory/domain/product_bom.dart index caddac0..68344da 100644 --- a/kifi-app/lib/features/inventory/domain/product_bom.dart +++ b/kifi-app/lib/features/inventory/domain/product_bom.dart @@ -4,7 +4,7 @@ class ProductBom { final int componentProductId; final double quantity; final DateTime? createdAt; - + // Optional field to store the actual component product details fetched from the API final Map? componentProduct; @@ -23,7 +23,9 @@ class ProductBom { parentProductId: json['parentProductId'], componentProductId: json['componentProductId'], 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'], ); } diff --git a/kifi-app/lib/features/inventory/domain/stock_movement.dart b/kifi-app/lib/features/inventory/domain/stock_movement.dart index 09dd45b..7f1d693 100644 --- a/kifi-app/lib/features/inventory/domain/stock_movement.dart +++ b/kifi-app/lib/features/inventory/domain/stock_movement.dart @@ -5,12 +5,22 @@ class StockMovementItem { final double quantity; final double? unitPrice; + final String? huid; + final double? weight; + final String? photoUrl; + final double? purchaseAmount; + StockMovementItem({ this.id, this.movementId, this.productId, required this.quantity, this.unitPrice, + + this.huid, + this.weight, + this.photoUrl, + this.purchaseAmount, }); factory StockMovementItem.fromJson(Map json) { @@ -20,6 +30,11 @@ class StockMovementItem { productId: json['productId'], quantity: (json['quantity'] 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, 'quantity': quantity, 'unitPrice': unitPrice, + + 'huid': huid, + 'weight': weight, + 'photoUrl': photoUrl, + 'purchaseAmount': purchaseAmount, }; } } @@ -38,7 +58,8 @@ class StockMovement { final int? id; final int? userId; 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 String? notes; final DateTime? createdAt; @@ -63,9 +84,13 @@ class StockMovement { type: json['type'], referenceTransactionId: json['referenceTransactionId'], 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 - ? (json['items'] as List).map((i) => StockMovementItem.fromJson(i)).toList() + ? (json['items'] as List) + .map((i) => StockMovementItem.fromJson(i)) + .toList() : null, ); } diff --git a/kifi-app/lib/features/inventory/domain/uom.dart b/kifi-app/lib/features/inventory/domain/uom.dart deleted file mode 100644 index 848ac22..0000000 --- a/kifi-app/lib/features/inventory/domain/uom.dart +++ /dev/null @@ -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 json) { - return UnitOfMeasure( - id: json['id'], - name: json['name'], - abbreviation: json['abbreviation'], - ); - } - - Map 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, - ); - } -} diff --git a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart index a97078b..081bc4f 100644 --- a/kifi-app/lib/features/inventory/presentation/add_product_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/add_product_screen.dart @@ -3,15 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.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 '../../../core/network/dio_client.dart'; import '../../../../core/widgets/smart_search_dropdown.dart'; import '../domain/product.dart'; import '../providers/products_provider.dart'; import '../providers/product_categories_provider.dart'; -import '../providers/uoms_provider.dart'; -import '../domain/uom.dart'; import '../../business/providers/business_provider.dart'; import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; @@ -24,36 +21,18 @@ class AddProductScreen extends ConsumerStatefulWidget { } class _AddProductScreenState extends ConsumerState { - final PageController _pageController = PageController(); - int _currentPage = 0; - final int _totalPages = 4; final _formKey = GlobalKey(); + final _hsnController = TextEditingController(); + final _gstController = TextEditingController(); // Basic String _name = ''; String _sku = ''; - String _hsnCode = ''; ProductCategory? _selectedCategory; int? _uomId; // Properties 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 final List _images = []; @@ -72,21 +51,10 @@ class _AddProductScreenState extends ConsumerState { final p = widget.product!; _name = p.name; _sku = p.sku ?? ''; - _hsnCode = p.hsnCode ?? ''; + _hsnController.text = p.hsnCode ?? ''; _uomId = p.uomId; _color = p.color ?? ''; - _size = p.size ?? ''; - _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; + _gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; if (p.imageIds.isNotEmpty) { _existingImageIds.addAll(p.imageIds); @@ -103,24 +71,11 @@ class _AddProductScreenState extends ConsumerState { } } - void _nextPage() { - FocusScope.of(context).unfocus(); - if (_currentPage < _totalPages - 1) { - if (_currentPage == 0 && _selectedCategory == null) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category.'))); - 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); - } + @override + void dispose() { + _hsnController.dispose(); + _gstController.dispose(); + super.dispose(); } Future _pickImages() async { @@ -167,7 +122,6 @@ class _AddProductScreenState extends ConsumerState { ); } - Future _save() async { if (!_formKey.currentState!.validate()) return; _formKey.currentState!.save(); @@ -182,24 +136,14 @@ class _AddProductScreenState extends ConsumerState { final product = Product( id: widget.product?.id, name: _name, - hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null, + hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null, sku: _sku.isNotEmpty ? _sku : null, categoryId: _selectedCategory?.id, uomId: _uomId, color: _color.isNotEmpty ? _color : null, - size: _size.isNotEmpty ? _size : null, - dimensions: _dimensions.isNotEmpty ? _dimensions : null, - purchasePrice: _purchasePrice, - sellingPrice: _autoCalculatePrice ? _calculateLivePrice() : _sellingPrice, - gstRate: _gstRate, - weight: _weight, - priceCalcRule: _autoCalculatePrice ? 'COMMODITY' : 'MANUAL', - autoCalculatePrice: _autoCalculatePrice, - purityFactor: _purityFactor, - makingCharges: _makingCharges, - makingChargesType: _makingChargesType, - wastagePercentage: _wastagePercentage, - trackInventory: _trackInventory, + gstRate: double.tryParse(_gstController.text) ?? 0, + priceCalcRule: 'MANUAL', + autoCalculatePrice: false, ); if (widget.product != null) { @@ -215,12 +159,10 @@ class _AddProductScreenState extends ConsumerState { } } - double _calculateLivePrice() { - return _sellingPrice; - } - @override Widget build(BuildContext context) { + final categoriesState = ref.watch(productCategoriesProvider); + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, appBar: AppBar( @@ -232,23 +174,185 @@ class _AddProductScreenState extends ConsumerState { onTap: () => FocusScope.of(context).unfocus(), child: Column( children: [ - _buildProgressPills(), Expanded( child: Form( key: _formKey, - child: PageView( - controller: _pageController, - physics: const NeverScrollableScrollPhysics(), - onPageChanged: (idx) { - FocusScope.of(context).unfocus(); - setState(() => _currentPage = idx); - }, - children: [ - _buildBasicStep(), - _buildPropertiesStep(), - _buildPricingStep(), - _buildMediaStep(), - ], + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + + _buildPremiumTextField( + label: 'Product Name*', + initialValue: _name, + 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( + hintText: 'Select Category*', + value: _selectedCategory, + items: leafCategories, + itemAsString: (c) => c.name, + itemBuilder: (context, item) { + List 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( + 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 { ); } - 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() { return SafeArea( child: Padding( - padding: const EdgeInsets.all(24.0), - child: Row( - children: [ - if (_currentPage > 0) - Expanded( - flex: 1, - child: OutlinedButton( - onPressed: _isSaving ? null : _prevPage, - style: OutlinedButton.styleFrom( - 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) - ), - ), + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 4, ), - ], + 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( - hintText: 'Select Category*', - value: _selectedCategory, - items: leafCategories, - itemAsString: (c) => c.name, - itemBuilder: (context, item) { - // Build full path - List 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( - 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( - 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}) { return Stack( fit: StackFit.expand, @@ -737,33 +393,29 @@ class _AddProductScreenState extends ConsumerState { onTap: onTap, child: Container( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), 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( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), child: Container( - color: Colors.grey[200], - child: isNetwork - ? (_token == null - ? const Icon(LucideIcons.image, color: Colors.grey) - : Image.network( - url!, - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer $_token'}, - errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), - )) - : Image.file( - file!, - fit: BoxFit.cover, - ), + color: Colors.grey[200], + child: isNetwork + ? (_token == null + ? const Icon(LucideIcons.image, color: Colors.grey) + : Image.network( + url!, + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $_token'}, + errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), + )) + : Image.file(file!, fit: BoxFit.cover), + ), ), ), ), - ), - Positioned( - top: 8, + Positioned( + top: 8, right: 8, child: GestureDetector( onTap: onDelete, @@ -778,10 +430,8 @@ class _AddProductScreenState extends ConsumerState { ); } - // ==== WIDGET HELPERS ==== - Widget _buildPremiumTextField({ - required String label, + required String label, String? initialValue, String? prefixText, String? suffixText, @@ -789,25 +439,13 @@ class _AddProductScreenState extends ConsumerState { void Function(String)? onChanged, void Function(String?)? onSaved, String? Function(String?)? validator, - bool darkTheme = false, }) { return TextFormField( initialValue: initialValue, - style: TextStyle(color: darkTheme ? Colors.white : null), decoration: InputDecoration( labelText: label, - labelStyle: TextStyle(color: darkTheme ? Colors.white70 : Colors.grey[600]), prefixText: prefixText, - prefixStyle: TextStyle(color: darkTheme ? Colors.white : Colors.black, fontWeight: FontWeight.bold), 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, onChanged: onChanged, @@ -815,59 +453,4 @@ class _AddProductScreenState extends ConsumerState { validator: validator, ); } - - Widget _buildPremiumDropdown({ - required String label, - required T? value, - required List 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( - 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; - } - } } diff --git a/kifi-app/lib/features/inventory/presentation/bom_tab.dart b/kifi-app/lib/features/inventory/presentation/bom_tab.dart index 8a10be4..12d58c4 100644 --- a/kifi-app/lib/features/inventory/presentation/bom_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/bom_tab.dart @@ -27,13 +27,17 @@ class _BomTabState extends ConsumerState { Future _loadBom() async { setState(() => _isLoading = true); try { - final items = await ref.read(productsProvider.notifier).fetchProductBom(widget.productId); + final items = await ref + .read(productsProvider.notifier) + .fetchProductBom(widget.productId); setState(() { _bomItems = items; }); } catch (e) { 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 { if (mounted) setState(() => _isLoading = false); @@ -42,7 +46,9 @@ class _BomTabState extends ConsumerState { void _showAddComponentSheet() { 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; final qtyCtrl = TextEditingController(text: '1'); @@ -63,7 +69,10 @@ class _BomTabState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, 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), SmartSearchDropdown( hintText: 'Select Product', @@ -79,10 +88,11 @@ class _BomTabState extends ConsumerState { const SizedBox(height: 16), TextField( controller: qtyCtrl, - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), decoration: const InputDecoration( labelText: 'Quantity Required', - border: OutlineInputBorder(), ), ), const SizedBox(height: 24), @@ -92,20 +102,26 @@ class _BomTabState extends ConsumerState { onPressed: () async { if (selectedProduct == null) return; final qty = double.tryParse(qtyCtrl.text) ?? 1.0; - + final bomItem = ProductBom( parentProductId: widget.productId, componentProductId: selectedProduct!.id!, quantity: qty, ); - + 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); _loadBom(); } catch (e) { 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 { ], ), ); - } + }, ), ); } @@ -131,14 +147,19 @@ class _BomTabState extends ConsumerState { if (_isLoading) { return const Center(child: CircularProgressIndicator()); } - + final productsState = ref.watch(productsProvider); final allProducts = productsState.value ?? []; return Stack( children: [ 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 ListView.builder( padding: const EdgeInsets.all(16).copyWith(bottom: 80), @@ -146,25 +167,37 @@ class _BomTabState extends ConsumerState { itemBuilder: (context, index) { final item = _bomItems[index]; final component = allProducts.firstWhere( - (p) => p.id == item.componentProductId, - orElse: () => Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'), + (p) => p.id == item.componentProductId, + orElse: () => + Product(name: 'Unknown Product', priceCalcRule: 'MANUAL'), ); - + return Card( margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), 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}'), trailing: IconButton( icon: const Icon(Icons.delete, color: Colors.red), onPressed: () async { try { - await ref.read(productsProvider.notifier).deleteBomItem(item.id!); + await ref + .read(productsProvider.notifier) + .deleteBomItem(item.id!); _loadBom(); } catch (e) { 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'), + ), + ); } } }, @@ -173,7 +206,7 @@ class _BomTabState extends ConsumerState { ); }, ), - + Positioned( bottom: 90, right: 16, diff --git a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart index b453529..4045bc5 100644 --- a/kifi-app/lib/features/inventory/presentation/category_management_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/category_management_screen.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 { const CategoryManagementScreen({super.key}); @@ -13,93 +18,575 @@ class _CategoryManagementScreenState extends ConsumerState 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 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, + body: Column( children: [ - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: () => _showAddCategoryDialog(category), + Container( + color: Colors.white, + 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) { - final nameController = TextEditingController(); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(parent == null ? 'Add Root Category' : 'Add Subcategory to ${parent.name}'), - content: TextField( - controller: nameController, - decoration: const InputDecoration(labelText: 'Category Name'), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), + Widget _buildEmptyState() { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const SizedBox(height: 150), + Icon(LucideIcons.layers, size: 64, color: Colors.grey.withOpacity(0.5)), + const SizedBox(height: 16), + const Center(child: Text('No categories yet', style: TextStyle(fontSize: 18, color: Colors.grey))), + const SizedBox(height: 8), + const Center(child: Text('Create your first product category to get started', style: TextStyle(color: Colors.grey))), + ], + ); + } + + Widget _buildCategoryNode(ProductCategory category, List 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, - ); - ref.read(productCategoriesProvider.notifier).createCategory(newCategory); - Navigator.pop(context); - } - }, - child: const Text('Save'), + ), + ), + ); + } + + Widget _buildCategoryPopupMenu(ProductCategory category, bool isLeaf) { + return PopupMenuButton( + icon: const Icon(LucideIcons.moreVertical, color: Colors.grey), + onSelected: (value) { + if (value == 'edit') { + _showAddCategorySheet(context, category, ref, isEdit: true); + } 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 createState() => _CategoryFormSheetState(); +} + +class _CategoryFormSheetState extends ConsumerState { + final _formKey = GlobalKey(); + 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> _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( + 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( + 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( + 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( + 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, + ), + ); + } } diff --git a/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart index b335703..880f9a3 100644 --- a/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/daily_rates_screen.dart @@ -108,9 +108,9 @@ class _DailyRatesScreenState extends ConsumerState { final categoriesState = ref.watch(productCategoriesProvider); return Scaffold( - backgroundColor: Colors.grey[50], + backgroundColor: Colors.grey[100], 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, foregroundColor: Colors.black, elevation: 0, @@ -118,7 +118,7 @@ class _DailyRatesScreenState extends ConsumerState { ), body: categoriesState.when( 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) { return Center( @@ -230,7 +230,6 @@ class _DailyRatesScreenState extends ConsumerState { decoration: InputDecoration( labelText: 'Today\'s Rate (per ${category.baseUnit})', prefixText: '₹ ', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), ), diff --git a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart index 9d06b26..1af223c 100644 --- a/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart @@ -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/presentation/add_product_screen.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'; class ProductDetailScreen extends ConsumerWidget { @@ -17,17 +16,25 @@ class ProductDetailScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { // Watch products to get latest updates (like stock changes) 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( - length: 3, + length: 2, child: Scaffold( backgroundColor: Colors.grey[50], appBar: AppBar( elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, - title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)), + title: Text( + currentProduct.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), actions: [ IconButton( icon: const Icon(Icons.edit, color: Colors.black), @@ -35,7 +42,8 @@ class ProductDetailScreen extends ConsumerWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => AddProductScreen(product: currentProduct), + builder: (context) => + AddProductScreen(product: currentProduct), ), ); }, @@ -47,7 +55,6 @@ class ProductDetailScreen extends ConsumerWidget { indicatorColor: Colors.blue, tabs: [ Tab(text: "Overview"), - Tab(text: "BOM"), Tab(text: "Stock Ledger"), ], ), @@ -55,23 +62,9 @@ class ProductDetailScreen extends ConsumerWidget { body: TabBarView( children: [ _buildOverviewTab(context, currentProduct, ref), - BomTab(productId: currentProduct.id!), - StockLedgerTab(productId: currentProduct.id!), + StockLedgerTab(product: currentProduct), ], ), - 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; 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( '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content', fit: BoxFit.cover, 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), - - // Stock Summary Card - Card( - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: Padding( - 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 + // Product Details Card Card( elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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(), - _buildDetailRow("SKU", p.sku ?? "-"), - _buildDetailRow("Purchase Price", "₹${p.purchasePrice?.toStringAsFixed(2) ?? '0.00'}"), - _buildDetailRow("Selling Price", "₹${p.sellingPrice?.toStringAsFixed(2) ?? '0.00'}"), - _buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"), - _buildDetailRow("Reorder Level", "${p.reorderLevel ?? 0}"), + _buildDetailRow("SKU / Barcode", p.sku ?? "-"), + _buildDetailRow("HSN Code", p.hsnCode ?? "-"), + _buildDetailRow( + "GST Rate", + "${p.gstRate?.toStringAsFixed(1) ?? '0'}%", + ), ], ), ), diff --git a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart index 54ed323..28407f5 100644 --- a/kifi-app/lib/features/inventory/presentation/product_list_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/product_list_screen.dart @@ -43,7 +43,8 @@ class _ProductListScreenState extends ConsumerState { } void _onScroll() { - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { ref.read(productsProvider.notifier).fetchNextPage(); } } @@ -52,126 +53,208 @@ class _ProductListScreenState extends ConsumerState { Widget build(BuildContext context) { final productsState = ref.watch(productsProvider); final categoriesState = ref.watch(productCategoriesProvider); - + return Scaffold( + backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Products Catalog'), + title: const Text( + 'Products Catalog', + style: TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, + backgroundColor: Colors.white, + foregroundColor: Colors.black, + centerTitle: true, ), body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search products by name or SKU...', - 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(productsProvider.notifier).search(val); - }); - }, + children: [ + Container( + color: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search products by name or SKU...', + prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), + contentPadding: const EdgeInsets.symmetric(vertical: 14), ), + onChanged: (val) { + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(milliseconds: 500), () { + ref.read(productsProvider.notifier).search(val); + }); + }, ), - Expanded( - child: productsState.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('Error: $err')), - data: (products) { - if (products.isEmpty) { - return RefreshIndicator( - 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))) - ], - ), - ); - } - + ), + Expanded( + child: productsState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('Error: $err')), + data: (products) { + if (products.isEmpty) { return RefreshIndicator( - onRefresh: () => ref.read(productsProvider.notifier).refresh(), - child: ListView.builder( - controller: _scrollController, + onRefresh: () => + ref.read(productsProvider.notifier).refresh(), + child: ListView( physics: const AlwaysScrollableScrollPhysics(), - itemCount: products.length + 1, // +1 for loading indicator - padding: const EdgeInsets.symmetric(horizontal: 16), - itemBuilder: (context, index) { + children: const [ + SizedBox(height: 100), + 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) { return const SizedBox(height: 80); } 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( - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - elevation: 2, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => ProductDetailScreen(product: p))); - }, - child: Padding( - padding: const EdgeInsets.all(12.0), - 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), maxLines: 2, overflow: TextOverflow.ellipsis), - 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)), + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + ProductDetailScreen(product: p), + ), + ); + }, + 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) ...[ - const SizedBox(width: 8), - Text(p.sku!, style: const TextStyle(color: Colors.grey, fontSize: 12)), - ] - ], + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + 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 { ); }, ), - ) - ], - ), + ), + ], + ), floatingActionButton: FloatingActionButton( onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const AddProductScreen())); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AddProductScreen()), + ); }, backgroundColor: Theme.of(context).colorScheme.primary, child: const Icon(LucideIcons.plus, color: Colors.white), @@ -207,7 +293,8 @@ class _ProductListScreenState extends ConsumerState { ); } - 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( borderRadius: BorderRadius.circular(12), @@ -215,15 +302,17 @@ class _ProductListScreenState extends ConsumerState { width: 60, height: 60, color: Colors.grey[200], - child: _token == null - ? const Icon(LucideIcons.image, color: Colors.grey) - : Image.network( - imageUrl, - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer $_token'}, - errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey), - ), + child: _token == null + ? const Icon(LucideIcons.image, color: Colors.grey) + : Image.network( + imageUrl, + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $_token'}, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.imageOff, color: Colors.grey), + ), ), ); } + } diff --git a/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart b/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart index 28c18cc..e99ccef 100644 --- a/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart +++ b/kifi-app/lib/features/inventory/presentation/quick_adjust_stock_screen.dart @@ -11,10 +11,12 @@ class QuickAdjustStockScreen extends ConsumerStatefulWidget { const QuickAdjustStockScreen({super.key}); @override - ConsumerState createState() => _QuickAdjustStockScreenState(); + ConsumerState createState() => + _QuickAdjustStockScreenState(); } -class _QuickAdjustStockScreenState extends ConsumerState { +class _QuickAdjustStockScreenState + extends ConsumerState { final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); Timer? _debounce; @@ -28,7 +30,8 @@ class _QuickAdjustStockScreenState extends ConsumerState } void _onScroll() { - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { ref.read(productsProvider.notifier).fetchNextPage(); } } @@ -51,12 +54,9 @@ class _QuickAdjustStockScreenState extends ConsumerState @override Widget build(BuildContext context) { final productsState = ref.watch(productsProvider); - + return Scaffold( - appBar: AppBar( - title: const Text('Quick Adjust Stock'), - elevation: 0, - ), + appBar: AppBar(title: const Text('Quick Adjust Stock'), elevation: 0), body: Column( children: [ Padding( @@ -66,13 +66,10 @@ class _QuickAdjustStockScreenState extends ConsumerState decoration: InputDecoration( hintText: 'Search product to adjust...', prefixIcon: const Icon(LucideIcons.search), - filled: true, - fillColor: Colors.grey.shade100, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (val) { if (_debounce?.isActive ?? false) _debounce!.cancel(); @@ -89,7 +86,8 @@ class _QuickAdjustStockScreenState extends ConsumerState data: (products) { if (products.isEmpty) { return RefreshIndicator( - onRefresh: () => ref.read(productsProvider.notifier).refresh(), + onRefresh: () => + ref.read(productsProvider.notifier).refresh(), child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: const [ @@ -101,7 +99,8 @@ class _QuickAdjustStockScreenState extends ConsumerState } return RefreshIndicator( - onRefresh: () => ref.read(productsProvider.notifier).refresh(), + onRefresh: () => + ref.read(productsProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), @@ -112,43 +111,60 @@ class _QuickAdjustStockScreenState extends ConsumerState return const SizedBox(height: 80); } final p = products[index]; - return Card( - elevation: 0, - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - 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)), - ], + return Card( + elevation: 0, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade200), ), - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => AdjustStockSheet(productId: p.id!), - ); - }, - ), - ); - }, - ), - ); - }, + 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: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => + AdjustStockSheet(productId: p.id!), + ); + }, + ), + ); + }, + ), + ); + }, ), ), ], @@ -169,7 +185,8 @@ class _QuickAdjustStockScreenState extends ConsumerState ); } - 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( borderRadius: BorderRadius.circular(12), @@ -177,14 +194,15 @@ class _QuickAdjustStockScreenState extends ConsumerState width: 50, height: 50, color: Colors.grey[200], - child: _token == null - ? const Icon(LucideIcons.image, color: Colors.grey) - : Image.network( - imageUrl, - fit: BoxFit.cover, - headers: {'Authorization': 'Bearer $_token'}, - errorBuilder: (context, error, stackTrace) => const Icon(LucideIcons.imageOff, color: Colors.grey), - ), + child: _token == null + ? const Icon(LucideIcons.image, color: Colors.grey) + : Image.network( + imageUrl, + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $_token'}, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.imageOff, color: Colors.grey), + ), ), ); } diff --git a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart index 266dc5f..c093943 100644 --- a/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart +++ b/kifi-app/lib/features/inventory/presentation/stock_ledger_tab.dart @@ -1,20 +1,26 @@ import 'package:flutter/material.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/product_categories_provider.dart'; +import 'package:kifi_app/core/network/dio_client.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 { - final int productId; + final Product product; - const StockLedgerTab({super.key, required this.productId}); + const StockLedgerTab({super.key, required this.product}); @override ConsumerState createState() => _StockLedgerTabState(); } class _StockLedgerTabState extends ConsumerState { - List? _ledger; + List? _items; bool _isLoading = true; @override @@ -25,10 +31,12 @@ class _StockLedgerTabState extends ConsumerState { Future _fetchLedger() async { try { - final ledger = await ref.read(productsProvider.notifier).fetchStockLedger(widget.productId); + final items = await ref + .read(productsProvider.notifier) + .fetchInventoryItems(widget.product.id!); if (mounted) { setState(() { - _ledger = ledger; + _items = items; _isLoading = false; }); } @@ -37,7 +45,9 @@ class _StockLedgerTabState extends ConsumerState { setState(() { _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 { return const Center(child: CircularProgressIndicator()); } - if (_ledger == null || _ledger!.isEmpty) { - return const Center(child: Text("No stock movements recorded.")); + if (_items == null || _items!.isEmpty) { + 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( onRefresh: _fetchLedger, child: ListView.builder( padding: const EdgeInsets.all(16.0), - itemCount: _ledger!.length, + itemCount: _items!.length, itemBuilder: (context, index) { - final movement = _ledger![index]; - final isAddition = movement.type == 'ADDITION' || movement.type == 'OPENING'; - final qty = movement.items?.isNotEmpty == true ? movement.items!.first.quantity : 0.0; - + final item = _items![index]; + final weight = item.grossWeight ?? 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( elevation: 0, margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: isAddition ? Colors.green.withOpacity(0.1) : Colors.red.withOpacity(0.1), - shape: BoxShape.circle, - ), - 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, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${isAddition ? '+' : '-'}${qty.toStringAsFixed(1)}', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: isAddition ? Colors.green : Colors.red, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: FutureBuilder( + 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), - ), ], ), ), diff --git a/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart b/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart deleted file mode 100644 index a904e76..0000000 --- a/kifi-app/lib/features/inventory/presentation/uoms_list_screen.dart +++ /dev/null @@ -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 createState() => _UomsListScreenState(); -} - -class _UomsListScreenState extends ConsumerState { - 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), - ), - ); - } -} diff --git a/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart b/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart deleted file mode 100644 index 7dfe712..0000000 --- a/kifi-app/lib/features/inventory/presentation/widgets/add_uom_sheet.dart +++ /dev/null @@ -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 createState() => _AddUomSheetState(); -} - -class _AddUomSheetState extends ConsumerState { - final _formKey = GlobalKey(); - 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 _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)), - ), - ], - ), - ), - ); - } -} diff --git a/kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart b/kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart new file mode 100644 index 0000000..08d3689 --- /dev/null +++ b/kifi-app/lib/features/inventory/presentation/widgets/quick_rate_update_sheet.dart @@ -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 createState() => _QuickRateUpdateSheetState(); +} + +class _QuickRateUpdateSheetState extends ConsumerState { + String? _selectedCommodityCode; + final _rateController = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _rateController.dispose(); + super.dispose(); + } + + Future _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( + 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)), + ), + ], + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart index 0b26d3d..8cd64f7 100644 --- a/kifi-app/lib/features/inventory/providers/product_categories_provider.dart +++ b/kifi-app/lib/features/inventory/providers/product_categories_provider.dart @@ -8,15 +8,16 @@ class ProductCategory { final String name; final int? parentCategoryId; final bool hasChild; + final String? commodityCode; final String? defaultHsn; final double? defaultGst; final bool huidRequired; final double? defaultMakingCharge; final String? makingChargeType; - final String calculationMethod; final String baseUnit; final bool isActive; final int sortOrder; + final double? dailyRate; ProductCategory({ this.id, @@ -24,15 +25,16 @@ class ProductCategory { required this.name, this.parentCategoryId, this.hasChild = false, + this.commodityCode, this.defaultHsn, this.defaultGst, this.huidRequired = false, this.defaultMakingCharge, this.makingChargeType, - this.calculationMethod = 'UNIT', this.baseUnit = 'pcs', this.isActive = true, this.sortOrder = 0, + this.dailyRate, }); factory ProductCategory.fromJson(Map json) { @@ -42,15 +44,16 @@ class ProductCategory { name: json['name'], parentCategoryId: json['parentCategoryId'], hasChild: json['hasChild'] ?? false, + commodityCode: json['commodityCode'], defaultHsn: json['defaultHsn'], defaultGst: (json['defaultGst'] as num?)?.toDouble(), huidRequired: json['huidRequired'] ?? false, defaultMakingCharge: (json['defaultMakingCharge'] as num?)?.toDouble(), makingChargeType: json['makingChargeType'], - calculationMethod: json['calculationMethod'] ?? 'UNIT', baseUnit: json['baseUnit'] ?? 'pcs', isActive: json['isActive'] ?? true, sortOrder: json['sortOrder'] ?? 0, + dailyRate: (json['dailyRate'] as num?)?.toDouble(), ); } @@ -61,12 +64,12 @@ class ProductCategory { 'name': name, 'parentCategoryId': parentCategoryId, 'hasChild': hasChild, + 'commodityCode': commodityCode, 'defaultHsn': defaultHsn, 'defaultGst': defaultGst, 'huidRequired': huidRequired, 'defaultMakingCharge': defaultMakingCharge, 'makingChargeType': makingChargeType, - 'calculationMethod': calculationMethod, 'baseUnit': baseUnit, 'isActive': isActive, 'sortOrder': sortOrder, @@ -101,9 +104,71 @@ class ProductCategoriesNotifier extends AsyncNotifier> { } } + Future 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 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> fetchRateHistory(int categoryId) async { + final response = await DioClient().dio.get( + '/inventory/categories/$categoryId/rates/history', + ); + return response.data as List; + } + + Future updateCategoryRate(int categoryId, double rate) async { + await DioClient().dio.post( + '/inventory/categories/$categoryId/rates', + data: {'rate': rate}, + ); + state = await AsyncValue.guard(() => _fetchCategories()); + } + + Future 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 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>(() { - return ProductCategoriesNotifier(); -}); +final productCategoriesProvider = + AsyncNotifierProvider>(() { + return ProductCategoriesNotifier(); + }); diff --git a/kifi-app/lib/features/inventory/providers/products_provider.dart b/kifi-app/lib/features/inventory/providers/products_provider.dart index 48ae485..5bc80eb 100644 --- a/kifi-app/lib/features/inventory/providers/products_provider.dart +++ b/kifi-app/lib/features/inventory/providers/products_provider.dart @@ -6,6 +6,7 @@ import '../../../core/network/dio_client.dart'; import '../domain/product.dart'; import '../domain/stock_movement.dart'; import '../domain/product_bom.dart'; +import '../domain/inventory_item.dart'; import 'package:dio/dio.dart'; import 'package:image_picker/image_picker.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; @@ -24,20 +25,25 @@ class ProductsNotifier extends AsyncNotifier> { return _fetchProducts(page: _currentPage, size: _pageSize); } - Future> _fetchProducts({required int page, required int size}) async { + Future> _fetchProducts({ + required int page, + required int size, + }) async { final response = await DioClient().dio.get( '/inventory/products', queryParameters: { - 'page': page, + 'page': page, 'size': size, if (_currentSearchQuery.isNotEmpty) 'search': _currentSearchQuery, - } + }, ); if (response.statusCode == 200) { final List data = response.data; if (data.isNotEmpty) { 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)); } catch (_) {} } @@ -58,7 +64,7 @@ class ProductsNotifier extends AsyncNotifier> { final currentList = state.value ?? []; final nextPage = _currentPage + 1; final newProducts = await _fetchProducts(page: nextPage, size: _pageSize); - + _currentPage = nextPage; state = AsyncValue.data([...currentList, ...newProducts]); } catch (e, stack) { @@ -73,7 +79,9 @@ class ProductsNotifier extends AsyncNotifier> { _currentPage = 0; _hasMore = true; try { - state = AsyncValue.data(await _fetchProducts(page: _currentPage, size: _pageSize)); + state = AsyncValue.data( + await _fetchProducts(page: _currentPage, size: _pageSize), + ); } catch (e, stack) { state = AsyncValue.error(e, stack); } @@ -90,7 +98,7 @@ class ProductsNotifier extends AsyncNotifier> { '/inventory/products', data: product.toJson(), ); - + if (images != null && images.isNotEmpty && response.data != null) { final productId = response.data['id']; for (var image in images) { @@ -100,9 +108,12 @@ class ProductsNotifier extends AsyncNotifier> { minWidth: 800, quality: 80, ); - + final formData = FormData.fromMap({ - 'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), + 'file': MultipartFile.fromBytes( + compressedBytes, + filename: image.name, + ), }); await DioClient().dio.post( '/inventory/products/$productId/images', @@ -118,13 +129,17 @@ class ProductsNotifier extends AsyncNotifier> { } } - Future updateProduct(int id, Product product, {List? newImages}) async { + Future updateProduct( + int id, + Product product, { + List? newImages, + }) async { try { await DioClient().dio.put( '/inventory/products/$id', data: product.toJson(), ); - + if (newImages != null && newImages.isNotEmpty) { for (var image in newImages) { final bytes = await image.readAsBytes(); @@ -135,7 +150,10 @@ class ProductsNotifier extends AsyncNotifier> { ); final formData = FormData.fromMap({ - 'file': MultipartFile.fromBytes(compressedBytes, filename: image.name), + 'file': MultipartFile.fromBytes( + compressedBytes, + filename: image.name, + ), }); await DioClient().dio.post( '/inventory/products/$id/images', @@ -166,7 +184,9 @@ class ProductsNotifier extends AsyncNotifier> { Future> fetchStockLedger(int productId) async { 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) { final List data = response.data; return data.map((e) => StockMovement.fromJson(e)).toList(); @@ -177,9 +197,26 @@ class ProductsNotifier extends AsyncNotifier> { } } + Future> fetchInventoryItems(int productId) async { + try { + final response = await DioClient().dio.get( + '/inventory/items/product/$productId', + ); + if (response.data != null) { + final List data = response.data; + return data.map((e) => InventoryItem.fromJson(e)).toList(); + } + return []; + } catch (e) { + throw Exception('Failed to fetch inventory items: $e'); + } + } + Future> fetchProductBom(int productId) async { 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) { final List data = response.data; return data.map((e) => ProductBom.fromJson(e)).toList(); @@ -210,6 +247,8 @@ class ProductsNotifier extends AsyncNotifier> { } } -final productsProvider = AsyncNotifierProvider>(() { - return ProductsNotifier(); -}); +final productsProvider = AsyncNotifierProvider>( + () { + return ProductsNotifier(); + }, +); diff --git a/kifi-app/lib/features/inventory/providers/uoms_provider.dart b/kifi-app/lib/features/inventory/providers/uoms_provider.dart deleted file mode 100644 index 57e6551..0000000 --- a/kifi-app/lib/features/inventory/providers/uoms_provider.dart +++ /dev/null @@ -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 _allUoms = []; - - @override - Future> build() async { - return _fetchUoms(); - } - - Future> _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 fetchUoms() async { - try { - final data = await _fetchUoms(); - state = AsyncValue.data(data); - } catch (e, stack) { - state = AsyncValue.error(e, stack); - } - } - - Future 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 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 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>(() { - return UomsNotifier(); -}); diff --git a/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart b/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart index 9828a5c..bfbf6af 100644 --- a/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/kifi-app/lib/features/onboarding/presentation/onboarding_screen.dart @@ -17,22 +17,26 @@ class _OnboardingScreenState extends State { final List> _pages = [ { '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, }, { '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, }, { '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, }, { '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, }, ]; @@ -76,16 +80,21 @@ class _OnboardingScreenState extends State { const SizedBox(height: 60), Text( page['title'], - style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 28), + style: Theme.of( + context, + ).textTheme.displayLarge?.copyWith(fontSize: 28), textAlign: TextAlign.center, ), const SizedBox(height: 24), Text( page['description'], - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - height: 1.5, - ), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.7), + height: 1.5, + ), textAlign: TextAlign.center, ), ], @@ -109,7 +118,9 @@ class _OnboardingScreenState extends State { decoration: BoxDecoration( color: _currentPage == index ? 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), ), ), @@ -126,7 +137,11 @@ class _OnboardingScreenState extends State { ); } }, - child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'), + child: Text( + _currentPage == _pages.length - 1 + ? 'Get Started' + : 'Next', + ), ), ], ), diff --git a/kifi-app/lib/features/projects/data/project_repository.dart b/kifi-app/lib/features/projects/data/project_repository.dart index b42abb5..880de4a 100644 --- a/kifi-app/lib/features/projects/data/project_repository.dart +++ b/kifi-app/lib/features/projects/data/project_repository.dart @@ -16,7 +16,9 @@ class ProjectRepository { return (response.data as List).map((p) => Project.fromJson(p)).toList(); } on DioException catch (e) { 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'); } @@ -28,12 +30,15 @@ class ProjectRepository { int? customerId, double? budget, }) async { - final response = await _dio.post('/projects', data: { - 'name': name, - if (description != null) 'description': description, - if (customerId != null) 'customerId': customerId, - if (budget != null) 'budget': budget, - }); + final response = await _dio.post( + '/projects', + data: { + 'name': name, + if (description != null) 'description': description, + if (customerId != null) 'customerId': customerId, + if (budget != null) 'budget': budget, + }, + ); return Project.fromJson(response.data); } @@ -45,13 +50,16 @@ class ProjectRepository { double? budget, String? status, }) async { - final response = await _dio.put('/projects/$projectId', data: { - if (name != null) 'name': name, - if (description != null) 'description': description, - if (customerId != null) 'customerId': customerId, - if (budget != null) 'budget': budget, - if (status != null) 'status': status, - }); + final response = await _dio.put( + '/projects/$projectId', + data: { + if (name != null) 'name': name, + if (description != null) 'description': description, + if (customerId != null) 'customerId': customerId, + if (budget != null) 'budget': budget, + if (status != null) 'status': status, + }, + ); return Project.fromJson(response.data); } @@ -73,14 +81,17 @@ class ProjectRepository { bool? isBillable, double? hourlyRate, }) async { - final response = await _dio.post('/projects/$projectId/tasks', data: { - 'title': title, - if (description != null) 'description': description, - if (assigneeName != null) 'assigneeName': assigneeName, - if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, - if (isBillable != null) 'isBillable': isBillable, - if (hourlyRate != null) 'hourlyRate': hourlyRate, - }); + final response = await _dio.post( + '/projects/$projectId/tasks', + data: { + 'title': title, + if (description != null) 'description': description, + if (assigneeName != null) 'assigneeName': assigneeName, + if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, + if (isBillable != null) 'isBillable': isBillable, + if (hourlyRate != null) 'hourlyRate': hourlyRate, + }, + ); return ProjectTask.fromJson(response.data); } @@ -95,25 +106,31 @@ class ProjectRepository { double? hourlyRate, double? hoursLogged, }) async { - final response = await _dio.put('/projects/tasks/$taskId', data: { - if (title != null) 'title': title, - if (description != null) 'description': description, - if (assigneeName != null) 'assigneeName': assigneeName, - if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, - if (status != null) 'status': status, - if (isBillable != null) 'isBillable': isBillable, - if (hourlyRate != null) 'hourlyRate': hourlyRate, - if (hoursLogged != null) 'hoursLogged': hoursLogged, - }); + final response = await _dio.put( + '/projects/tasks/$taskId', + data: { + if (title != null) 'title': title, + if (description != null) 'description': description, + if (assigneeName != null) 'assigneeName': assigneeName, + if (assigneeUserId != null) 'assigneeUserId': assigneeUserId, + if (status != null) 'status': status, + if (isBillable != null) 'isBillable': isBillable, + if (hourlyRate != null) 'hourlyRate': hourlyRate, + if (hoursLogged != null) 'hoursLogged': hoursLogged, + }, + ); return ProjectTask.fromJson(response.data); } Future deleteTask(int taskId) async { await _dio.delete('/projects/tasks/$taskId'); } -Future> getComments(int taskId) async { + + Future> getComments(int taskId) async { 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 addComment({ @@ -121,11 +138,14 @@ Future> getComments(int taskId) async { required String content, List>? attachments, }) async { - final response = await _dio.post('/projects/tasks/$taskId/comments', data: { - 'content': content, - if (attachments != null && attachments.isNotEmpty) - 'imagesJson': jsonEncode(attachments), - }); + final response = await _dio.post( + '/projects/tasks/$taskId/comments', + data: { + 'content': content, + if (attachments != null && attachments.isNotEmpty) + 'imagesJson': jsonEncode(attachments), + }, + ); return ProjectTaskComment.fromJson(response.data); } } diff --git a/kifi-app/lib/features/projects/domain/project.dart b/kifi-app/lib/features/projects/domain/project.dart index ca8e553..2f0b09c 100644 --- a/kifi-app/lib/features/projects/domain/project.dart +++ b/kifi-app/lib/features/projects/domain/project.dart @@ -28,7 +28,9 @@ class Project { userId: json['userId'] as int?, budget: (json['budget'] as num?)?.toDouble() ?? 0.0, status: json['status'] as String? ?? 'ACTIVE', - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : null, ); } diff --git a/kifi-app/lib/features/projects/domain/project_task.dart b/kifi-app/lib/features/projects/domain/project_task.dart index 70c3bd0..5773f93 100644 --- a/kifi-app/lib/features/projects/domain/project_task.dart +++ b/kifi-app/lib/features/projects/domain/project_task.dart @@ -37,7 +37,9 @@ class ProjectTask { isBillable: json['isBillable'] as bool? ?? false, hourlyRate: (json['hourlyRate'] 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, ); } diff --git a/kifi-app/lib/features/projects/presentation/project_board_screen.dart b/kifi-app/lib/features/projects/presentation/project_board_screen.dart index 8767c8f..caf0f81 100644 --- a/kifi-app/lib/features/projects/presentation/project_board_screen.dart +++ b/kifi-app/lib/features/projects/presentation/project_board_screen.dart @@ -26,7 +26,10 @@ class _ProjectBoardScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.grey[100], 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, foregroundColor: Colors.black, elevation: 0, @@ -49,7 +52,12 @@ class _ProjectBoardScreenState extends ConsumerState { data: (tasks) { final columns = [ _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('Done', 'DONE', Colors.green, LucideIcons.checkCircle), ]; @@ -60,8 +68,14 @@ class _ProjectBoardScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: columns.map((col) { - final columnTasks = tasks.where((t) => t.status == col.status).toList(); - return _buildColumn(col, columnTasks, showAdd: col.status == 'TODO'); + final columnTasks = tasks + .where((t) => t.status == col.status) + .toList(); + return _buildColumn( + col, + columnTasks, + showAdd: col.status == 'TODO', + ); }).toList(), ), ); @@ -90,7 +104,9 @@ class _ProjectBoardScreenState extends ConsumerState { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( 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( children: [ @@ -106,14 +122,21 @@ class _ProjectBoardScreenState extends ConsumerState { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), decoration: BoxDecoration( color: col.color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Text( '${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 { ), // Task cards - ...tasks.map((t) => TaskCard( - task: t, - onEdit: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => AddTaskSheet( - projectId: widget.project.id, - task: t, - ), - ); - }, - onGenerateInvoice: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => InvoiceBuilderScreen( - initialCustomerId: widget.project.customerId, - initialItems: [ - InvoiceItem( - id: 0, - invoiceId: 0, - productId: null, - description: t.title, - quantity: t.hoursLogged, - unitPrice: t.hourlyRate, - total: t.hoursLogged * t.hourlyRate, - ) - ], + ...tasks.map( + (t) => TaskCard( + task: t, + onEdit: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => + AddTaskSheet(projectId: widget.project.id, task: t), + ); + }, + onGenerateInvoice: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceBuilderScreen( + initialCustomerId: widget.project.customerId, + initialItems: [ + InvoiceItem( + id: 0, + invoiceId: 0, + productId: null, + description: t.title, + quantity: t.hoursLogged, + unitPrice: t.hourlyRate, + total: t.hoursLogged * t.hourlyRate, + ), + ], + ), ), - ), - ); - }, - )), + ); + }, + ), + ), if (showAdd) Padding( @@ -166,17 +189,23 @@ class _ProjectBoardScreenState extends ConsumerState { style: OutlinedButton.styleFrom( foregroundColor: col.color, 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), ), 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: () { showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, - builder: (_) => AddTaskSheet(projectId: widget.project.id), + builder: (_) => + AddTaskSheet(projectId: widget.project.id), ); }, ), diff --git a/kifi-app/lib/features/projects/presentation/project_hub_screen.dart b/kifi-app/lib/features/projects/presentation/project_hub_screen.dart index b4d3f8e..0a496ae 100644 --- a/kifi-app/lib/features/projects/presentation/project_hub_screen.dart +++ b/kifi-app/lib/features/projects/presentation/project_hub_screen.dart @@ -31,20 +31,37 @@ class ProjectHubScreen extends ConsumerWidget { child: Container( padding: const EdgeInsets.all(16), 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), - 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( 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), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('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)), + Text( + '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), + ), ], ), ), @@ -62,36 +79,50 @@ class ProjectHubScreen extends ConsumerWidget { childAspectRatio: 1.2, children: [ _buildActionCard( - context, - 'Projects & Tasks', - 'Manage all projects', - LucideIcons.folderKanban, + context, + 'Projects & Tasks', + 'Manage all projects', + LucideIcons.folderKanban, Colors.blue, - () => Navigator.push(context, MaterialPageRoute(builder: (_) => const ProjectsScreen())), + () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProjectsScreen()), + ), ), _buildActionCard( - context, - 'Invoices', - 'Client billing', - LucideIcons.receipt, + context, + 'Invoices', + 'Client billing', + LucideIcons.receipt, Colors.green, - () => Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoicesListScreen())), + () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const InvoicesListScreen()), + ), ), _buildActionCard( - context, - 'Clients', - 'Manage clients', - LucideIcons.users, + context, + 'Clients', + 'Manage clients', + LucideIcons.users, NatureColors.getColor('RECEIVABLES'), - () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomersListScreen())), + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const CustomersListScreen(), + ), + ), ), _buildActionCard( - context, - 'Contractors', - 'Manage vendors', - LucideIcons.truck, + context, + 'Contractors', + 'Manage vendors', + LucideIcons.truck, 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( onTap: onTap, child: Container( @@ -116,9 +154,22 @@ class ProjectHubScreen extends ConsumerWidget { children: [ Icon(icon, color: color, size: 28), 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), - Text(subtitle, style: TextStyle(color: color.withValues(alpha: 0.7), fontSize: 12)), + Text( + subtitle, + style: TextStyle( + color: color.withValues(alpha: 0.7), + fontSize: 12, + ), + ), ], ), ), diff --git a/kifi-app/lib/features/projects/presentation/projects_screen.dart b/kifi-app/lib/features/projects/presentation/projects_screen.dart index badd2b6..d7ca35d 100644 --- a/kifi-app/lib/features/projects/presentation/projects_screen.dart +++ b/kifi-app/lib/features/projects/presentation/projects_screen.dart @@ -22,7 +22,10 @@ class _ProjectsScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Projects', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text( + 'Projects', + style: TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, @@ -37,12 +40,6 @@ class _ProjectsScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Search projects...', 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), ), onChanged: (val) { @@ -53,15 +50,28 @@ class _ProjectsScreenState extends ConsumerState { Expanded( child: projectsAsync.when( 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) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.folderKanban, size: 48, color: Colors.grey.shade400), + Icon( + LucideIcons.folderKanban, + size: 48, + color: Colors.grey.shade400, + ), 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 { margin: const EdgeInsets.only(bottom: 12), elevation: 0, color: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 16, 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)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + 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( mainAxisSize: MainAxisSize.min, children: [ IconButton( - icon: Icon(LucideIcons.edit, size: 18, color: Colors.grey.shade600), + icon: Icon( + LucideIcons.edit, + size: 18, + color: Colors.grey.shade600, + ), onPressed: () { showModalBottomSheet( context: context, isScrollControlled: true, 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: () { Navigator.push( context, MaterialPageRoute( - builder: (context) => ProjectBoardScreen(project: p), + builder: (context) => + ProjectBoardScreen(project: p), ), ); }, diff --git a/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart index 2eff590..fa1648c 100644 --- a/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart +++ b/kifi-app/lib/features/projects/presentation/widgets/add_task_sheet.dart @@ -56,131 +56,185 @@ class _AddTaskSheetState extends ConsumerState { ), child: SafeArea( child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(_isEditing ? 'Edit Task' : 'New Task', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)), - ], - ), - const SizedBox(height: 16), - 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( - initialValue: _isEditing && _assigneeName.isNotEmpty - ? TextEditingValue(text: _assigneeName) - : null, - optionsBuilder: (TextEditingValue textEditingValue) async { - if (textEditingValue.text.isEmpty) { - return const Iterable.empty(); - } - try { - return await ref.read(userSearchProvider(textEditingValue.text).future); - } catch (e) { - return const Iterable.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), - ); - }, - ), + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _isEditing ? 'Edit Task' : 'New Task', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, ), ), - ); - }, - 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) ...[ + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), + ], + ), 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, + 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( + initialValue: _isEditing && _assigneeName.isNotEmpty + ? TextEditingValue(text: _assigneeName) + : null, + optionsBuilder: (TextEditingValue textEditingValue) async { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + try { + return await ref.read( + userSearchProvider(textEditingValue.text).future, + ); + } catch (e) { + return const Iterable.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 { if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); diff --git a/kifi-app/lib/features/projects/presentation/widgets/task_card.dart b/kifi-app/lib/features/projects/presentation/widgets/task_card.dart index edca9a0..ad2a934 100644 --- a/kifi-app/lib/features/projects/presentation/widgets/task_card.dart +++ b/kifi-app/lib/features/projects/presentation/widgets/task_card.dart @@ -85,7 +85,10 @@ class TaskCard extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), decoration: BoxDecoration( color: statusColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(20), @@ -121,7 +124,11 @@ class TaskCard extends ConsumerWidget { const SizedBox(height: 4), Text( 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, overflow: TextOverflow.ellipsis, ), @@ -132,9 +139,13 @@ class TaskCard extends ConsumerWidget { // Bottom row: assignee + billable badge + invoice button Row( children: [ - if (task.assigneeName != null && task.assigneeName!.isNotEmpty) ...[ + if (task.assigneeName != null && + task.assigneeName!.isNotEmpty) ...[ Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: Colors.indigo.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), @@ -142,11 +153,19 @@ class TaskCard extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.user, size: 12, color: Colors.indigo.shade400), + Icon( + LucideIcons.user, + size: 12, + color: Colors.indigo.shade400, + ), const SizedBox(width: 4), Text( 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) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: Colors.green.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16), @@ -163,11 +185,19 @@ class TaskCard extends ConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.indianRupee, size: 11, color: Colors.green.shade600), + Icon( + LucideIcons.indianRupee, + size: 11, + color: Colors.green.shade600, + ), const SizedBox(width: 2), Text( '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( padding: const EdgeInsets.symmetric(horizontal: 8), 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, ), ), @@ -219,7 +262,13 @@ class TaskCard extends ConsumerWidget { ), ), 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), ListTile( leading: const Icon(LucideIcons.eye), @@ -245,30 +294,45 @@ class TaskCard extends ConsumerWidget { ), ListTile( 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 { Navigator.pop(ctx); final confirm = await showDialog( context: context, builder: (c) => AlertDialog( 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: [ - TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Cancel'), + ), TextButton( 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) { try { - await ref.read(projectRepositoryProvider).deleteTask(task.id); + await ref + .read(projectRepositoryProvider) + .deleteTask(task.id); ref.invalidate(projectTasksProvider(task.projectId)); } catch (e) { 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', padding: EdgeInsets.zero, 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 { if (newStatus != task.status) { try { - await ref.read(projectRepositoryProvider).updateTask( - taskId: task.id, - status: newStatus, - ); + await ref + .read(projectRepositoryProvider) + .updateTask(taskId: task.id, status: newStatus); ref.invalidate(projectTasksProvider(task.projectId)); } catch (e) { 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 _buildStatusMenuItem(String value, String label, Color color) { + PopupMenuItem _buildStatusMenuItem( + String value, + String label, + Color color, + ) { return PopupMenuItem( value: value, child: Row( diff --git a/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart index 87cceab..c0994f3 100644 --- a/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart +++ b/kifi-app/lib/features/projects/presentation/widgets/task_details_sheet.dart @@ -63,10 +63,13 @@ class _TaskDetailsSheetState extends ConsumerState { final bytes = await f.readAsBytes(); final extension = file.path!.split('.').last.toLowerCase(); String contentType = 'application/octet-stream'; - if (['pdf'].contains(extension)) contentType = 'application/pdf'; - else if (['doc', 'docx'].contains(extension)) contentType = 'application/msword'; - else if (['png', 'jpg', 'jpeg'].contains(extension)) contentType = 'image/jpeg'; - + if (['pdf'].contains(extension)) + contentType = 'application/pdf'; + else if (['doc', 'docx'].contains(extension)) + contentType = 'application/msword'; + else if (['png', 'jpg', 'jpeg'].contains(extension)) + contentType = 'image/jpeg'; + setState(() { _attachments.add({ 'fileName': file.name, @@ -98,22 +101,28 @@ class _TaskDetailsSheetState extends ConsumerState { ref.invalidate(projectTaskCommentsProvider(widget.task.id)); } catch (e) { 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 { if (mounted) setState(() => _isSubmitting = false); } } - Future _downloadAttachment(Map attMap, int commentId, int attIndex) async { + Future _downloadAttachment( + Map attMap, + int commentId, + int attIndex, + ) async { final filePath = attMap['filePath']; final fileName = attMap['fileName'] ?? 'attachment'; final contentType = attMap['contentType'] ?? 'application/octet-stream'; if (filePath == null) return; - + final uniqueId = commentId * 1000 + attIndex; setState(() => _downloadingIds.add(uniqueId)); - + try { final dir = await getTemporaryDirectory(); final savePath = '${dir.path}/$fileName'; @@ -121,16 +130,21 @@ class _TaskDetailsSheetState extends ConsumerState { if (_jwtToken != null) { 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); - + 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)]); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error downloading: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error downloading: $e'))); } } finally { if (mounted) { @@ -141,7 +155,9 @@ class _TaskDetailsSheetState extends ConsumerState { @override Widget build(BuildContext context) { - final commentsAsync = ref.watch(projectTaskCommentsProvider(widget.task.id)); + final commentsAsync = ref.watch( + projectTaskCommentsProvider(widget.task.id), + ); return Container( height: MediaQuery.of(context).size.height * 0.9, @@ -155,7 +171,8 @@ class _TaskDetailsSheetState extends ConsumerState { Expanded( child: commentsAsync.when( 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( padding: const EdgeInsets.all(16), itemCount: comments.length, @@ -170,7 +187,10 @@ class _TaskDetailsSheetState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(c.content, style: const TextStyle(fontSize: 14)), + Text( + c.content, + style: const TextStyle(fontSize: 14), + ), if (c.images.isNotEmpty) ...[ const SizedBox(height: 8), Wrap( @@ -178,7 +198,7 @@ class _TaskDetailsSheetState extends ConsumerState { children: c.images.asMap().entries.map((entry) { final imgIndex = entry.key; final imgData = entry.value; - + bool isOldBase64 = !imgData.startsWith('{'); Map? parsedData; if (!isOldBase64) { @@ -189,60 +209,111 @@ class _TaskDetailsSheetState extends ConsumerState { } } - final isImage = isOldBase64 || (parsedData?['contentType']?.startsWith('image/') ?? false); - + final isImage = + isOldBase64 || + (parsedData?['contentType']?.startsWith( + 'image/', + ) ?? + false); + if (isImage) { ImageProvider provider; - if (isOldBase64 || parsedData?['base64Content'] != null) { - final b64 = isOldBase64 ? imgData : parsedData!['base64Content']; - provider = MemoryImage(base64Decode(b64)); + if (isOldBase64 || + parsedData?['base64Content'] != null) { + final b64 = isOldBase64 + ? imgData + : parsedData!['base64Content']; + provider = MemoryImage(base64Decode(b64)); } else { - final url = 'https://app.technobeesolutions.in/api/kifi/projects/tasks/attachments/download?filePath=${parsedData!['filePath']}&contentType=${parsedData['contentType']}'; - provider = _jwtToken != null - ? NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'}) - : const AssetImage('assets/images/placeholder.png') as ImageProvider; + final url = + 'https://app.technobeesolutions.in/api/kifi-v2/projects/tasks/attachments/download?filePath=${parsedData!['filePath']}&contentType=${parsedData['contentType']}'; + provider = _jwtToken != null + ? NetworkImage( + url, + headers: { + 'Authorization': + 'Bearer $_jwtToken', + }, + ) + : const AssetImage( + 'assets/images/placeholder.png', + ) + as ImageProvider; } return GestureDetector( onTap: () { // Collect all images for the gallery - final allImages = c.images.where((i) { - if (!i.startsWith('{')) return true; - try { - final map = jsonDecode(i); - return map['contentType']?.startsWith('image/') ?? false; - } catch (_) { + final allImages = c.images + .where((i) { + if (!i.startsWith('{')) 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/projects/tasks/attachments/download?filePath=${map['filePath']}&contentType=${map['contentType']}'; - return NetworkImage(url, headers: {'Authorization': 'Bearer $_jwtToken'}) as ImageProvider; - }).toList(); - + try { + final map = jsonDecode(i); + return map['contentType'] + ?.startsWith( + 'image/', + ) ?? + false; + } 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 int realIndex = 0; - for (int i=0; i<=imgIndex; i++) { - final ii = c.images[i]; - bool img = !ii.startsWith('{'); - if (!img) { - try { - final m = jsonDecode(ii); - img = m['contentType']?.startsWith('image/') ?? false; - } catch(_) { img = true; } + for (int i = 0; i <= imgIndex; i++) { + final ii = c.images[i]; + bool img = !ii.startsWith('{'); + if (!img) { + try { + final m = jsonDecode(ii); + img = + m['contentType']?.startsWith( + 'image/', + ) ?? + false; + } catch (_) { + img = true; } - if (img && i < imgIndex) realIndex++; + } + if (img && i < imgIndex) realIndex++; } Navigator.push( context, MaterialPageRoute( - builder: (context) => AttachmentGalleryScreen( - images: allImages, - initialIndex: realIndex, - ), + builder: (context) => + AttachmentGalleryScreen( + images: allImages, + initialIndex: realIndex, + ), ), ); }, @@ -258,14 +329,21 @@ class _TaskDetailsSheetState extends ConsumerState { ); } else { // Non-image file - final fileName = parsedData?['fileName'] ?? 'Document'; + final fileName = + parsedData?['fileName'] ?? 'Document'; final uniqueId = c.id * 1000 + imgIndex; - final isDownloading = _downloadingIds.contains(uniqueId); - + final isDownloading = _downloadingIds + .contains(uniqueId); + return GestureDetector( onTap: () { - if (!isDownloading && parsedData != null) { - _downloadAttachment(parsedData, c.id, imgIndex); + if (!isDownloading && + parsedData != null) { + _downloadAttachment( + parsedData, + c.id, + imgIndex, + ); } }, child: Container( @@ -273,21 +351,41 @@ class _TaskDetailsSheetState extends ConsumerState { height: 80, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular( + 8, + ), + border: Border.all( + color: Colors.grey.shade300, + ), ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: + MainAxisAlignment.center, children: [ isDownloading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(LucideIcons.fileText, color: Colors.blue), + ? const SizedBox( + width: 24, + height: 24, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon( + LucideIcons.fileText, + color: Colors.blue, + ), const SizedBox(height: 4), Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: + const EdgeInsets.symmetric( + horizontal: 4, + ), child: Text( fileName, - style: const TextStyle(fontSize: 10), + style: const TextStyle( + fontSize: 10, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -298,8 +396,8 @@ class _TaskDetailsSheetState extends ConsumerState { ); } }).toList(), - ) - ] + ), + ], ], ), ), @@ -320,7 +418,9 @@ class _TaskDetailsSheetState extends ConsumerState { Widget _buildHeader(BuildContext context) { return Container( 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( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -328,13 +428,26 @@ class _TaskDetailsSheetState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(widget.task.title, 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)), + Text( + widget.task.title, + 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,8 +475,9 @@ class _TaskDetailsSheetState extends ConsumerState { spacing: 8, children: _attachments.asMap().entries.map((entry) { final att = entry.value; - final isImg = att['contentType']?.startsWith('image/') ?? false; - + final isImg = + att['contentType']?.startsWith('image/') ?? false; + return Stack( children: [ Container( @@ -373,21 +487,30 @@ class _TaskDetailsSheetState extends ConsumerState { color: Colors.grey.shade200, borderRadius: BorderRadius.circular(8), ), - child: isImg - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.memory( - base64Decode(att['base64Content']!), - fit: BoxFit.cover, + child: isImg + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.memory( + base64Decode(att['base64Content']!), + 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( right: 0, @@ -400,7 +523,11 @@ class _TaskDetailsSheetState extends ConsumerState { }, child: Container( 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 { ), ), _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( icon: const Icon(LucideIcons.send, color: Colors.blue), onPressed: _submitComment, diff --git a/kifi-app/lib/features/projects/providers/project_provider.dart b/kifi-app/lib/features/projects/providers/project_provider.dart index 4c3493a..bafcd83 100644 --- a/kifi-app/lib/features/projects/providers/project_provider.dart +++ b/kifi-app/lib/features/projects/providers/project_provider.dart @@ -15,12 +15,16 @@ final projectsProvider = FutureProvider>((ref) async { return repo.getProjects(); }); -final projectTasksProvider = FutureProvider.family, int>((ref, projectId) async { +final projectTasksProvider = FutureProvider.family, int>(( + ref, + projectId, +) async { final repo = ref.watch(projectRepositoryProvider); return repo.getTasks(projectId); }); -final projectTaskCommentsProvider = FutureProvider.family, int>((ref, taskId) async { - final repo = ref.watch(projectRepositoryProvider); - return repo.getComments(taskId); -}); +final projectTaskCommentsProvider = + FutureProvider.family, int>((ref, taskId) async { + final repo = ref.watch(projectRepositoryProvider); + return repo.getComments(taskId); + }); diff --git a/kifi-app/lib/features/projects/providers/user_provider.dart b/kifi-app/lib/features/projects/providers/user_provider.dart index 1d60985..8d66537 100644 --- a/kifi-app/lib/features/projects/providers/user_provider.dart +++ b/kifi-app/lib/features/projects/providers/user_provider.dart @@ -3,12 +3,18 @@ import '../../../../core/network/dio_client.dart'; import '../../../../core/domain/user.dart'; import 'dart:async'; -final userSearchProvider = FutureProvider.family, String>((ref, query) async { +final userSearchProvider = FutureProvider.family, String>(( + ref, + query, +) async { if (query.isEmpty) return []; - + 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) { final List data = response.data; return data.map((json) => User.fromJson(json)).toList(); diff --git a/kifi-app/lib/features/sales/domain/customer.dart b/kifi-app/lib/features/sales/domain/customer.dart index 3bef0b8..e320afc 100644 --- a/kifi-app/lib/features/sales/domain/customer.dart +++ b/kifi-app/lib/features/sales/domain/customer.dart @@ -10,7 +10,7 @@ class Customer { final int? stateId; final String? photoUrl; final DateTime? createdAt; - + // New fields final String? fatherName; final String? gender; @@ -48,7 +48,9 @@ class Customer { fatherName: json['fatherName'], gender: json['gender'], age: json['age'], - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : null, ); } diff --git a/kifi-app/lib/features/sales/domain/invoice.dart b/kifi-app/lib/features/sales/domain/invoice.dart index 671a7c6..3723341 100644 --- a/kifi-app/lib/features/sales/domain/invoice.dart +++ b/kifi-app/lib/features/sales/domain/invoice.dart @@ -164,7 +164,9 @@ class Invoice { discountTotal: json['discountTotal']?.toDouble() ?? 0.0, totalAmount: json['totalAmount'].toDouble(), 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'], paymentWalletId: json['paymentWalletId'], status: json['status'] ?? 'DRAFT', @@ -172,9 +174,17 @@ class Invoice { isEmi: json['isEmi'] ?? false, emiAmount: json['emiAmount']?.toDouble(), emiCycle: json['emiCycle'], - emiStartDate: json['emiStartDate'] != null ? DateTime.parse(json['emiStartDate']) : 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, + emiStartDate: json['emiStartDate'] != null + ? DateTime.parse(json['emiStartDate']) + : 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; data['invoiceNumber'] = invoiceNumber; 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['taxTotal'] = taxTotal; data['discountTotal'] = discountTotal; data['totalAmount'] = totalAmount; 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 (paymentWalletId != null) data['paymentWalletId'] = paymentWalletId; data['status'] = status; @@ -198,9 +212,11 @@ class Invoice { data['isEmi'] = isEmi; if (emiAmount != null) data['emiAmount'] = emiAmount; 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(); - if (payments != null) data['payments'] = payments!.map((i) => i.toJson()).toList(); + if (payments != null) + data['payments'] = payments!.map((i) => i.toJson()).toList(); return data; } } @@ -229,7 +245,9 @@ class InvoicePayment { id: json['id'], invoiceId: json['invoiceId'], 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', emiInstallmentNumber: json['emiInstallmentNumber'], walletId: json['walletId'], @@ -241,9 +259,11 @@ class InvoicePayment { if (id != null) data['id'] = id; if (invoiceId != null) data['invoiceId'] = invoiceId; 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; - if (emiInstallmentNumber != null) data['emiInstallmentNumber'] = emiInstallmentNumber; + if (emiInstallmentNumber != null) + data['emiInstallmentNumber'] = emiInstallmentNumber; if (walletId != null) data['walletId'] = walletId; return data; } diff --git a/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart b/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart index 5003249..9c92ca0 100644 --- a/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart +++ b/kifi-app/lib/features/sales/presentation/add_customer_sheet.dart @@ -7,7 +7,8 @@ import 'dart:async'; import 'dart:io'; import 'package:image_picker/image_picker.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/smart_search_dropdown.dart'; import '../../business/providers/indian_states_provider.dart'; @@ -45,9 +46,15 @@ class _AddCustomerSheetState extends ConsumerState { _emailCtrl = TextEditingController(text: widget.customer?.email ?? ''); _addressCtrl = TextEditingController(text: widget.customer?.address ?? ''); _gstinCtrl = TextEditingController(text: widget.customer?.gstin ?? ''); - _idNumberCtrl = TextEditingController(text: widget.customer?.idNumber ?? ''); - _fatherNameCtrl = TextEditingController(text: widget.customer?.fatherName ?? ''); - _ageCtrl = TextEditingController(text: widget.customer?.age?.toString() ?? ''); + _idNumberCtrl = TextEditingController( + text: widget.customer?.idNumber ?? '', + ); + _fatherNameCtrl = TextEditingController( + text: widget.customer?.fatherName ?? '', + ); + _ageCtrl = TextEditingController( + text: widget.customer?.age?.toString() ?? '', + ); _selectedGender = widget.customer?.gender; _selectedStateId = widget.customer?.stateId; import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { @@ -79,7 +86,9 @@ class _AddCustomerSheetState extends ConsumerState { void _showImagePickerModal() { showModalBottomSheet( 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( child: Wrap( children: [ @@ -107,7 +116,7 @@ class _AddCustomerSheetState extends ConsumerState { Future _save() async { if (!_formKey.currentState!.validate()) return; - + setState(() => _isLoading = true); final customer = Customer( @@ -126,13 +135,23 @@ class _AddCustomerSheetState extends ConsumerState { try { if (widget.customer == null) { - await ref.read(customersProvider.notifier).addCustomer(customer, photo: _photo); + await ref + .read(customersProvider.notifier) + .addCustomer(customer, photo: _photo); } 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) { 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) { if (mounted) { @@ -142,9 +161,12 @@ class _AddCustomerSheetState extends ConsumerState { title: const Text('Error'), content: Text(e.toString()), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('OK')) + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), ], - ) + ), ); } } finally { @@ -181,7 +203,13 @@ class _AddCustomerSheetState extends ConsumerState { onPressed: () => Navigator.pop(context), ), 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), @@ -195,14 +223,22 @@ class _AddCustomerSheetState extends ConsumerState { backgroundColor: Colors.grey[200], backgroundImage: _photo != null ? FileImage(File(_photo!.path)) as ImageProvider - : (widget.customer?.photoUrl != null && _token != null - ? NetworkImage( - '${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content', - headers: {'Authorization': 'Bearer $_token'}, - ) - : null), - child: _photo == null && widget.customer?.photoUrl == null - ? const Icon(LucideIcons.user, size: 50, color: Colors.grey) + : (widget.customer?.photoUrl != null && + _token != null + ? NetworkImage( + '${DioClient().dio.options.baseUrl}/customers/${widget.customer!.id}/photo/content', + headers: { + 'Authorization': 'Bearer $_token', + }, + ) + : null), + child: + _photo == null && widget.customer?.photoUrl == null + ? const Icon( + LucideIcons.user, + size: 50, + color: Colors.grey, + ) : null, ), Positioned( @@ -214,7 +250,11 @@ class _AddCustomerSheetState extends ConsumerState { color: Colors.blue, 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 { controller: _nameCtrl, labelText: 'Customer Name *', 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), PremiumTextField( @@ -255,7 +296,10 @@ class _AddCustomerSheetState extends ConsumerState { const SizedBox(width: 16), Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(16), @@ -266,9 +310,13 @@ class _AddCustomerSheetState extends ConsumerState { isExpanded: true, hint: const Text('Gender'), items: ['Male', 'Female', 'Other'] - .map((g) => DropdownMenuItem(value: g, child: Text(g))) + .map( + (g) => + DropdownMenuItem(value: g, child: Text(g)), + ) .toList(), - onChanged: (val) => setState(() => _selectedGender = val), + onChanged: (val) => + setState(() => _selectedGender = val), ), ), ), @@ -290,7 +338,9 @@ class _AddCustomerSheetState extends ConsumerState { textCapitalization: TextCapitalization.characters, validator: (val) { 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())) { return 'Invalid GSTIN format'; } @@ -314,13 +364,14 @@ class _AddCustomerSheetState extends ConsumerState { labelText: 'State', hintText: 'Select State', value: _selectedStateId, - fillColor: Colors.grey[100], items: states.map((s) => s.id).toList(), 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})'; }, - onChanged: (val) => setState(() => _selectedStateId = val), + onChanged: (val) => + setState(() => _selectedStateId = val), ); }, loading: () => const CircularProgressIndicator(), @@ -344,11 +395,24 @@ class _AddCustomerSheetState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Colors.blue, foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - child: _isLoading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text(widget.customer == null ? 'Save Customer' : 'Update Customer'), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + widget.customer == null + ? 'Save Customer' + : 'Update Customer', + ), ), ), const SizedBox(height: 24), diff --git a/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart b/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart index d8db6c6..f16cd5f 100644 --- a/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart +++ b/kifi-app/lib/features/sales/presentation/customer_ledger_screen.dart @@ -11,7 +11,8 @@ class CustomerLedgerScreen extends ConsumerStatefulWidget { const CustomerLedgerScreen({super.key, required this.customer}); @override - ConsumerState createState() => _CustomerLedgerScreenState(); + ConsumerState createState() => + _CustomerLedgerScreenState(); } class _CustomerLedgerScreenState extends ConsumerState { @@ -31,10 +32,14 @@ class _CustomerLedgerScreenState extends ConsumerState { loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => Center(child: Text('Error: $err')), 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) { - return const Center(child: Text('No invoices found for this customer.')); + return const Center( + child: Text('No invoices found for this customer.'), + ); } // Sort chronological @@ -43,56 +48,131 @@ class _CustomerLedgerScreenState extends ConsumerState { double runningBalance = 0.0; List rows = []; final wallets = ref.read(walletProvider).value ?? []; - + int index = 1; for (var inv in customerInvoices) { // Add row for Invoice (Debit) runningBalance += inv.totalAmount; - rows.add(DataRow( - cells: [ - DataCell(Text('${index++}')), - DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))), - DataCell(Text(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))), - ], - )); - + rows.add( + DataRow( + cells: [ + DataCell(Text('${index++}')), + DataCell( + Text(DateFormat('dd MMM yyyy').format(inv.issueDate)), + ), + DataCell( + Text( + 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) if (inv.payments != null && inv.payments!.isNotEmpty) { for (var p in inv.payments!) { runningBalance -= p.amount; - final walletName = wallets.firstWhere((w) => w.id == p.walletId, orElse: () => wallets.first).name; - rows.add(DataRow( - cells: [ - DataCell(Text('${index++}')), - 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))), - ], - )); + final walletName = wallets + .firstWhere( + (w) => w.id == p.walletId, + orElse: () => wallets.first, + ) + .name; + rows.add( + DataRow( + cells: [ + DataCell(Text('${index++}')), + 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) { runningBalance -= inv.amountPaid; - rows.add(DataRow( - cells: [ - DataCell(Text('${index++}')), - DataCell(Text(DateFormat('dd MMM yyyy').format(inv.issueDate))), - DataCell(Text(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))), - ], - )); + rows.add( + DataRow( + cells: [ + DataCell(Text('${index++}')), + DataCell( + Text(DateFormat('dd MMM yyyy').format(inv.issueDate)), + ), + DataCell( + Text( + 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 { padding: const EdgeInsets.all(24), child: Column( 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), Text( formatCurrency.format(runningBalance), style: TextStyle( fontSize: 28, 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 { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( - headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Colors.grey.shade100, + ), columnSpacing: 24, columns: const [ - DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))), - DataColumn(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))), + DataColumn( + label: Text( + 'S.No', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + DataColumn( + 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, ), diff --git a/kifi-app/lib/features/sales/presentation/customers_list_screen.dart b/kifi-app/lib/features/sales/presentation/customers_list_screen.dart index 6d4e502..47a790e 100644 --- a/kifi-app/lib/features/sales/presentation/customers_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/customers_list_screen.dart @@ -7,7 +7,8 @@ import '../../../core/widgets/shimmer_loading.dart'; import 'add_customer_sheet.dart'; import 'customer_ledger_screen.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 'package:intl/intl.dart'; @@ -15,7 +16,8 @@ class CustomersListScreen extends ConsumerStatefulWidget { const CustomersListScreen({super.key}); @override - ConsumerState createState() => _CustomersListScreenState(); + ConsumerState createState() => + _CustomersListScreenState(); } class _CustomersListScreenState extends ConsumerState { @@ -53,7 +55,10 @@ class _CustomersListScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Customers', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text( + 'Customers', + style: TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, @@ -69,12 +74,6 @@ class _CustomersListScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Search by name or phone...', 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), ), onChanged: _onSearchChanged, @@ -99,15 +98,20 @@ class _CustomersListScreenState extends ConsumerState { children: [ Icon(LucideIcons.users, size: 64, color: Colors.grey), 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), + ), ], ), ); } - + return RefreshIndicator( onRefresh: () async { - await ref.read(customersProvider.notifier).refresh(_searchCtrl.text); + await ref + .read(customersProvider.notifier) + .refresh(_searchCtrl.text); }, child: ListView.builder( physics: const AlwaysScrollableScrollPhysics(), @@ -115,26 +119,34 @@ class _CustomersListScreenState extends ConsumerState { itemCount: customers.length, itemBuilder: (context, index) { final customer = customers[index]; - + return Consumer( builder: (context, ref, _) { final invoicesState = ref.watch(invoicesProvider); double outstandingBalance = 0.0; if (invoicesState.hasValue) { for (var inv in invoicesState.value!) { - if (inv.customerId == customer.id && inv.status != 'PAID' && inv.status != 'CANCELLED' && inv.status != 'DRAFT') { - outstandingBalance += (inv.totalAmount - inv.amountPaid); + if (inv.customerId == customer.id && + inv.status != 'PAID' && + inv.status != 'CANCELLED' && + inv.status != 'DRAFT') { + outstandingBalance += + (inv.totalAmount - inv.amountPaid); } } } - + return Container( margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), 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( @@ -143,12 +155,20 @@ class _CustomersListScreenState extends ConsumerState { child: InkWell( borderRadius: BorderRadius.circular(20), onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => CustomerLedgerScreen(customer: customer))); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CustomerLedgerScreen( + customer: customer, + ), + ), + ); }, child: Padding( padding: const EdgeInsets.all(16), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ // Avatar Hero( @@ -156,14 +176,29 @@ class _CustomersListScreenState extends ConsumerState { child: CircleAvatar( radius: 30, backgroundColor: Colors.blue.shade50, - backgroundImage: (customer.photoUrl != null && _token != null) + backgroundImage: + (customer.photoUrl != null && + _token != null) ? NetworkImage( '${DioClient().dio.options.baseUrl}/customers/${customer.id}/photo/content', - headers: {'Authorization': 'Bearer $_token'}, + headers: { + 'Authorization': + 'Bearer $_token', + }, ) : 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, ), ), @@ -171,44 +206,102 @@ class _CustomersListScreenState extends ConsumerState { // Details Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, 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), - if (customer.phone != null && customer.phone!.isNotEmpty) + if (customer.phone != null && + customer.phone!.isNotEmpty) Row( children: [ - Icon(LucideIcons.phone, size: 14, color: Colors.grey.shade600), + Icon( + LucideIcons.phone, + size: 14, + color: Colors.grey.shade600, + ), 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: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.only( + top: 4, + ), child: Row( children: [ - Icon(LucideIcons.building, size: 14, color: Colors.grey.shade600), + Icon( + LucideIcons.building, + size: 14, + color: + Colors.grey.shade600, + ), 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), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: + MainAxisAlignment + .spaceBetween, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: + const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), decoration: BoxDecoration( - color: outstandingBalance > 0 ? Colors.red.shade50 : Colors.green.shade50, - borderRadius: BorderRadius.circular(8), + color: + outstandingBalance > 0 + ? Colors.red.shade50 + : Colors.green.shade50, + borderRadius: + BorderRadius.circular( + 8, + ), ), child: Text( - outstandingBalance > 0 ? 'Pending: ${formatCurrency.format(outstandingBalance)}' : 'Settled', + outstandingBalance > 0 + ? 'Pending: ${formatCurrency.format(outstandingBalance)}' + : 'Settled', style: TextStyle( - color: outstandingBalance > 0 ? Colors.red.shade700 : Colors.green.shade700, - fontWeight: FontWeight.bold, + color: + outstandingBalance > 0 + ? Colors.red.shade700 + : Colors + .green + .shade700, + fontWeight: + FontWeight.bold, fontSize: 12, ), ), @@ -216,39 +309,87 @@ class _CustomersListScreenState extends ConsumerState { Row( children: [ IconButton( - icon: const Icon(LucideIcons.edit2, size: 20, color: Colors.blue), - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints(), + icon: const Icon( + LucideIcons.edit2, + size: 20, + color: Colors.blue, + ), + visualDensity: + VisualDensity.compact, + constraints: + const BoxConstraints(), padding: EdgeInsets.zero, onPressed: () { showModalBottomSheet( context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => AddCustomerSheet(customer: customer), + isScrollControlled: + true, + backgroundColor: + Colors + .transparent, + builder: (context) => + AddCustomerSheet( + customer: + customer, + ), ); }, ), const SizedBox(width: 16), IconButton( - icon: const Icon(LucideIcons.trash2, size: 20, color: Colors.redAccent), - visualDensity: VisualDensity.compact, - constraints: const BoxConstraints(), + icon: const Icon( + LucideIcons.trash2, + size: 20, + color: Colors.redAccent, + ), + visualDensity: + VisualDensity.compact, + constraints: + const BoxConstraints(), padding: EdgeInsets.zero, onPressed: () { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Delete Customer'), - content: Text('Are you sure you want to delete ${customer.name}? This action cannot be undone.'), + title: const Text( + 'Delete Customer', + ), + content: Text( + 'Are you sure you want to delete ${customer.name}? This action cannot be undone.', + ), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Cancel')), + TextButton( + onPressed: () => + Navigator.pop( + ctx, + ), + child: + const Text( + 'Cancel', + ), + ), TextButton( onPressed: () { - ref.read(customersProvider.notifier).deleteCustomer(customer.id!); - Navigator.pop(ctx); + ref + .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 { ); }, 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, ), ); diff --git a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart index c7849f1..ff25894 100644 --- a/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoice_details_screen.dart @@ -22,13 +22,18 @@ class InvoiceDetailsScreen extends ConsumerStatefulWidget { const InvoiceDetailsScreen({super.key, required this.invoice}); @override - ConsumerState createState() => _InvoiceDetailsScreenState(); + ConsumerState createState() => + _InvoiceDetailsScreenState(); } class _InvoiceDetailsScreenState extends ConsumerState { final ScreenshotController _screenshotController = ScreenshotController(); - void _showReceivePaymentSheet(BuildContext context, WidgetRef ref, Invoice latestInvoice) async { + void _showReceivePaymentSheet( + BuildContext context, + WidgetRef ref, + Invoice latestInvoice, + ) async { final result = await showModalBottomSheet( context: context, isScrollControlled: true, @@ -43,10 +48,12 @@ class _InvoiceDetailsScreenState extends ConsumerState { @override Widget build(BuildContext context) { final invoicesState = ref.watch(invoicesProvider); - final latestInvoice = invoicesState.value?.firstWhere( - (i) => i.id == widget.invoice.id, - orElse: () => widget.invoice, - ) ?? widget.invoice; + final latestInvoice = + invoicesState.value?.firstWhere( + (i) => i.id == widget.invoice.id, + orElse: () => widget.invoice, + ) ?? + widget.invoice; final customersState = ref.watch(customersProvider); final customer = customersState.value?.firstWhere( @@ -56,19 +63,22 @@ class _InvoiceDetailsScreenState extends ConsumerState { final businessState = ref.watch(businessProfileProvider); final business = businessState.value; - + // Watch products so the UI rebuilds if products are loaded asynchronously ref.watch(productsProvider); final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('dd MMM yyyy'); - + double remaining = latestInvoice.totalAmount - latestInvoice.amountPaid; return Scaffold( backgroundColor: Colors.grey[50], appBar: AppBar( - title: Text('Invoice #${latestInvoice.invoiceNumber}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + title: Text( + 'Invoice #${latestInvoice.invoiceNumber}', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), backgroundColor: Colors.white, foregroundColor: Colors.black, elevation: 0, @@ -76,7 +86,8 @@ class _InvoiceDetailsScreenState extends ConsumerState { actions: [ IconButton( icon: const Icon(LucideIcons.share2, size: 20), - onPressed: () => _showShareOptions(context, latestInvoice.invoiceNumber), + onPressed: () => + _showShareOptions(context, latestInvoice.invoiceNumber), ), ], ), @@ -87,426 +98,783 @@ class _InvoiceDetailsScreenState extends ConsumerState { color: Colors.grey[50], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Biller Header Card (Premium Dark) - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue.shade900, Colors.blue.shade800], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Biller Header Card (Premium Dark) + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade900, Colors.blue.shade800], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.withOpacity(0.2), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow(color: Colors.blue.withOpacity(0.2), blurRadius: 15, offset: const Offset(0, 5)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - business?.businessName ?? 'Your Company Name', - style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + business?.businessName ?? 'Your Company Name', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + latestInvoice.status, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + letterSpacing: 1, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + if (business?.address != null && + business!.address!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.mapPin, + size: 14, + color: Colors.blue.shade200, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + business.address!, + style: TextStyle( + color: Colors.blue.shade100, + fontSize: 13, + height: 1.4, + ), + ), + ), + ], ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + if (business?.contactNumber != null && + business!.contactNumber!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + LucideIcons.phone, + size: 14, + color: Colors.blue.shade200, + ), + const SizedBox(width: 8), + Text( + business.contactNumber!, + style: TextStyle( + color: Colors.blue.shade100, + fontSize: 13, + ), + ), + ], + ), + ), + if (business?.gstin != null && + business!.gstin!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + LucideIcons.building, + size: 14, + color: Colors.blue.shade200, + ), + const SizedBox(width: 8), + Text( + 'GSTIN: ${business.gstin}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 20), + + // Invoice Dates & Customer Details Row + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Dates + Expanded( + flex: 2, + child: Container( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(20), + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), ), - child: Text( - latestInvoice.status, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12, letterSpacing: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'INVOICE NO', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Text( + latestInvoice.invoiceNumber, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + const SizedBox(height: 16), + const Text( + 'INVOICE DATE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Text( + formatDate.format(latestInvoice.issueDate), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + const Text( + 'DUE DATE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1, + ), + ), + const SizedBox(height: 4), + Text( + latestInvoice.dueDate != null + ? formatDate.format(latestInvoice.dueDate!) + : '-', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), + ), + const SizedBox(width: 12), + // Bill To + Expanded( + flex: 3, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'BILL TO', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1, + ), + ), + const SizedBox(height: 8), + if (customer != null) ...[ + Text( + customer.name, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + const SizedBox(height: 4), + if (customer.address != null && + customer.address!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + customer.address!, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + height: 1.3, + ), + ), + ), + if (customer.phone != null && + customer.phone!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + customer.phone!, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ), + if (customer.gstin != null && + customer.gstin!.isNotEmpty) + Text( + 'GSTIN: ${customer.gstin}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ] else ...[ + const Text( + 'Walk-in Customer', + style: TextStyle( + fontSize: 14, + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text( + 'Itemized Details', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 12), + + // Item Cards instead of DataTable + ...(latestInvoice.items ?? []).map((item) { + final product = item.productId != null + ? ref + .read(productsProvider) + .value + ?.where((p) => p.id == item.productId) + .firstOrNull + : null; + final skuToDisplay = item.sku ?? product?.sku; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.03), + blurRadius: 5, + offset: const Offset(0, 2), + ), ], ), - const SizedBox(height: 16), - if (business?.address != null && business!.address!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(LucideIcons.mapPin, size: 14, color: Colors.blue.shade200), - const SizedBox(width: 8), - Expanded(child: Text(business.address!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13, height: 1.4))), - ], - ), - ), - if (business?.contactNumber != null && business!.contactNumber!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Icon(LucideIcons.phone, size: 14, color: Colors.blue.shade200), - const SizedBox(width: 8), - Text(business.contactNumber!, style: TextStyle(color: Colors.blue.shade100, fontSize: 13)), - ], - ), - ), - if (business?.gstin != null && business!.gstin!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Icon(LucideIcons.building, size: 14, color: Colors.blue.shade200), - const SizedBox(width: 8), - Text('GSTIN: ${business.gstin}', style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold)), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 20), - - // Invoice Dates & Customer Details Row - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Dates - Expanded( - flex: 2, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('INVOICE NO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), - const SizedBox(height: 4), - Text(latestInvoice.invoiceNumber, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.blue)), - const SizedBox(height: 16), - const Text('INVOICE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), - const SizedBox(height: 4), - Text(formatDate.format(latestInvoice.issueDate), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - const SizedBox(height: 16), - const Text('DUE DATE', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), - const SizedBox(height: 4), - Text(latestInvoice.dueDate != null ? formatDate.format(latestInvoice.dueDate!) : '-', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - ], - ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item.description ?? 'Item', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + formatCurrency.format(item.total), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], + ), + if (skuToDisplay != null && skuToDisplay.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'SKU: $skuToDisplay', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w600, + ), + ), + ), + if (item.hsnCode != null && item.hsnCode!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'HSN: ${item.hsnCode}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w600, + ), + ), + ) + else if (product?.hsnCode != null && + product!.hsnCode!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'HSN: ${product.hsnCode}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2)} x ${formatCurrency.format(item.unitPrice)}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade800, + ), + ), + Text( + formatCurrency.format( + item.quantity * item.unitPrice, + ), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (item.makingCharge > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '+ Making Charges', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + formatCurrency.format( + item.makingCharge, + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + if (item.otherCharges > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '+ Other Charges', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + formatCurrency.format( + item.otherCharges, + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + if (item.discount > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '- Discount', + style: TextStyle( + fontSize: 12, + color: Colors.green.shade600, + ), + ), + Text( + '-${formatCurrency.format(item.discount)}', + style: TextStyle( + fontSize: 12, + color: Colors.green.shade600, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + if (item.taxRate > 0) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '+ Tax (${item.taxRate}%)', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + formatCurrency.format( + item.total - + (item.quantity * + item.unitPrice) - + item.makingCharge - + item.otherCharges + + item.discount, + ), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + ), + ], ), ), - const SizedBox(width: 12), - // Bill To - Expanded( - flex: 3, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade200), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('BILL TO', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.grey, letterSpacing: 1)), - const SizedBox(height: 8), - if (customer != null) ...[ - Text(customer.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, height: 1.2)), - const SizedBox(height: 4), - if (customer.address != null && customer.address!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(customer.address!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.3)), - ), - if (customer.phone != null && customer.phone!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(customer.phone!, style: TextStyle(fontSize: 13, color: Colors.grey.shade700)), - ), - if (customer.gstin != null && customer.gstin!.isNotEmpty) - Text('GSTIN: ${customer.gstin}', style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.blue)), - ] else ...[ - const Text('Walk-in Customer', style: TextStyle(fontSize: 14, fontStyle: FontStyle.italic, color: Colors.grey)), - ] - ], - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 4), - child: Text('Itemized Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - ), - const SizedBox(height: 12), - - // Item Cards instead of DataTable - ...(latestInvoice.items ?? []).map((item) { - final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null; - final skuToDisplay = item.sku ?? product?.sku; - - return Container( - margin: const EdgeInsets.only(bottom: 12), + ); + }), + + const SizedBox(height: 12), + + // Summary Section + Container( + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade200), boxShadow: [ - BoxShadow(color: Colors.grey.withOpacity(0.03), blurRadius: 5, offset: const Offset(0, 2)), + BoxShadow( + color: Colors.grey.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), ], ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + children: [ + _buildSummaryRow( + 'Subtotal', + formatCurrency.format(latestInvoice.subtotal), + ), + if (latestInvoice.discountTotal > 0) + _buildSummaryRow( + 'Discount', + '-${formatCurrency.format(latestInvoice.discountTotal)}', + color: Colors.green.shade700, + ), + if (latestInvoice.taxTotal > 0) + _buildSummaryRow( + 'Tax', + formatCurrency.format(latestInvoice.taxTotal), + ), + + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1, color: Colors.grey), + ), + + _buildSummaryRow( + 'Grand Total', + formatCurrency.format(latestInvoice.totalAmount), + isBold: true, + fontSize: 18, + color: Colors.black, + ), + const SizedBox(height: 12), + + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Column( children: [ - Expanded( - child: Text( - item.description ?? 'Item', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), + _buildSummaryRow( + 'Amount Paid', + formatCurrency.format(latestInvoice.amountPaid), + color: Colors.green.shade700, + isBold: true, ), - Text( - formatCurrency.format(item.total), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black), + const SizedBox(height: 8), + _buildSummaryRow( + 'Balance Due', + formatCurrency.format(remaining), + color: remaining > 0 + ? Colors.red.shade700 + : Colors.green.shade700, + isBold: true, + fontSize: 16, ), ], ), - if (skuToDisplay != null && skuToDisplay.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text('SKU: $skuToDisplay', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), - ), - if (item.hsnCode != null && item.hsnCode!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text('HSN: ${item.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), - ) - else if (product?.hsnCode != null && product!.hsnCode!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text('HSN: ${product.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600)), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade50, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('${item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2)} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade800)), - Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), - ], - ), - if (item.makingCharge > 0) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('+ Making Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), - Text(formatCurrency.format(item.makingCharge), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)), - ], - ), - ), - if (item.otherCharges > 0) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('+ Other Charges', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), - Text(formatCurrency.format(item.otherCharges), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)), - ], - ), - ), - if (item.discount > 0) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('- Discount', style: TextStyle(fontSize: 12, color: Colors.green.shade600)), - Text('-${formatCurrency.format(item.discount)}', style: TextStyle(fontSize: 12, color: Colors.green.shade600, fontWeight: FontWeight.w600)), - ], - ), - ), - if (item.taxRate > 0) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('+ Tax (${item.taxRate}%)', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), - Text(formatCurrency.format(item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount), style: TextStyle(fontSize: 12, color: Colors.grey.shade700)), - ], - ), - ), - ], - ), - ), - ], - ), + ), + ], ), - ); - }), - - const SizedBox(height: 12), - - // Summary Section - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade200), - boxShadow: [ - BoxShadow(color: Colors.grey.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)), - ], ), - child: Column( - children: [ - _buildSummaryRow('Subtotal', formatCurrency.format(latestInvoice.subtotal)), - if (latestInvoice.discountTotal > 0) - _buildSummaryRow('Discount', '-${formatCurrency.format(latestInvoice.discountTotal)}', color: Colors.green.shade700), - if (latestInvoice.taxTotal > 0) - _buildSummaryRow('Tax', formatCurrency.format(latestInvoice.taxTotal)), - - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Divider(height: 1, color: Colors.grey), - ), - - _buildSummaryRow('Grand Total', formatCurrency.format(latestInvoice.totalAmount), isBold: true, fontSize: 18, color: Colors.black), - const SizedBox(height: 12), - - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + + const SizedBox(height: 24), + + // Payment Info + FutureBuilder>( + future: ref + .read(invoicesProvider.notifier) + .fetchPaymentsForInvoice(latestInvoice.id!), + builder: (context, snapshot) { + final payments = snapshot.data ?? []; + final displayMethod = payments.isNotEmpty + ? payments.last.paymentMethod + : latestInvoice.paymentMethod; + + if (displayMethod == null && !latestInvoice.isEmi) + return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.grey.shade50, + color: Colors.blue.shade50.withOpacity(0.5), borderRadius: BorderRadius.circular(12), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSummaryRow('Amount Paid', formatCurrency.format(latestInvoice.amountPaid), color: Colors.green.shade700, isBold: true), - const SizedBox(height: 8), - _buildSummaryRow('Balance Due', formatCurrency.format(remaining), - color: remaining > 0 ? Colors.red.shade700 : Colors.green.shade700, - isBold: true, - fontSize: 16 - ), + if (displayMethod != null) ...[ + Row( + children: [ + const Icon( + LucideIcons.creditCard, + size: 16, + color: Colors.blue, + ), + const SizedBox(width: 8), + Text( + 'Payment Mode: $displayMethod', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.blue, + ), + ), + ], + ), + ], + if (latestInvoice.isEmi && + latestInvoice.emiAmount != null) ...[ + if (displayMethod != null) + const SizedBox(height: 12), + Row( + children: [ + const Icon( + LucideIcons.calendarClock, + size: 16, + color: Colors.purple, + ), + const SizedBox(width: 8), + Text( + 'EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.purple, + ), + ), + ], + ), + if (latestInvoice.nextPaymentDate != null && + remaining > 0) + Padding( + padding: const EdgeInsets.only( + left: 24, + top: 4, + ), + child: Text( + 'Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ), + ], ], ), - ), - ], + ); + }, ), - ), - - const SizedBox(height: 24), - - // Payment Info - FutureBuilder>( - future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!), - builder: (context, snapshot) { - final payments = snapshot.data ?? []; - final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod; - - if (displayMethod == null && !latestInvoice.isEmi) return const SizedBox.shrink(); - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.blue.shade50.withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (displayMethod != null) ...[ - Row( - children: [ - const Icon(LucideIcons.creditCard, size: 16, color: Colors.blue), - const SizedBox(width: 8), - Text('Payment Mode: $displayMethod', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.blue)), - ], - ), - ], - if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[ - if (displayMethod != null) const SizedBox(height: 12), - Row( - children: [ - const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple), - const SizedBox(width: 8), - Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.purple)), - ], - ), - if (latestInvoice.nextPaymentDate != null && remaining > 0) - Padding( - padding: const EdgeInsets.only(left: 24, top: 4), - child: Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: TextStyle(fontSize: 13, color: Colors.grey.shade700)), - ), - ], - ], - ), - ); - }, - ), - - // Bottom Spacing for FAB - const SizedBox(height: 100), - ], + + // Bottom Spacing for FAB + const SizedBox(height: 100), + ], + ), ), ), ), - ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, - floatingActionButton: (remaining > 0 && latestInvoice.status != 'DRAFT') - ? Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SizedBox( - width: double.infinity, - child: FloatingActionButton.extended( - onPressed: () => _showReceivePaymentSheet(context, ref, latestInvoice), - icon: const Icon(LucideIcons.indianRupee), - label: const Text('Receive Payment', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - backgroundColor: Colors.blue, - elevation: 4, + floatingActionButton: (remaining > 0 && latestInvoice.status != 'DRAFT') + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: FloatingActionButton.extended( + onPressed: () => + _showReceivePaymentSheet(context, ref, latestInvoice), + icon: const Icon(LucideIcons.indianRupee), + label: const Text( + 'Receive Payment', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.blue, + elevation: 4, + ), ), - ), - ) - : null, + ) + : null, ); } - Widget _buildSummaryRow(String label, String value, {bool isBold = false, Color? color, double fontSize = 14}) { + Widget _buildSummaryRow( + String label, + String value, { + bool isBold = false, + Color? color, + double fontSize = 14, + }) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(label, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w500, color: color ?? Colors.grey.shade600)), - Text(value, style: TextStyle(fontSize: fontSize, fontWeight: isBold ? FontWeight.bold : FontWeight.w600, color: color ?? Colors.black87)), + Text( + label, + style: TextStyle( + fontSize: fontSize, + fontWeight: isBold ? FontWeight.bold : FontWeight.w500, + color: color ?? Colors.grey.shade600, + ), + ), + Text( + value, + style: TextStyle( + fontSize: fontSize, + fontWeight: isBold ? FontWeight.bold : FontWeight.w600, + color: color ?? Colors.black87, + ), + ), ], ), ); @@ -515,13 +883,21 @@ class _InvoiceDetailsScreenState extends ConsumerState { void _showShareOptions(BuildContext context, String invoiceNumber) { showModalBottomSheet( 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( child: Wrap( children: [ Padding( padding: const EdgeInsets.all(16.0), - child: Text('Share Invoice $invoiceNumber', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + child: Text( + 'Share Invoice $invoiceNumber', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), ), ListTile( leading: const Icon(LucideIcons.image, color: Colors.blue), @@ -550,41 +926,60 @@ class _InvoiceDetailsScreenState extends ConsumerState { Future _shareAsImage(String invoiceNumber) async { try { - final Uint8List? image = await _screenshotController.capture(pixelRatio: 3.0); + final Uint8List? image = await _screenshotController.capture( + pixelRatio: 3.0, + ); if (image == null) return; - + final directory = await getTemporaryDirectory(); - final imagePath = await File('${directory.path}/Invoice_$invoiceNumber.png').create(); + final imagePath = await File( + '${directory.path}/Invoice_$invoiceNumber.png', + ).create(); await imagePath.writeAsBytes(image); - - await Share.shareXFiles([XFile(imagePath.path)], text: 'Invoice $invoiceNumber'); + + await Share.shareXFiles([ + XFile(imagePath.path), + ], text: 'Invoice $invoiceNumber'); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); + if (mounted) + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error sharing image: $e'))); } } Future _shareAsPdf(String invoiceNumber) async { try { final invoicesState = ref.read(invoicesProvider); - final latestInvoice = invoicesState.value?.firstWhere( - (i) => i.id == widget.invoice.id, - orElse: () => widget.invoice, - ) ?? widget.invoice; + final latestInvoice = + invoicesState.value?.firstWhere( + (i) => i.id == widget.invoice.id, + orElse: () => widget.invoice, + ) ?? + widget.invoice; final customers = ref.read(customersProvider).value ?? []; - final customer = customers.where((c) => c.id == latestInvoice.customerId).isEmpty + final customer = + customers.where((c) => c.id == latestInvoice.customerId).isEmpty ? null : customers.firstWhere((c) => c.id == latestInvoice.customerId); final businessState = ref.read(businessProfileProvider); final business = businessState.value; - final formatCurrency = NumberFormat.currency(symbol: 'Rs. ', decimalDigits: 2); + final formatCurrency = NumberFormat.currency( + symbol: 'Rs. ', + decimalDigits: 2, + ); final formatDate = DateFormat('dd MMM yyyy'); // Fetch payment info - final payments = await ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(latestInvoice.id!); - final displayMethod = payments.isNotEmpty ? payments.last.paymentMethod : latestInvoice.paymentMethod; + final payments = await ref + .read(invoicesProvider.notifier) + .fetchPaymentsForInvoice(latestInvoice.id!); + final displayMethod = payments.isNotEmpty + ? payments.last.paymentMethod + : latestInvoice.paymentMethod; final pdf = pw.Document(); @@ -602,46 +997,131 @@ class _InvoiceDetailsScreenState extends ConsumerState { pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Text(business?.businessName ?? 'Your Company', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)), + pw.Text( + business?.businessName ?? 'Your Company', + style: pw.TextStyle( + fontSize: 24, + fontWeight: pw.FontWeight.bold, + ), + ), pw.SizedBox(height: 4), - if (business?.address != null) pw.Text(business!.address!, style: const pw.TextStyle(fontSize: 12)), - if (business?.contactNumber != null) pw.Text('Phone: ${business!.contactNumber}', style: const pw.TextStyle(fontSize: 12)), - if (business?.gstin != null) pw.Text('GSTIN: ${business!.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + if (business?.address != null) + pw.Text( + business!.address!, + style: const pw.TextStyle(fontSize: 12), + ), + if (business?.contactNumber != null) + pw.Text( + 'Phone: ${business!.contactNumber}', + style: const pw.TextStyle(fontSize: 12), + ), + if (business?.gstin != null) + pw.Text( + 'GSTIN: ${business!.gstin}', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), ], ), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text('INVOICE', style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), + pw.Text( + 'INVOICE', + style: pw.TextStyle( + fontSize: 28, + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue800, + ), + ), pw.SizedBox(height: 8), - pw.Text(latestInvoice.invoiceNumber, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)), - pw.Text('Date: ${formatDate.format(latestInvoice.issueDate)}', style: const pw.TextStyle(fontSize: 12)), + pw.Text( + latestInvoice.invoiceNumber, + style: pw.TextStyle( + fontSize: 16, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + 'Date: ${formatDate.format(latestInvoice.issueDate)}', + style: const pw.TextStyle(fontSize: 12), + ), if (latestInvoice.dueDate != null) - pw.Text('Due Date: ${formatDate.format(latestInvoice.dueDate!)}', style: const pw.TextStyle(fontSize: 12)), + pw.Text( + 'Due Date: ${formatDate.format(latestInvoice.dueDate!)}', + style: const pw.TextStyle(fontSize: 12), + ), pw.SizedBox(height: 4), pw.Container( - padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: pw.BoxDecoration(color: PdfColors.grey200, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(4))), - child: pw.Text(latestInvoice.status, style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + padding: const pw.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: pw.BoxDecoration( + color: PdfColors.grey200, + borderRadius: const pw.BorderRadius.all( + pw.Radius.circular(4), + ), + ), + child: pw.Text( + latestInvoice.status, + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), ), ], ), ], ), pw.SizedBox(height: 32), - + // Bill To - pw.Text('BILL TO:', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold, color: PdfColors.grey600)), + pw.Text( + 'BILL TO:', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey600, + ), + ), pw.SizedBox(height: 4), if (customer != null) ...[ - pw.Text(customer.name, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)), - if (customer.address != null) pw.Text(customer.address!, style: const pw.TextStyle(fontSize: 12)), - if (customer.phone != null) pw.Text('Phone: ${customer.phone}', style: const pw.TextStyle(fontSize: 12)), - if (customer.gstin != null) pw.Text('GSTIN: ${customer.gstin}', style: pw.TextStyle(fontSize: 12, fontWeight: pw.FontWeight.bold)), + pw.Text( + customer.name, + style: pw.TextStyle( + fontSize: 16, + fontWeight: pw.FontWeight.bold, + ), + ), + if (customer.address != null) + pw.Text( + customer.address!, + style: const pw.TextStyle(fontSize: 12), + ), + if (customer.phone != null) + pw.Text( + 'Phone: ${customer.phone}', + style: const pw.TextStyle(fontSize: 12), + ), + if (customer.gstin != null) + pw.Text( + 'GSTIN: ${customer.gstin}', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), ] else ...[ - pw.Text('Walk-in Customer', style: const pw.TextStyle(fontSize: 14)), + pw.Text( + 'Walk-in Customer', + style: const pw.TextStyle(fontSize: 14), + ), ], - + pw.SizedBox(height: 32), // Items Table @@ -649,10 +1129,18 @@ class _InvoiceDetailsScreenState extends ConsumerState { context: context, border: const pw.TableBorder( bottom: pw.BorderSide(color: PdfColors.grey300, width: .5), - horizontalInside: pw.BorderSide(color: PdfColors.grey300, width: .5), + horizontalInside: pw.BorderSide( + color: PdfColors.grey300, + width: .5, + ), + ), + headerStyle: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + color: PdfColors.white, + ), + headerDecoration: const pw.BoxDecoration( + color: PdfColors.blue800, ), - headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.white), - headerDecoration: const pw.BoxDecoration(color: PdfColors.blue800), cellAlignments: { 0: pw.Alignment.centerLeft, 1: pw.Alignment.centerRight, @@ -662,30 +1150,54 @@ class _InvoiceDetailsScreenState extends ConsumerState { data: [ ['Description', 'Qty', 'Unit Price', 'Total'], ...(latestInvoice.items ?? []).map((item) { - final product = item.productId != null ? ref.read(productsProvider).value?.where((p) => p.id == item.productId).firstOrNull : null; + final product = item.productId != null + ? ref + .read(productsProvider) + .value + ?.where((p) => p.id == item.productId) + .firstOrNull + : null; final skuToDisplay = item.sku ?? product?.sku; final hsnToDisplay = item.hsnCode ?? product?.hsnCode; String itemDesc = item.description ?? 'Item'; - if (skuToDisplay != null && skuToDisplay.isNotEmpty) itemDesc += '\nSKU: $skuToDisplay'; - if (hsnToDisplay != null && hsnToDisplay.isNotEmpty) itemDesc += '\nHSN: $hsnToDisplay'; - if (item.makingCharge > 0) itemDesc += '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}'; - if (item.otherCharges > 0) itemDesc += '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}'; - if (item.discount > 0) itemDesc += '\n- Discount: ${formatCurrency.format(item.discount)}'; + if (skuToDisplay != null && skuToDisplay.isNotEmpty) + itemDesc += '\nSKU: $skuToDisplay'; + if (hsnToDisplay != null && hsnToDisplay.isNotEmpty) + itemDesc += '\nHSN: $hsnToDisplay'; + if (item.makingCharge > 0) + itemDesc += + '\n+ Making Charges: ${formatCurrency.format(item.makingCharge)}'; + if (item.otherCharges > 0) + itemDesc += + '\n+ Other Charges: ${formatCurrency.format(item.otherCharges)}'; + if (item.discount > 0) + itemDesc += + '\n- Discount: ${formatCurrency.format(item.discount)}'; if (item.taxRate > 0) { - final taxAmt = item.total - (item.quantity * item.unitPrice) - item.makingCharge - item.otherCharges + item.discount; - itemDesc += '\n+ Tax (${item.taxRate}%): ${formatCurrency.format(taxAmt)}'; + final taxAmt = + item.total - + (item.quantity * item.unitPrice) - + item.makingCharge - + item.otherCharges + + item.discount; + itemDesc += + '\n+ Tax (${item.taxRate}%): ${formatCurrency.format(taxAmt)}'; } - + return [ itemDesc, - item.quantity.toStringAsFixed(item.quantity.truncateToDouble() == item.quantity ? 0 : 2), + item.quantity.toStringAsFixed( + item.quantity.truncateToDouble() == item.quantity + ? 0 + : 2, + ), formatCurrency.format(item.unitPrice), formatCurrency.format(item.total), ]; }), ], ), - + pw.SizedBox(height: 24), // Totals @@ -701,26 +1213,37 @@ class _InvoiceDetailsScreenState extends ConsumerState { mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Subtotal:'), - pw.Text(formatCurrency.format(latestInvoice.subtotal)), + pw.Text( + formatCurrency.format(latestInvoice.subtotal), + ), ], ), pw.SizedBox(height: 4), if (latestInvoice.discountTotal > 0) ...[ pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + mainAxisAlignment: + pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Discount:'), - pw.Text('-${formatCurrency.format(latestInvoice.discountTotal)}', style: const pw.TextStyle(color: PdfColors.green700)), + pw.Text( + '-${formatCurrency.format(latestInvoice.discountTotal)}', + style: const pw.TextStyle( + color: PdfColors.green700, + ), + ), ], ), pw.SizedBox(height: 4), ], if (latestInvoice.taxTotal > 0) ...[ pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + mainAxisAlignment: + pw.MainAxisAlignment.spaceBetween, children: [ pw.Text('Tax:'), - pw.Text(formatCurrency.format(latestInvoice.taxTotal)), + pw.Text( + formatCurrency.format(latestInvoice.taxTotal), + ), ], ), pw.SizedBox(height: 4), @@ -729,24 +1252,57 @@ class _InvoiceDetailsScreenState extends ConsumerState { pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text('Grand Total:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)), - pw.Text(formatCurrency.format(latestInvoice.totalAmount), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 16)), + pw.Text( + 'Grand Total:', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: 16, + ), + ), + pw.Text( + formatCurrency.format(latestInvoice.totalAmount), + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: 16, + ), + ), ], ), pw.SizedBox(height: 12), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text('Amount Paid:', style: pw.TextStyle(color: PdfColors.green700)), - pw.Text(formatCurrency.format(latestInvoice.amountPaid), style: pw.TextStyle(color: PdfColors.green700)), + pw.Text( + 'Amount Paid:', + style: pw.TextStyle(color: PdfColors.green700), + ), + pw.Text( + formatCurrency.format(latestInvoice.amountPaid), + style: pw.TextStyle(color: PdfColors.green700), + ), ], ), pw.Divider(color: PdfColors.grey400), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text('Balance Due:', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)), - pw.Text(formatCurrency.format(latestInvoice.totalAmount - latestInvoice.amountPaid), style: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 14)), + pw.Text( + 'Balance Due:', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: 14, + ), + ), + pw.Text( + formatCurrency.format( + latestInvoice.totalAmount - + latestInvoice.amountPaid, + ), + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + fontSize: 14, + ), + ), ], ), ], @@ -754,48 +1310,88 @@ class _InvoiceDetailsScreenState extends ConsumerState { ), ], ), - + pw.SizedBox(height: 24), - + if (displayMethod != null || latestInvoice.isEmi) pw.Container( padding: const pw.EdgeInsets.all(12), - decoration: pw.BoxDecoration(color: PdfColors.blue50, borderRadius: const pw.BorderRadius.all(pw.Radius.circular(8))), + decoration: pw.BoxDecoration( + color: PdfColors.blue50, + borderRadius: const pw.BorderRadius.all( + pw.Radius.circular(8), + ), + ), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ if (displayMethod != null) - pw.Text('Payment Mode: $displayMethod', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.blue800)), - if (latestInvoice.isEmi && latestInvoice.emiAmount != null) ...[ + pw.Text( + 'Payment Mode: $displayMethod', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue800, + ), + ), + if (latestInvoice.isEmi && + latestInvoice.emiAmount != null) ...[ if (displayMethod != null) pw.SizedBox(height: 4), - pw.Text('EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold, color: PdfColors.purple800)), - if (latestInvoice.nextPaymentDate != null && (latestInvoice.totalAmount - latestInvoice.amountPaid) > 0) - pw.Text('Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', style: const pw.TextStyle(fontSize: 12, color: PdfColors.grey700)), + pw.Text( + 'EMI: ${formatCurrency.format(latestInvoice.emiAmount)} / ${latestInvoice.emiCycle}', + style: pw.TextStyle( + fontWeight: pw.FontWeight.bold, + color: PdfColors.purple800, + ), + ), + if (latestInvoice.nextPaymentDate != null && + (latestInvoice.totalAmount - + latestInvoice.amountPaid) > + 0) + pw.Text( + 'Next Due: ${formatDate.format(latestInvoice.nextPaymentDate!)}', + style: const pw.TextStyle( + fontSize: 12, + color: PdfColors.grey700, + ), + ), ], ], ), ), - + pw.SizedBox(height: 40), - + // Footer pw.Divider(color: PdfColors.grey300), pw.SizedBox(height: 8), pw.Center( - child: pw.Text('Thank you for your business!', style: pw.TextStyle(color: PdfColors.grey600, fontStyle: pw.FontStyle.italic)), + child: pw.Text( + 'Thank you for your business!', + style: pw.TextStyle( + color: PdfColors.grey600, + fontStyle: pw.FontStyle.italic, + ), + ), ), ]; }, ), ); - + final directory = await getTemporaryDirectory(); - final pdfPath = await File('${directory.path}/Invoice_$invoiceNumber.pdf').create(); + final pdfPath = await File( + '${directory.path}/Invoice_$invoiceNumber.pdf', + ).create(); await pdfPath.writeAsBytes(await pdf.save()); - - await Share.shareXFiles([XFile(pdfPath.path)], text: 'Invoice $invoiceNumber'); + + await Share.shareXFiles([ + XFile(pdfPath.path), + ], text: 'Invoice $invoiceNumber'); } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e'))); + if (mounted) + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error sharing PDF: $e'))); } } } diff --git a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart index 89c48b6..ad71195 100644 --- a/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart +++ b/kifi-app/lib/features/sales/presentation/invoices_list_screen.dart @@ -32,31 +32,52 @@ class _InvoicesListScreenState extends ConsumerState { Color _getStatusColor(Invoice invoice) { if (invoice.isEmi && invoice.status != 'PAID') return Colors.purple; switch (invoice.status) { - case 'DRAFT': return Colors.grey; - case 'FINALIZED': return Colors.orange; - case 'PAID': return Colors.green; - case 'PARTIAL': return Colors.blue; - case 'OVERDUE': return Colors.red; - case 'CANCELLED': return Colors.black; - default: return Colors.grey; + case 'DRAFT': + return Colors.grey; + case 'FINALIZED': + return Colors.orange; + case 'PAID': + return Colors.green; + 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( context: context, isScrollControlled: true, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), builder: (ctx) { return Padding( padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), child: Column( mainAxisSize: MainAxisSize.min, 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), FutureBuilder>( - future: ref.read(invoicesProvider.notifier).fetchPaymentsForInvoice(invoice.id!), + future: ref + .read(invoicesProvider.notifier) + .fetchPaymentsForInvoice(invoice.id!), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -72,26 +93,71 @@ class _InvoicesListScreenState extends ConsumerState { ); } final wallets = ref.read(walletProvider).value ?? []; - + return SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( - headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100), + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Colors.grey.shade100, + ), columnSpacing: 24, columns: const [ - DataColumn(label: Text('Date', 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))), + DataColumn( + label: Text( + 'Date', + 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) { - 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( cells: [ - DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')), - DataCell(Text(p.walletId != null ? walletName : '-')), + DataCell( + Text( + p.paymentDate != null + ? DateFormat( + 'dd MMM yyyy', + ).format(p.paymentDate!) + : '-', + ), + ), + DataCell( + Text(p.walletId != null ? walletName : '-'), + ), 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(), @@ -131,35 +197,33 @@ class _InvoicesListScreenState extends ConsumerState { final invoicesState = ref.watch(invoicesProvider); final customersState = ref.watch(customersProvider); final customers = customersState.value ?? []; - + final formatCurrency = NumberFormat.currency(symbol: '₹'); final formatDate = DateFormat('MMM dd, yyyy'); return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Invoices'), + title: const Text( + 'Invoices', + style: TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, + centerTitle: true, ), body: Column( children: [ Container( color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Search by Invoice # or Customer', prefixIcon: const Icon(LucideIcons.search, color: Colors.grey), - filled: true, - fillColor: Colors.grey[100], - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric(vertical: 12), + contentPadding: const EdgeInsets.symmetric(vertical: 14), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(LucideIcons.x, size: 20), @@ -192,9 +256,16 @@ class _InvoicesListScreenState extends ConsumerState { error: (err, stack) => Center(child: Text('Error: $err')), data: (allInvoices) { final invoices = allInvoices.where((invoice) { - final matchesInvoice = invoice.invoiceNumber.toLowerCase().contains(_searchQuery); - final customer = customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first); - final customerName = invoice.customerId != null ? customer.name.toLowerCase() : ''; + final matchesInvoice = invoice.invoiceNumber + .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); return matchesInvoice || matchesCustomer; }).toList(); @@ -208,12 +279,31 @@ class _InvoicesListScreenState extends ConsumerState { physics: const AlwaysScrollableScrollPhysics(), children: const [ 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, + ), + ), + ], + ), + ), ], ), ); } - + return RefreshIndicator( onRefresh: () async { await ref.read(invoicesProvider.notifier).refresh(); @@ -224,139 +314,270 @@ class _InvoicesListScreenState extends ConsumerState { itemCount: invoices.length, itemBuilder: (context, index) { final invoice = invoices[index]; - double remaining = invoice.totalAmount - invoice.amountPaid; - final customer = invoice.customerId != null - ? customers.firstWhere((c) => c.id == invoice.customerId, orElse: () => customers.first) + double remaining = + invoice.totalAmount - invoice.amountPaid; + final customer = invoice.customerId != null + ? customers.firstWhere( + (c) => c.id == invoice.customerId, + orElse: () => customers.first, + ) : null; - - return Card( - elevation: 0, - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => InvoiceDetailsScreen(invoice: invoice)), - ); - }, - borderRadius: BorderRadius.circular(16), - 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)), - ], - ), + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + InvoiceDetailsScreen(invoice: invoice), + ), + ); + }, + 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( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.all(8), 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), + color: Colors.purple.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.purple.withOpacity(0.2), ), - if (invoice.amountPaid > 0) - Text( - 'Paid: ${formatCurrency.format(invoice.amountPaid)}', - style: TextStyle(color: Colors.green.shade700, fontSize: 12), + ), + child: Row( + children: [ + const Icon( + LucideIcons.calendarClock, + size: 16, + color: Colors.purple, ), - if (remaining > 0 && invoice.status != 'DRAFT') + const SizedBox(width: 8), Text( - 'Bal: ${formatCurrency.format(remaining)}', - style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold), + 'EMI: ${formatCurrency.format(invoice.emiAmount)} / ${invoice.emiCycle}', + style: const TextStyle( + color: Colors.purple, + fontSize: 12, + ), ), - ], + ], + ), ), ], - ), - if (invoice.isEmi && invoice.emiAmount != null) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.purple.withOpacity(0.05), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.purple.withOpacity(0.2)), - ), - child: Row( - children: [ - const Icon(LucideIcons.calendarClock, size: 16, color: Colors.purple), + 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), - 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 { ), floatingActionButton: FloatingActionButton.extended( onPressed: () { - Navigator.push(context, MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen())); + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const InvoiceBuilderScreen()), + ); }, icon: const Icon(LucideIcons.plus), label: const Text('Create Invoice'), diff --git a/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart index b11ddd1..64bd521 100644 --- a/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart +++ b/kifi-app/lib/features/sales/presentation/widgets/receive_payment_sheet.dart @@ -12,7 +12,8 @@ class ReceivePaymentSheet extends ConsumerStatefulWidget { const ReceivePaymentSheet({super.key, required this.invoice}); @override - ConsumerState createState() => _ReceivePaymentSheetState(); + ConsumerState createState() => + _ReceivePaymentSheetState(); } class _ReceivePaymentSheetState extends ConsumerState { @@ -32,7 +33,7 @@ class _ReceivePaymentSheetState extends ConsumerState { Widget build(BuildContext context) { final walletsState = ref.watch(walletProvider); final allWallets = walletsState.value ?? []; - + final wallets = allWallets.where((w) { if (_paymentMethod == 'Cash') { return w.nature == 'CASH'; @@ -40,8 +41,10 @@ class _ReceivePaymentSheetState extends ConsumerState { return w.nature == 'INCOME' || w.nature == 'SAVINGS'; } }).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; } @@ -63,8 +66,14 @@ class _ReceivePaymentSheetState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Receive Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context)), + const Text( + 'Receive Payment', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => Navigator.pop(context), + ), ], ), const SizedBox(height: 16), @@ -74,7 +83,10 @@ class _ReceivePaymentSheetState extends ConsumerState { keyboardType: const TextInputType.numberWithOptions(decimal: true), ), 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), Container( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -88,14 +100,19 @@ class _ReceivePaymentSheetState extends ConsumerState { isExpanded: true, hint: const Text('Select Wallet'), items: wallets - .map((w) => DropdownMenuItem(value: w.id, child: Text(w.name))) + .map( + (w) => DropdownMenuItem(value: w.id, child: Text(w.name)), + ) .toList(), onChanged: (val) => setState(() => _selectedWalletId = val), ), ), ), 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), Container( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -129,36 +146,64 @@ class _ReceivePaymentSheetState extends ConsumerState { backgroundColor: Colors.blue, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), - onPressed: _isLoading ? null : () async { - final amount = double.tryParse(_amountCtrl.text) ?? 0; - if (amount <= 0) return; - - setState(() => _isLoading = true); - try { - await ref.read(invoicesProvider.notifier).addPayment( - widget.invoice.id!, - InvoicePayment( - amount: amount, - paymentMethod: _paymentMethod, - walletId: _selectedWalletId, + onPressed: _isLoading + ? null + : () async { + final amount = double.tryParse(_amountCtrl.text) ?? 0; + if (amount <= 0) return; + + setState(() => _isLoading = true); + try { + await ref + .read(invoicesProvider.notifier) + .addPayment( + widget.invoice.id!, + InvoicePayment( + 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!'), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.toString()))); + setState(() => _isLoading = false); + } + } + }, + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Confirm Payment', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ); - if (mounted) { - Navigator.pop(context, true); // true indicates success - 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 - ? const SizedBox(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), diff --git a/kifi-app/lib/features/sales/providers/customers_provider.dart b/kifi-app/lib/features/sales/providers/customers_provider.dart index aa2182f..701581d 100644 --- a/kifi-app/lib/features/sales/providers/customers_provider.dart +++ b/kifi-app/lib/features/sales/providers/customers_provider.dart @@ -17,7 +17,9 @@ class CustomersNotifier extends AsyncNotifier> { try { final response = await DioClient().dio.get( '/customers', - queryParameters: search != null && search.isNotEmpty ? {'search': search} : null, + queryParameters: search != null && search.isNotEmpty + ? {'search': search} + : null, ); if (response.statusCode == 200) { final List data = response.data; @@ -46,16 +48,18 @@ class CustomersNotifier extends AsyncNotifier> { '/customers', data: customer.toJson(), ); - + if (photo != null && response.data != null) { final customerId = response.data['id']; await _uploadPhoto(customerId, photo); } - + await refresh(); } catch (e) { 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'); } @@ -63,19 +67,18 @@ class CustomersNotifier extends AsyncNotifier> { Future updateCustomer(int id, Customer customer, {XFile? photo}) async { try { - await DioClient().dio.put( - '/customers/$id', - data: customer.toJson(), - ); - + await DioClient().dio.put('/customers/$id', data: customer.toJson()); + if (photo != null) { await _uploadPhoto(id, photo); } - + await refresh(); } catch (e) { 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'); } @@ -89,15 +92,12 @@ class CustomersNotifier extends AsyncNotifier> { minHeight: 531, quality: 85, ); - + final formData = FormData.fromMap({ 'file': MultipartFile.fromBytes(compressedBytes, filename: photo.name), }); - - await DioClient().dio.post( - '/customers/$customerId/photo', - data: formData, - ); + + await DioClient().dio.post('/customers/$customerId/photo', data: formData); } Future deleteCustomer(int id) async { @@ -110,6 +110,7 @@ class CustomersNotifier extends AsyncNotifier> { } } -final customersProvider = AsyncNotifierProvider>(() { - return CustomersNotifier(); -}); +final customersProvider = + AsyncNotifierProvider>(() { + return CustomersNotifier(); + }); diff --git a/kifi-app/lib/features/sales/providers/invoices_provider.dart b/kifi-app/lib/features/sales/providers/invoices_provider.dart index 3216a04..a635b56 100644 --- a/kifi-app/lib/features/sales/providers/invoices_provider.dart +++ b/kifi-app/lib/features/sales/providers/invoices_provider.dart @@ -36,14 +36,13 @@ class InvoicesNotifier extends AsyncNotifier> { Future createInvoice(Invoice invoice) async { try { - await DioClient().dio.post( - '/invoices', - data: invoice.toJson(), - ); + await DioClient().dio.post('/invoices', data: invoice.toJson()); await refresh(); } catch (e) { 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'); } @@ -67,7 +66,9 @@ class InvoicesNotifier extends AsyncNotifier> { await refresh(); } catch (e) { 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'); } @@ -75,7 +76,9 @@ class InvoicesNotifier extends AsyncNotifier> { Future> fetchPaymentsForInvoice(int invoiceId) async { try { - final response = await DioClient().dio.get('/invoices/$invoiceId/payments'); + final response = await DioClient().dio.get( + '/invoices/$invoiceId/payments', + ); if (response.statusCode == 200) { final List data = response.data; return data.map((e) => InvoicePayment.fromJson(e)).toList(); @@ -88,6 +91,8 @@ class InvoicesNotifier extends AsyncNotifier> { } } -final invoicesProvider = AsyncNotifierProvider>(() { - return InvoicesNotifier(); -}); +final invoicesProvider = AsyncNotifierProvider>( + () { + return InvoicesNotifier(); + }, +); diff --git a/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart index 9c28379..74145ab 100644 --- a/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart +++ b/kifi-app/lib/features/transactions/presentation/add_transaction_screen.dart @@ -970,7 +970,7 @@ class _AddTransactionScreenState extends ConsumerState { borderRadius: BorderRadius.circular(8), image: DecorationImage( 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, fit: BoxFit.cover, ), @@ -1087,7 +1087,7 @@ class _AddTransactionScreenState extends ConsumerState { // Add existing attachments for (final att in existingAttachments) { 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 { allImages.add(const AssetImage('assets/images/placeholder.png')); } diff --git a/kifi-app/lib/features/vendor/domain/purchase_order.dart b/kifi-app/lib/features/vendor/domain/purchase_order.dart index 397c4bb..728ded0 100644 --- a/kifi-app/lib/features/vendor/domain/purchase_order.dart +++ b/kifi-app/lib/features/vendor/domain/purchase_order.dart @@ -10,6 +10,9 @@ class PurchaseOrder { final DateTime? dueDate; final double subtotal; final double taxTotal; + final double? cgstTotal; + final double? sgstTotal; + final double? igstTotal; final double discountTotal; final double totalAmount; final String status; @@ -32,6 +35,9 @@ class PurchaseOrder { this.dueDate, this.subtotal = 0.0, this.taxTotal = 0.0, + this.cgstTotal, + this.sgstTotal, + this.igstTotal, this.discountTotal = 0.0, this.totalAmount = 0.0, this.status = 'PENDING', @@ -52,21 +58,36 @@ class PurchaseOrder { userId: json['userId'], vendorId: json['vendorId'], 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, subtotal: (json['subtotal'] ?? 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(), totalAmount: (json['totalAmount'] ?? 0.0).toDouble(), status: json['status'] ?? 'PENDING', notes: json['notes'], - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, - updatedAt: json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : null, + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt']) + : null, poDate: json['poDate'] != null ? DateTime.parse(json['poDate']) : null, 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'], - 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, ); } @@ -81,6 +102,9 @@ class PurchaseOrder { 'dueDate': dueDate?.toIso8601String(), 'subtotal': subtotal, 'taxTotal': taxTotal, + 'cgstTotal': cgstTotal, + 'sgstTotal': sgstTotal, + 'igstTotal': igstTotal, 'discountTotal': discountTotal, 'totalAmount': totalAmount, 'status': status, @@ -102,6 +126,9 @@ class PurchaseOrder { DateTime? dueDate, double? subtotal, double? taxTotal, + double? cgstTotal, + double? sgstTotal, + double? igstTotal, double? discountTotal, double? totalAmount, String? status, @@ -122,6 +149,9 @@ class PurchaseOrder { dueDate: dueDate ?? this.dueDate, subtotal: subtotal ?? this.subtotal, taxTotal: taxTotal ?? this.taxTotal, + cgstTotal: cgstTotal ?? this.cgstTotal, + sgstTotal: sgstTotal ?? this.sgstTotal, + igstTotal: igstTotal ?? this.igstTotal, discountTotal: discountTotal ?? this.discountTotal, totalAmount: totalAmount ?? this.totalAmount, status: status ?? this.status, diff --git a/kifi-app/lib/features/vendor/domain/purchase_order_item.dart b/kifi-app/lib/features/vendor/domain/purchase_order_item.dart index 5324847..0228e5d 100644 --- a/kifi-app/lib/features/vendor/domain/purchase_order_item.dart +++ b/kifi-app/lib/features/vendor/domain/purchase_order_item.dart @@ -5,6 +5,9 @@ class PurchaseOrderItem { final double quantity; final double unitPrice; final double taxRate; + final double? cgstRate; + final double? sgstRate; + final double? igstRate; final double discount; final double total; final String? description; @@ -12,6 +15,11 @@ class PurchaseOrderItem { final double otherCharges; final String? sku; + final String? huid; + final double? weight; + final String? photoUrl; + final String? localPhotoPath; + PurchaseOrderItem({ this.id, this.poId, @@ -19,12 +27,20 @@ class PurchaseOrderItem { this.quantity = 1.0, this.unitPrice = 0.0, this.taxRate = 0.0, + this.cgstRate, + this.sgstRate, + this.igstRate, this.discount = 0.0, this.total = 0.0, this.description, this.makingCharge = 0.0, this.otherCharges = 0.0, this.sku, + + this.huid, + this.weight, + this.photoUrl, + this.localPhotoPath, }); factory PurchaseOrderItem.fromJson(Map json) { @@ -35,12 +51,19 @@ class PurchaseOrderItem { quantity: (json['quantity'] ?? 1.0).toDouble(), unitPrice: (json['unitPrice'] ?? 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(), total: (json['total'] ?? 0.0).toDouble(), description: json['description'], makingCharge: (json['makingCharge'] ?? 0.0).toDouble(), otherCharges: (json['otherCharges'] ?? 0.0).toDouble(), 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, 'unitPrice': unitPrice, 'taxRate': taxRate, + 'cgstRate': cgstRate, + 'sgstRate': sgstRate, + 'igstRate': igstRate, 'discount': discount, 'total': total, 'description': description, 'makingCharge': makingCharge, 'otherCharges': otherCharges, 'sku': sku, + + 'huid': huid, + 'weight': weight, + 'photoUrl': photoUrl, }; } @@ -68,12 +98,20 @@ class PurchaseOrderItem { double? quantity, double? unitPrice, double? taxRate, + double? cgstRate, + double? sgstRate, + double? igstRate, double? discount, double? total, String? description, double? makingCharge, double? otherCharges, String? sku, + + String? huid, + double? weight, + String? photoUrl, + String? localPhotoPath, }) { return PurchaseOrderItem( id: id ?? this.id, @@ -82,12 +120,20 @@ class PurchaseOrderItem { quantity: quantity ?? this.quantity, unitPrice: unitPrice ?? this.unitPrice, taxRate: taxRate ?? this.taxRate, + cgstRate: cgstRate ?? this.cgstRate, + sgstRate: sgstRate ?? this.sgstRate, + igstRate: igstRate ?? this.igstRate, discount: discount ?? this.discount, total: total ?? this.total, description: description ?? this.description, makingCharge: makingCharge ?? this.makingCharge, otherCharges: otherCharges ?? this.otherCharges, sku: sku ?? this.sku, + + huid: huid ?? this.huid, + weight: weight ?? this.weight, + photoUrl: photoUrl ?? this.photoUrl, + localPhotoPath: localPhotoPath ?? this.localPhotoPath, ); } } diff --git a/kifi-app/lib/features/vendor/domain/purchase_payment.dart b/kifi-app/lib/features/vendor/domain/purchase_payment.dart index 62aa63a..1608f3e 100644 --- a/kifi-app/lib/features/vendor/domain/purchase_payment.dart +++ b/kifi-app/lib/features/vendor/domain/purchase_payment.dart @@ -24,7 +24,9 @@ class PurchasePayment { transactionId: json['transactionId'], amount: (json['amount'] ?? 0.0).toDouble(), paymentMethod: json['paymentMethod'], - paymentDate: json['paymentDate'] != null ? DateTime.parse(json['paymentDate']) : null, + paymentDate: json['paymentDate'] != null + ? DateTime.parse(json['paymentDate']) + : null, notes: json['notes'], ); } diff --git a/kifi-app/lib/features/vendor/domain/vendor.dart b/kifi-app/lib/features/vendor/domain/vendor.dart index b522b27..2466c17 100644 --- a/kifi-app/lib/features/vendor/domain/vendor.dart +++ b/kifi-app/lib/features/vendor/domain/vendor.dart @@ -40,7 +40,9 @@ class Vendor { idNumber: json['idNumber'], photoUrl: json['photoUrl'], contactPerson: json['contactPerson'], - createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt']) + : null, ); } diff --git a/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart index 9ed7874..eb9e981 100644 --- a/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart +++ b/kifi-app/lib/features/vendor/presentation/add_vendor_sheet.dart @@ -7,7 +7,8 @@ import 'dart:async'; import 'dart:io'; import 'package:image_picker/image_picker.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/smart_search_dropdown.dart'; import '../../business/providers/indian_states_provider.dart'; @@ -45,7 +46,9 @@ class _AddVendorSheetState extends ConsumerState { _addressCtrl = TextEditingController(text: widget.vendor?.address ?? ''); _gstinCtrl = TextEditingController(text: widget.vendor?.gstin ?? ''); _idNumberCtrl = TextEditingController(text: widget.vendor?.idNumber ?? ''); - _contactPersonCtrl = TextEditingController(text: widget.vendor?.contactPerson ?? ''); + _contactPersonCtrl = TextEditingController( + text: widget.vendor?.contactPerson ?? '', + ); _selectedStateId = widget.vendor?.stateId; import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { if (mounted) setState(() => _token = val); @@ -75,7 +78,9 @@ class _AddVendorSheetState extends ConsumerState { void _showImagePickerModal() { showModalBottomSheet( 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( child: Wrap( children: [ @@ -109,15 +114,18 @@ class _AddVendorSheetState extends ConsumerState { String? photoUrl = widget.vendor?.photoUrl; if (_photo != null && _token != null) { final formData = FormData.fromMap({ - 'file': await MultipartFile.fromFile(_photo!.path, filename: _photo!.name), + 'file': await MultipartFile.fromFile( + _photo!.path, + filename: _photo!.name, + ), 'type': 'VENDOR', }); - + final uploadResp = await DioClient().dio.post( '/upload', data: formData, ); - + if (uploadResp.statusCode == 200) { photoUrl = uploadResp.data['url']; } @@ -133,7 +141,9 @@ class _AddVendorSheetState extends ConsumerState { idNumber: _idNumberCtrl.text.isEmpty ? null : _idNumberCtrl.text, stateId: _selectedStateId, photoUrl: photoUrl, - contactPerson: _contactPersonCtrl.text.isEmpty ? null : _contactPersonCtrl.text, + contactPerson: _contactPersonCtrl.text.isEmpty + ? null + : _contactPersonCtrl.text, ); if (widget.vendor == null) { @@ -145,7 +155,9 @@ class _AddVendorSheetState extends ConsumerState { if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); @@ -173,7 +185,10 @@ class _AddVendorSheetState extends ConsumerState { Expanded( child: Text( widget.vendor == null ? 'New Vendor' : 'Edit Vendor', - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -196,10 +211,16 @@ class _AddVendorSheetState extends ConsumerState { backgroundImage: _photo != null ? FileImage(File(_photo!.path)) as ImageProvider : (widget.vendor?.photoUrl != null - ? NetworkImage(widget.vendor!.photoUrl!) - : null), - child: (_photo == null && widget.vendor?.photoUrl == null) - ? const Icon(LucideIcons.truck, size: 50, color: Colors.blue) + ? NetworkImage(widget.vendor!.photoUrl!) + : null), + child: + (_photo == null && + widget.vendor?.photoUrl == null) + ? const Icon( + LucideIcons.truck, + size: 50, + color: Colors.blue, + ) : null, ), Positioned( @@ -213,7 +234,11 @@ class _AddVendorSheetState extends ConsumerState { color: Colors.blue, shape: BoxShape.circle, ), - child: const Icon(LucideIcons.camera, color: Colors.white, size: 20), + child: const Icon( + LucideIcons.camera, + color: Colors.white, + size: 20, + ), ), ), ), @@ -226,7 +251,8 @@ class _AddVendorSheetState extends ConsumerState { labelText: 'Vendor Name *', prefixIcon: const Icon(LucideIcons.building), textCapitalization: TextCapitalization.words, - validator: (val) => val == null || val.isEmpty ? 'Required' : null, + validator: (val) => + val == null || val.isEmpty ? 'Required' : null, ), const SizedBox(height: 16), PremiumTextField( @@ -275,10 +301,12 @@ class _AddVendorSheetState extends ConsumerState { value: _selectedStateId, items: states.map((s) => s.id).toList(), 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})'; }, - onChanged: (val) => setState(() => _selectedStateId = val), + onChanged: (val) => + setState(() => _selectedStateId = val), ); }, loading: () => const CircularProgressIndicator(), @@ -302,11 +330,24 @@ class _AddVendorSheetState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: 16), backgroundColor: Colors.blue, foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), - child: _isLoading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text(widget.vendor == null ? 'Save Vendor' : 'Update Vendor'), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text( + widget.vendor == null + ? 'Save Vendor' + : 'Update Vendor', + ), ), ), SizedBox(height: MediaQuery.of(context).viewInsets.bottom), diff --git a/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart index a8ac758..0bee0b8 100644 --- a/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart +++ b/kifi-app/lib/features/vendor/presentation/pay_vendor_sheet.dart @@ -25,13 +25,21 @@ class _PayVendorSheetState extends ConsumerState { String? _selectedMethod; bool _isLoading = false; - final List _paymentMethods = ['CASH', 'BANK_TRANSFER', 'UPI', 'CARD', 'CHEQUE']; + final List _paymentMethods = [ + 'CASH', + 'BANK_TRANSFER', + 'UPI', + 'CARD', + 'CHEQUE', + ]; @override void initState() { super.initState(); final balance = widget.po.totalAmount - widget.po.amountPaid; - _amountCtrl = TextEditingController(text: balance > 0 ? balance.toStringAsFixed(2) : '0'); + _amountCtrl = TextEditingController( + text: balance > 0 ? balance.toStringAsFixed(2) : '0', + ); } @override @@ -44,7 +52,9 @@ class _PayVendorSheetState extends ConsumerState { Future _savePayment() async { if (!_formKey.currentState!.validate()) return; if (_selectedMethod == null) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a payment method'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a payment method')), + ); return; } @@ -57,15 +67,21 @@ class _PayVendorSheetState extends ConsumerState { notes: _notesCtrl.text.isEmpty ? null : _notesCtrl.text, ); - await ref.read(purchaseOrdersProvider.notifier).addPayment(widget.po.id!, payment); - + await ref + .read(purchaseOrdersProvider.notifier) + .addPayment(widget.po.id!, payment); + if (mounted) { Navigator.pop(context, true); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Payment recorded successfully'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Payment recorded successfully')), + ); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); @@ -75,7 +91,12 @@ class _PayVendorSheetState extends ConsumerState { @override Widget build(BuildContext context) { return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 24, + right: 24, + top: 24, + ), child: Form( key: _formKey, child: Column( @@ -85,7 +106,10 @@ class _PayVendorSheetState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text('Record Payment', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const Text( + 'Record Payment', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), IconButton( icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(context), @@ -96,7 +120,9 @@ class _PayVendorSheetState extends ConsumerState { PremiumTextField( controller: _amountCtrl, labelText: 'Amount Paid', - keyboardType: const TextInputType.numberWithOptions(decimal: true), + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), prefixIcon: const Icon(LucideIcons.indianRupee), validator: (val) { if (val == null || val.isEmpty) return 'Required'; @@ -125,7 +151,7 @@ class _PayVendorSheetState extends ConsumerState { if (d != null) setState(() => _paymentDate = d); }, child: InputDecorator( - decoration: const InputDecoration(labelText: 'Payment Date', border: OutlineInputBorder()), + decoration: const InputDecoration(labelText: 'Payment Date'), child: Text(DateFormat('dd MMM yyyy').format(_paymentDate)), ), ), @@ -145,9 +171,16 @@ class _PayVendorSheetState extends ConsumerState { backgroundColor: Colors.green, foregroundColor: Colors.white, ), - child: _isLoading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Text('Save Payment'), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text('Save Payment'), ), ), const SizedBox(height: 24), diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart index 14d72cf..d02a148 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_builder_screen.dart @@ -1,21 +1,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons/lucide_icons.dart'; -import 'package:intl/intl.dart'; -import 'dart:io'; import 'package:image_picker/image_picker.dart'; +import 'dart:io'; import 'package:dio/dio.dart'; import '../../../core/network/dio_client.dart'; +import 'package:intl/intl.dart'; import '../../../core/widgets/premium_text_field.dart'; import '../../../core/widgets/smart_search_dropdown.dart'; import '../../inventory/providers/products_provider.dart'; +import '../../inventory/providers/product_categories_provider.dart'; import '../providers/vendors_provider.dart'; +import '../../business/providers/business_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; import '../providers/purchase_orders_provider.dart'; import '../domain/purchase_order.dart'; import '../domain/purchase_order_item.dart'; import '../domain/vendor.dart'; import '../../inventory/domain/product.dart'; import 'pay_vendor_sheet.dart' as import_pay; +import 'widgets/quick_add_vendor_sheet.dart'; + +String _resolveImageUrl(String path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + final baseUrl = DioClient().dio.options.baseUrl; + return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}'; +} class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget { final PurchaseOrder? existingPo; @@ -23,16 +35,18 @@ class PurchaseOrderBuilderScreen extends ConsumerStatefulWidget { const PurchaseOrderBuilderScreen({super.key, this.existingPo}); @override - ConsumerState createState() => _PurchaseOrderBuilderScreenState(); + ConsumerState createState() => + _PurchaseOrderBuilderScreenState(); } -class _PurchaseOrderBuilderScreenState extends ConsumerState { +class _PurchaseOrderBuilderScreenState + extends ConsumerState { int? _selectedVendorId; DateTime _issueDate = DateTime.now(); DateTime? _dueDate; final TextEditingController _poNumberCtrl = TextEditingController(); final TextEditingController _notesCtrl = TextEditingController(); - + List _items = []; bool _isLoading = false; XFile? _invoiceFile; @@ -49,8 +63,25 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState _loadExistingItems(int id) async { + try { + final po = await ref.read(purchaseOrdersProvider.notifier).getPurchaseOrder(id); + if (mounted && po.items.isNotEmpty) { + setState(() { + _items = List.from(po.items); + }); + } + } catch (e) { + debugPrint('Error loading existing PO items: $e'); } } @@ -61,17 +92,25 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState sum + (item.quantity * item.unitPrice)); + return _items.fold( + 0, + (sum, item) => sum + (item.total), + ); } + double get _totalAmount { return _subtotal; } Future _pickInvoiceFile() async { final picker = ImagePicker(); - final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 80); + final picked = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); if (picked != null) { setState(() => _invoiceFile = picked); } @@ -79,11 +118,22 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState _savePo() async { if (_selectedVendorId == null) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a vendor'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Please select a vendor'))); return; } if (_items.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please add at least one item'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please add at least one item')), + ); + return; + } + final totalAmount = _items.fold(0.0, (sum, item) => sum + item.total); + if (totalAmount == 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Total amount cannot be zero')), + ); return; } @@ -93,38 +143,123 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState v.id == _selectedVendorId, orElse: () => Vendor(name: '')).stateId; + final isSameState = businessState != null && vendorState != null && businessState == vendorState; + + double totalSubtotal = 0; + double totalTax = 0; + double totalCgst = 0; + double totalSgst = 0; + double totalIgst = 0; + + final List processedItems = []; + + for (var item in _items) { + String? finalPhotoUrl = item.photoUrl; + if (item.localPhotoPath != null && item.photoUrl == null) { + try { + final itemFile = File(item.localPhotoPath!); + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(itemFile.path, filename: itemFile.path.split('/').last), + }); + final uploadResp = await DioClient().dio.post('/upload', data: formData); + if (uploadResp.statusCode == 200) { + finalPhotoUrl = uploadResp.data['url'] as String?; + } + } catch (e) { + debugPrint('Item photo upload failed: $e'); + } + } + + final product = products.firstWhere((p) => p.id == item.productId, orElse: () => products.first); + final gstRate = product.gstRate ?? 0.0; + final taxAmount = (item.total * gstRate) / 100.0; + + double cgst = 0; + double sgst = 0; + double igst = 0; + + double cgstRate = 0; + double sgstRate = 0; + double igstRate = 0; + + if (gstRate > 0) { + if (isSameState) { + cgstRate = gstRate / 2; + sgstRate = gstRate / 2; + cgst = taxAmount / 2; + sgst = taxAmount / 2; + } else { + igstRate = gstRate; + igst = taxAmount; + } + } + + totalSubtotal += item.total; + totalTax += taxAmount; + totalCgst += cgst; + totalSgst += sgst; + totalIgst += igst; + + processedItems.add(item.copyWith( + cgstRate: cgstRate, + sgstRate: sgstRate, + igstRate: igstRate, + taxRate: gstRate, + photoUrl: finalPhotoUrl, + )); + } + final po = PurchaseOrder( id: widget.existingPo?.id, vendorId: _selectedVendorId, poNumber: _poNumberCtrl.text, issueDate: _issueDate, dueDate: _dueDate, - subtotal: _subtotal, - totalAmount: _totalAmount, + subtotal: totalSubtotal, + taxTotal: totalTax, + cgstTotal: totalCgst, + sgstTotal: totalSgst, + igstTotal: totalIgst, + totalAmount: totalSubtotal + totalTax, notes: _notesCtrl.text, vendorInvoiceUrl: finalInvoiceUrl, - items: _items, + items: processedItems, ); + print('--- SAVING PO ---'); + print(po.toJson()); if (widget.existingPo == null) { await ref.read(purchaseOrdersProvider.notifier).createPurchaseOrder(po); } else { await ref.read(purchaseOrdersProvider.notifier).updatePurchaseOrder(po); } - + if (mounted) Navigator.pop(context); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error saving PO: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error saving PO: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); @@ -133,20 +268,24 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState _receivePo() async { if (widget.existingPo == null) return; - + // We update the items in the existing PO to what's currently in _items (user might have edited quantities) final po = widget.existingPo!.copyWith(items: _items); - + setState(() => _isLoading = true); try { await ref.read(purchaseOrdersProvider.notifier).markAsReceived(po); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('PO Received and Stock Updated'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PO Received and Stock Updated')), + ); Navigator.pop(context); } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error receiving PO: $e'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error receiving PO: $e'))); } } finally { if (mounted) setState(() => _isLoading = false); @@ -157,13 +296,24 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState _items.add(result)); + if (result != null && result is List) { + setState(() { + for (var newItem in result) { + final lastIndex = _items.lastIndexWhere((item) => item.productId == newItem.productId); + if (lastIndex != -1) { + _items.insert(lastIndex + 1, newItem); + } else { + _items.add(newItem); + } + } + }); } }); } @@ -174,44 +324,63 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState import_pay.PayVendorSheet(po: widget.existingPo!), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + builder: (_) => + import_pay.PayVendorSheet(po: widget.existingPo!), ).then((val) { if (val == true && mounted) { - Navigator.pop(context); // Close the builder screen to refresh the list + Navigator.pop( + context, + ); // Close the builder screen to refresh the list } }); }, icon: const Icon(LucideIcons.indianRupee, size: 18), label: const Text('Pay'), ), - if (widget.existingPo != null && widget.existingPo!.status == 'PENDING') + if (widget.existingPo != null && + widget.existingPo!.status == 'PENDING') TextButton.icon( onPressed: _receivePo, icon: const Icon(LucideIcons.packageCheck, size: 18), label: const Text('Receive'), ), - if (widget.existingPo == null || widget.existingPo!.status == 'PENDING') + if (widget.existingPo == null || + widget.existingPo!.status == 'PENDING') TextButton.icon( onPressed: _savePo, icon: const Icon(LucideIcons.save, size: 18), label: const Text('Save'), ), - ] + ], ], ), body: SingleChildScrollView( @@ -225,8 +394,17 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState v.id!).toList(), - itemAsString: (id) => vendors.firstWhere((v) => v.id == id).name, + itemAsString: (id) => + vendors.where((v) => v.id == id).firstOrNull?.name ?? 'Unknown', onChanged: (val) => setState(() => _selectedVendorId = val), + emptyActionText: '+ Quick Add Vendor', + onEmptyActionPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => const QuickAddVendorSheet(), + ); + }, ), loading: () => const CircularProgressIndicator(), error: (e, stack) => Text('Error: $e'), @@ -237,7 +415,7 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState _issueDate = d); }, child: InputDecorator( - decoration: const InputDecoration(labelText: 'Issue Date', border: OutlineInputBorder()), + decoration: const InputDecoration( + labelText: 'Issue Date', + ), child: Text(DateFormat('dd MMM yyyy').format(_issueDate)), ), ), @@ -264,7 +444,12 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState p.id == item.productId, - orElse: () => Product(name: 'Unknown') - ); - final productName = product?.name ?? 'Unknown'; + final products = productsState.value ?? []; + final categoriesState = ref.watch(productCategoriesProvider); + final categories = categoriesState.value ?? []; + final businessState = ref.watch(businessProfileProvider).value?.stateId; + final vendorState = _selectedVendorId == null ? null : vendorsState.value?.firstWhere((v) => v.id == _selectedVendorId, orElse: () => Vendor(name: '')).stateId; + + // Group items by productId + final Map>> groupedItems = {}; + for (int i = 0; i < _items.length; i++) { + final item = _items[i]; + if (item.productId != null) { + if (!groupedItems.containsKey(item.productId!)) { + groupedItems[item.productId!] = []; + } + groupedItems[item.productId!]!.add({'index': i, 'item': item}); + } + } - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(productName), - subtitle: Text('${item.quantity} x ₹${item.unitPrice}'), - trailing: Row( - mainAxisSize: MainAxisSize.min, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: groupedItems.entries.map((entry) { + final productId = entry.key; + final group = entry.value; + final product = products.firstWhere( + (p) => p.id == productId, + orElse: () => products.first, // fallback + ); + final productName = product.name; + final category = categories.firstWhere( + (c) => c.id == product.categoryId, + orElse: () => ProductCategory(name: ''), + ); + + double groupSubtotal = 0; + for (final g in group) { + groupSubtotal += (g['item'] as PurchaseOrderItem).total; + } + + final gstRate = product.gstRate ?? 0.0; + final taxAmount = (groupSubtotal * gstRate) / 100.0; + final finalTotal = groupSubtotal + taxAmount; + + final isSameState = businessState != null && vendorState != null && businessState == vendorState; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('₹${(item.quantity * item.unitPrice).toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)), - IconButton( - icon: const Icon(LucideIcons.trash, color: Colors.red), - onPressed: () => setState(() => _items.removeAt(index)), + Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Text( + productName, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ...group.map((g) { + final itemIndex = g['index'] as int; + final item = g['item'] as PurchaseOrderItem; + return _POItemRow( + item: item, + productName: productName, + product: product, + category: category, + businessStateId: businessState, + vendorStateId: vendorState, + onChanged: (updatedItem) { + setState(() { + _items = List.from(_items)..[itemIndex] = updatedItem; + }); + }, + onDelete: () => setState(() => _items.removeAt(itemIndex)), + ); + }).toList(), + Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Group Subtotal (${group.length} items):', style: const TextStyle(color: Colors.grey)), + Text('₹${groupSubtotal.toStringAsFixed(2)}'), + ], + ), + const SizedBox(height: 4), + if (gstRate > 0) + if (isSameState) + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('CGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: const TextStyle(color: Colors.grey)), + Text('+ ₹${(taxAmount / 2).toStringAsFixed(2)}'), + ], + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('SGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: const TextStyle(color: Colors.grey)), + Text('+ ₹${(taxAmount / 2).toStringAsFixed(2)}'), + ], + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('IGST (${gstRate.toStringAsFixed(1)}%):', + style: const TextStyle(color: Colors.grey)), + Text('+ ₹${taxAmount.toStringAsFixed(2)}'), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Group Total:', style: TextStyle(fontWeight: FontWeight.bold)), + Text('₹${finalTotal.toStringAsFixed(2)}', + style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), + ], + ), + ], + ), ), ], - ), - ), + ); + }).toList(), ); }, ), @@ -315,8 +615,40 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState groupSubtotals = {}; + for (final item in _items) { + if (item.productId != null) groupSubtotals[item.productId!] = (groupSubtotals[item.productId!] ?? 0) + item.total; + } + + for (final entry in groupSubtotals.entries) { + final productId = entry.key; + final sub = entry.value; + final product = products.firstWhere((p) => p.id == productId, orElse: () => products.first); + final gstRate = product.gstRate ?? 0.0; + final taxAmount = (sub * gstRate) / 100.0; + grandTotal += (sub + taxAmount); + } + + return Text( + '₹${grandTotal.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.green, + ), + ); + } + ), ], ), const SizedBox(height: 24), @@ -326,24 +658,54 @@ class _PurchaseOrderBuilderScreenState extends ConsumerState AttachmentGalleryScreen( + images: [FileImage(File(_invoiceFile!.path))], + initialIndex: 0, + ), + ), + ); + }, trailing: IconButton( icon: const Icon(LucideIcons.x), onPressed: () => setState(() => _invoiceFile = null), ), ) - else if (_invoiceUrl != null) + else if (_invoiceUrl != null && _invoiceUrl!.isNotEmpty) ListTile( - leading: const Icon(LucideIcons.link), + leading: const Icon(LucideIcons.image, color: Colors.blue), title: const Text('View Attached Invoice'), + subtitle: const Text('Tap to view full image', style: TextStyle(fontSize: 12, color: Colors.blue)), onTap: () { - // Open link (TBD) + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(_invoiceUrl!))], + initialIndex: 0, + ), + ), + ); }, + trailing: IconButton( + icon: const Icon(LucideIcons.x), + onPressed: () => setState(() => _invoiceUrl = null), + ), ) else OutlinedButton.icon( @@ -369,33 +731,41 @@ class _AddItemSheet extends ConsumerStatefulWidget { class _AddItemSheetState extends ConsumerState<_AddItemSheet> { int? _selectedProductId; final TextEditingController _qtyCtrl = TextEditingController(text: '1'); - final TextEditingController _priceCtrl = TextEditingController(); - void _save() { if (_selectedProductId == null) return; - final qty = double.tryParse(_qtyCtrl.text) ?? 1; - final price = double.tryParse(_priceCtrl.text) ?? 0; - - final item = PurchaseOrderItem( - productId: _selectedProductId, - quantity: qty, - unitPrice: price, - total: qty * price, - ); - Navigator.pop(context, item); + final qty = int.tryParse(_qtyCtrl.text) ?? 1; + + final List items = []; + for (int i = 0; i < qty; i++) { + items.add(PurchaseOrderItem( + productId: _selectedProductId, + quantity: 1.0, // Each row is 1 unit initially, or could represent pieces/weight + unitPrice: 0.0, + total: 0.0, + )); + } + Navigator.pop(context, items); } @override Widget build(BuildContext context) { final productsState = ref.watch(productsProvider); - + return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom, left: 24, right: 24, top: 24), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + left: 24, + right: 24, + top: 24, + ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Add PO Item', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const Text( + 'Add PO Item', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 16), productsState.when( data: (products) => SmartSearchDropdown( @@ -403,12 +773,10 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { hintText: 'Select Product', value: _selectedProductId, items: products.map((p) => p.id!).toList(), - itemAsString: (id) => products.firstWhere((p) => p.id == id).name, + itemAsString: (id) => products.where((p) => p.id == id).firstOrNull?.name ?? 'Unknown', onChanged: (val) { setState(() { _selectedProductId = val; - final p = products.firstWhere((prod) => prod.id == val); - _priceCtrl.text = p.sellingPrice?.toString() ?? '0'; }); }, ), @@ -416,24 +784,10 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { error: (e, stack) => Text('Error: $e'), ), const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: PremiumTextField( - controller: _qtyCtrl, - labelText: 'Quantity', - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 16), - Expanded( - child: PremiumTextField( - controller: _priceCtrl, - labelText: 'Unit Price', - keyboardType: TextInputType.number, - ), - ), - ], + PremiumTextField( + controller: _qtyCtrl, + labelText: 'Number of Items', + keyboardType: TextInputType.number, ), const SizedBox(height: 24), SizedBox( @@ -454,3 +808,304 @@ class _AddItemSheetState extends ConsumerState<_AddItemSheet> { ); } } + + +class _POItemRow extends StatefulWidget { + final PurchaseOrderItem item; + final String productName; + final Product? product; + final ProductCategory? category; + final int? businessStateId; + final int? vendorStateId; + final ValueChanged onChanged; + final VoidCallback onDelete; + + const _POItemRow({ + required this.item, + required this.productName, + this.product, + this.category, + this.businessStateId, + this.vendorStateId, + required this.onChanged, + required this.onDelete, + }); + + @override + State<_POItemRow> createState() => _POItemRowState(); +} + +class _POItemRowState extends State<_POItemRow> { + late TextEditingController _weightCtrl; + late TextEditingController _rateCtrl; + late TextEditingController _huidCtrl; + late TextEditingController _totalCtrl; + + @override + void initState() { + super.initState(); + _weightCtrl = TextEditingController(text: widget.item.weight?.toString() ?? ''); + _rateCtrl = TextEditingController(text: widget.item.unitPrice > 0 ? widget.item.unitPrice.toString() : ''); + _huidCtrl = TextEditingController(text: widget.item.huid ?? ''); + _totalCtrl = TextEditingController(text: widget.item.total > 0 ? widget.item.total.toString() : ''); + } + + @override + void dispose() { + _weightCtrl.dispose(); + _rateCtrl.dispose(); + _huidCtrl.dispose(); + _totalCtrl.dispose(); + super.dispose(); + } + + void _updateItem() { + final weight = double.tryParse(_weightCtrl.text); + final rate = double.tryParse(_rateCtrl.text) ?? 0.0; + + final total = (weight ?? 0.0) * rate; + if (_totalCtrl.text != total.toStringAsFixed(2)) { + _totalCtrl.text = total.toStringAsFixed(2); + } + + final updated = widget.item.copyWith( + weight: weight, + huid: _huidCtrl.text.isEmpty ? null : _huidCtrl.text, + unitPrice: rate, + total: total, + ); + widget.onChanged(updated); + } + + Future _pickPhoto() async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 70); + if (picked != null) { + final updated = widget.item.copyWith(localPhotoPath: picked.path); + widget.onChanged(updated); + } + } + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + GestureDetector( + onTap: () { + if (widget.item.localPhotoPath != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [FileImage(File(widget.item.localPhotoPath!))], + initialIndex: 0, + ), + ), + ); + } else if (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(widget.item.photoUrl!))], + initialIndex: 0, + ), + ), + ); + } else { + _pickPhoto(); + } + }, + onLongPress: _pickPhoto, + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Stack( + children: [ + Positioned.fill( + child: widget.item.localPhotoPath != null + ? ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.file(File(widget.item.localPhotoPath!), fit: BoxFit.cover), + ) + : widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network(_resolveImageUrl(widget.item.photoUrl!), fit: BoxFit.cover), + ) + : const Center(child: Icon(LucideIcons.camera, color: Colors.grey, size: 22)), + ), + if (widget.item.localPhotoPath != null || (widget.item.photoUrl != null && widget.item.photoUrl!.isNotEmpty)) + Positioned( + bottom: 2, + right: 2, + child: GestureDetector( + onTap: _pickPhoto, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(LucideIcons.camera, size: 10, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.productName, style: const TextStyle(fontWeight: FontWeight.bold)), + if (widget.category != null || (widget.product?.sku != null && widget.product!.sku!.isNotEmpty)) + const SizedBox(height: 4), + if (widget.category != null || (widget.product?.sku != null && widget.product!.sku!.isNotEmpty)) + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (widget.category != null && widget.category!.name.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.withValues(alpha: 0.3)), + ), + child: Text( + widget.category!.name, + style: TextStyle(fontSize: 10, color: Colors.blue[700]), + ), + ), + if (widget.product?.sku != null && widget.product!.sku!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + ), + child: Text( + 'SKU: ${widget.product!.sku}', + style: TextStyle(fontSize: 10, color: Colors.grey[700]), + ), + ), + ], + ), + ], + ), + ), + IconButton( + icon: const Icon(LucideIcons.trash, color: Colors.red), + onPressed: widget.onDelete, + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: PremiumTextField( + controller: _huidCtrl, + labelText: 'HUID', + textCapitalization: TextCapitalization.characters, + onChanged: (_) => _updateItem(), + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: PremiumTextField( + controller: _weightCtrl, + labelText: 'Weight/Pcs', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text(widget.category!.baseUnit!, style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: PremiumTextField( + controller: _rateCtrl, + labelText: 'Rate', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => _updateItem(), + suffixIcon: widget.category?.baseUnit != null && widget.category!.baseUnit!.isNotEmpty + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.1), + borderRadius: const BorderRadius.horizontal(right: Radius.circular(12)), + border: Border(left: BorderSide(color: Colors.grey.withValues(alpha: 0.3))), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [Text('per ${widget.category!.baseUnit}', style: TextStyle(color: Colors.grey[700]))], + ), + ) + : null, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 2, + child: PremiumTextField( + controller: _totalCtrl, + labelText: 'Purchase Amt', + keyboardType: TextInputType.number, + readOnly: true, + onChanged: (_) => _updateItem(), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildTaxAndTotal(), + ], + ), + ), + ); + } + + Widget _buildTaxAndTotal() { + return const SizedBox.shrink(); // Moved to group level + } +} diff --git a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart index ea02323..c5f6d12 100644 --- a/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/purchase_order_details_screen.dart @@ -8,13 +8,18 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; import '../../inventory/providers/products_provider.dart'; +import '../../inventory/providers/product_categories_provider.dart'; import '../../inventory/domain/product.dart'; import '../domain/purchase_order.dart'; +import '../domain/purchase_order_item.dart'; import '../domain/purchase_payment.dart'; import '../providers/purchase_orders_provider.dart'; import '../providers/vendors_provider.dart'; import '../../business/providers/business_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; +import '../../../core/network/dio_client.dart'; import 'pay_vendor_sheet.dart' as import_pay; class PurchaseOrderDetailsScreen extends ConsumerStatefulWidget { @@ -23,12 +28,55 @@ class PurchaseOrderDetailsScreen extends ConsumerStatefulWidget { const PurchaseOrderDetailsScreen({super.key, required this.po}); @override - ConsumerState createState() => _PurchaseOrderDetailsScreenState(); + ConsumerState createState() => + _PurchaseOrderDetailsScreenState(); } -class _PurchaseOrderDetailsScreenState extends ConsumerState { +class _PurchaseOrderDetailsScreenState + extends ConsumerState { + PurchaseOrder? _detailedPo; + bool _isLoading = true; - void _showPaymentSheet(BuildContext context, WidgetRef ref, PurchaseOrder latestPo) { + @override + void initState() { + super.initState(); + _fetchDetails(); + } + + Future _fetchDetails() async { + try { + final po = await ref + .read(purchaseOrdersProvider.notifier) + .getPurchaseOrder(widget.po.id!); + if (mounted) { + setState(() { + _detailedPo = po; + _isLoading = false; + }); + } + } catch (e) { + debugPrint('Failed to load detailed PO: $e'); + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + String _resolveImageUrl(String path) { + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + final baseUrl = DioClient().dio.options.baseUrl; + return '$baseUrl/upload/view?path=${Uri.encodeComponent(path)}'; + } + + void _showPaymentSheet( + BuildContext context, + WidgetRef ref, + PurchaseOrder latestPo, + ) { showModalBottomSheet( context: context, isScrollControlled: true, @@ -41,21 +89,35 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState>( - future: ref.read(purchaseOrdersProvider.notifier).fetchPaymentsForPurchaseOrder(po.id!), + future: ref + .read(purchaseOrdersProvider.notifier) + .fetchPaymentsForPurchaseOrder(po.id!), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); @@ -70,23 +132,56 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState Colors.grey.shade100), + headingRowColor: WidgetStateProperty.resolveWith( + (states) => Colors.grey.shade100, + ), columnSpacing: 24, columns: const [ - DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))), - DataColumn(label: Text('Method', style: TextStyle(fontWeight: FontWeight.bold))), - DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))), + DataColumn( + label: Text( + 'Date', + 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) { return DataRow( cells: [ - DataCell(Text(p.paymentDate != null ? DateFormat('dd MMM yyyy').format(p.paymentDate!) : '-')), + DataCell( + Text( + p.paymentDate != null + ? DateFormat( + 'dd MMM yyyy', + ).format(p.paymentDate!) + : '-', + ), + ), 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(), @@ -101,15 +196,27 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState _shareAsPdf(String poNumber, PurchaseOrder po, dynamic vendor, dynamic business, List products) async { + Future _shareAsPdf( + String poNumber, + PurchaseOrder po, + dynamic vendor, + dynamic business, + List products, + ) async { try { - final formatCurrency = NumberFormat.currency(symbol: '₹'); + final formatCurrency = NumberFormat.currency(symbol: 'Rs. '); final formatDate = DateFormat('dd MMM yyyy'); + final font = await PdfGoogleFonts.robotoRegular(); + final boldFont = await PdfGoogleFonts.robotoBold(); final pdf = pw.Document(); pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, + theme: pw.ThemeData.withFont( + base: font, + bold: boldFont, + ), margin: const pw.EdgeInsets.all(32), build: (pw.Context context) { return [ @@ -120,21 +227,63 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}')); + final product = products.firstWhere( + (p) => p.id == item.productId, + orElse: () => Product(name: 'Item ${item.productId}'), + ); final pName = product.name; return pw.TableRow( children: [ - pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(pName, style: const pw.TextStyle(fontSize: 10))), - pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.center, style: const pw.TextStyle(fontSize: 10))), - pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))), - pw.Padding(padding: const pw.EdgeInsets.all(8), child: pw.Text(formatCurrency.format(item.quantity * item.unitPrice), textAlign: pw.TextAlign.right, style: const pw.TextStyle(fontSize: 10))), + pw.Padding( + padding: const pw.EdgeInsets.all(8), + child: pw.Text( + pName, + style: const pw.TextStyle(fontSize: 10), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(8), + child: pw.Text( + item.quantity.toString(), + textAlign: pw.TextAlign.center, + style: const pw.TextStyle(fontSize: 10), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(8), + child: pw.Text( + formatCurrency.format(item.unitPrice), + textAlign: pw.TextAlign.right, + style: const pw.TextStyle(fontSize: 10), + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(8), + child: pw.Text( + formatCurrency.format( + item.quantity * item.unitPrice, + ), + textAlign: pw.TextAlign.right, + style: const pw.TextStyle(fontSize: 10), + ), + ), ], ); }), @@ -219,8 +495,19 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState products) { + void _showShareOptions( + BuildContext context, + PurchaseOrder po, + dynamic vendor, + dynamic business, + List products, + ) { showModalBottomSheet( context: context, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), builder: (ctx) { return SafeArea( child: Wrap( children: [ const Padding( padding: EdgeInsets.all(16), - child: Text('Share Purchase Order', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + child: Text( + 'Share Purchase Invoice', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), ), ListTile( leading: const Icon(LucideIcons.fileText, color: Colors.red), @@ -300,11 +639,19 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState p.id == widget.po.id, - orElse: () => widget.po, - ) ?? widget.po; + final latestPo = _detailedPo ?? + posState.value?.firstWhere( + (p) => p.id == widget.po.id, + orElse: () => widget.po, + ) ?? + widget.po; final vendorsState = ref.watch(vendorsProvider); final vendor = vendorsState.value?.firstWhere( @@ -314,19 +661,24 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState _showShareOptions(context, latestPo, vendor, business, products), + onPressed: () => _showShareOptions( + context, + latestPo, + vendor, + business, + products, + ), ), ], ), @@ -349,13 +707,20 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(latestPo.vendorInvoiceUrl!))], + initialIndex: 0, + ), + ), + ); + }, + child: Row( + children: [ + Container( + width: 54, + height: 54, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + _resolveImageUrl(latestPo.vendorInvoiceUrl!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.fileText, color: Colors.grey), + ), + ), + ), + Positioned( + bottom: 2, + right: 2, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(LucideIcons.maximize2, size: 10, color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Vendor Bill Document', style: TextStyle(fontWeight: FontWeight.w600)), + SizedBox(height: 2), + Text('Tap to view full image', style: TextStyle(fontSize: 12, color: Colors.blue)), + ], + ), + ), + const Icon(LucideIcons.chevronRight, color: Colors.grey), + ], + ), + ), + ], + ), ), - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: latestPo.items.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = latestPo.items[index]; - final product = products.firstWhere((p) => p.id == item.productId, orElse: () => Product(name: 'Item ${item.productId}')); - final pName = product.name; - return Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + ], + + const SizedBox(height: 20), + + // Items List + const Text( + 'ITEMS', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.grey, + letterSpacing: 1, + ), + ), + const SizedBox(height: 12), + Builder(builder: (context) { + final Map> groupedItems = {}; + for (final item in latestPo.items) { + if (item.productId != null) { + groupedItems.putIfAbsent(item.productId!, () => []).add(item); + } + } + final businessStateId = business?.stateId; + final vendorStateId = vendor?.stateId; + final isSameState = businessStateId != null && vendorStateId != null && businessStateId == vendorStateId; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: groupedItems.entries.map((entry) { + final productId = entry.key; + final group = entry.value; + final product = products.firstWhere( + (p) => p.id == productId, + orElse: () => Product(name: 'Item $productId'), + ); + final productName = product.name; + final category = categories.firstWhere( + (c) => c.id == product.categoryId, + orElse: () => ProductCategory(name: 'Unknown Category'), + ); + + double groupSubtotal = 0; + for (final item in group) { + groupSubtotal += item.total; + } + + final gstRate = product.gstRate ?? 0.0; + final taxAmount = (groupSubtotal * gstRate) / 100.0; + final finalTotal = groupSubtotal + taxAmount; + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + productName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ), + const Divider(height: 1), + ...group.map((item) { + final hasPhoto = (item.photoUrl != null && item.photoUrl!.isNotEmpty); + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasPhoto) ...[ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => AttachmentGalleryScreen( + images: [NetworkImage(_resolveImageUrl(item.photoUrl!))], + initialIndex: 0, + ), + ), + ); + }, + child: Container( + width: 52, + height: 52, + margin: const EdgeInsets.only(right: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + color: Colors.grey.shade100, + ), + child: Stack( + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(9), + child: Image.network( + _resolveImageUrl(item.photoUrl!), + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + const Icon(LucideIcons.imageOff, color: Colors.grey, size: 24), + ), + ), + ), + Positioned( + bottom: 2, + right: 2, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(LucideIcons.maximize2, size: 10, color: Colors.white), + ), + ), + ], + ), + ), + ), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${item.weight != null && item.weight! > 0 ? '${item.weight} g' : '${item.quantity} pcs'} x ${formatCurrency.format(item.unitPrice)}', + style: TextStyle(color: Colors.grey.shade800, fontWeight: FontWeight.w600), + ), + Text( + formatCurrency.format(item.total), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (item.huid != null && item.huid!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.shade200), + ), + child: Text('HUID: ${item.huid}', style: TextStyle(fontSize: 12, color: Colors.blue.shade700, fontWeight: FontWeight.w600)), + ), + if (product.hsnCode != null && product.hsnCode!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.purple.shade200), + ), + child: Text('HSN: ${product.hsnCode}', style: TextStyle(fontSize: 12, color: Colors.purple.shade700, fontWeight: FontWeight.w600)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.shade200), + ), + child: Text(category.name, style: TextStyle(fontSize: 12, color: Colors.orange.shade700, fontWeight: FontWeight.w600)), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }).toList(), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50.withOpacity(0.5), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(pName, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Group Subtotal (${group.length} items):', style: TextStyle(color: Colors.grey.shade700)), + Text(formatCurrency.format(groupSubtotal)), + ], + ), const SizedBox(height: 4), - Text('${item.quantity} x ${formatCurrency.format(item.unitPrice)}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)), + if (gstRate > 0) + if (isSameState) + Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('CGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: TextStyle(color: Colors.grey.shade700)), + Text('+ ${formatCurrency.format(taxAmount / 2)}'), + ], + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('SGST (${(gstRate / 2).toStringAsFixed(1)}%):', style: TextStyle(color: Colors.grey.shade700)), + Text('+ ${formatCurrency.format(taxAmount / 2)}'), + ], + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('IGST (${gstRate.toStringAsFixed(1)}%):', style: TextStyle(color: Colors.grey.shade700)), + Text('+ ${formatCurrency.format(taxAmount)}'), + ], + ), + const Divider(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Group Total:', style: TextStyle(fontWeight: FontWeight.bold)), + Text(formatCurrency.format(finalTotal), style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.blue)), + ], + ), ], ), ), - Text(formatCurrency.format(item.quantity * item.unitPrice), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), ], ), ); - }, - ), - ), - + }).toList(), + ); + }), + const SizedBox(height: 20), - + // Summary Card Container( padding: const EdgeInsets.all(20), @@ -498,10 +1226,45 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState 0) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tax Amount', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 15, + ), + ), + Text( + formatCurrency.format(latestPo.taxTotal), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ], + ), + ), const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Divider(height: 1), @@ -509,8 +1272,22 @@ class _PurchaseOrderDetailsScreenState extends ConsumerState createState() => _PurchaseOrdersListScreenState(); + ConsumerState createState() => + _PurchaseOrdersListScreenState(); } -class _PurchaseOrdersListScreenState extends ConsumerState { +class _PurchaseOrdersListScreenState + extends ConsumerState { String _searchQuery = ''; final TextEditingController _searchController = TextEditingController(); @@ -35,9 +37,12 @@ class _PurchaseOrdersListScreenState extends ConsumerState v.id == po.vendorId, orElse: () => po.vendor!); + final vendor = vendors.firstWhere( + (v) => v.id == po.vendorId, + orElse: () => po.vendor!, + ); - return Card( - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: InkWell( - onTap: () { - if (po.status == 'PENDING') { - Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderBuilderScreen(existingPo: po))); - } else { - Navigator.push(context, MaterialPageRoute(builder: (_) => PurchaseOrderDetailsScreen(po: po))); - } - }, - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - po.poNumber, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () { + if (po.status == 'PENDING') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PurchaseOrderBuilderScreen( + existingPo: po, ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: _getStatusColor(po).withOpacity(0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _getStatusLabel(po), - style: TextStyle( - color: _getStatusColor(po), - fontSize: 12, + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + PurchaseOrderDetailsScreen(po: po), + ), + ); + } + }, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + po.poNumber, + style: const TextStyle( fontWeight: FontWeight.bold, + fontSize: 16, ), ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - const Icon(LucideIcons.user, size: 16, color: Colors.grey), - const SizedBox(width: 8), - Expanded( - child: Text( - vendor.name, - style: const TextStyle(fontWeight: FontWeight.w500), - maxLines: 1, - overflow: TextOverflow.ellipsis, + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: _getStatusColor( + po, + ).withOpacity(0.1), + borderRadius: BorderRadius.circular( + 20, + ), + ), + child: Text( + _getStatusLabel(po), + style: TextStyle( + color: _getStatusColor(po), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), ), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - const Icon(LucideIcons.calendar, size: 16, color: Colors.grey), - const SizedBox(width: 8), - Text( - DateFormat('MMM dd, yyyy').format(po.issueDate), - style: const TextStyle(color: Colors.grey, fontSize: 14), - ), - const Spacer(), - Text( - '₹${po.totalAmount.toStringAsFixed(2)}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + ], + ), + const SizedBox(height: 12), + Row( + children: [ + const Icon( + LucideIcons.user, + size: 16, + color: Colors.grey, ), - ), - ], - ), - ], + const SizedBox(width: 8), + Expanded( + child: Text( + vendor.name, + style: const TextStyle( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + LucideIcons.calendar, + size: 16, + color: Colors.grey, + ), + const SizedBox(width: 8), + Text( + DateFormat( + 'MMM dd, yyyy', + ).format(po.issueDate), + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + const Spacer(), + Text( + '₹${po.totalAmount.toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ], + ), ), ), ), @@ -200,7 +281,12 @@ class _PurchaseOrdersListScreenState extends ConsumerState const PurchaseOrderBuilderScreen())); + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PurchaseOrderBuilderScreen(), + ), + ); }, icon: const Icon(LucideIcons.plus), label: const Text('New PO'), diff --git a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart index 3a2238c..fd40bd6 100644 --- a/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart +++ b/kifi-app/lib/features/vendor/presentation/vendors_list_screen.dart @@ -22,7 +22,10 @@ class _VendorsListScreenState extends ConsumerState { return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( - title: const Text('Vendors', style: TextStyle(fontWeight: FontWeight.bold)), + title: const Text( + 'Vendors', + style: TextStyle(fontWeight: FontWeight.bold), + ), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, @@ -37,12 +40,6 @@ class _VendorsListScreenState extends ConsumerState { decoration: InputDecoration( hintText: 'Search vendors...', 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), ), onChanged: (val) => setState(() => _searchQuery = val), @@ -54,11 +51,23 @@ class _VendorsListScreenState extends ConsumerState { final filtered = vendors.where((v) { final search = _searchQuery.toLowerCase(); return v.name.toLowerCase().contains(search) || - (v.phone != null && v.phone!.contains(search)); + (v.phone != null && v.phone!.contains(search)); }).toList(); if (filtered.isEmpty) { - return const Center(child: Text('No vendors found', style: TextStyle(color: Colors.grey))); + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.users, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No vendors found.', + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + ], + ), + ); } return ListView.builder( @@ -66,23 +75,87 @@ class _VendorsListScreenState extends ConsumerState { itemCount: filtered.length, itemBuilder: (context, index) { final vendor = filtered[index]; - return Card( - margin: const EdgeInsets.only(bottom: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - leading: CircleAvatar( - backgroundColor: Colors.blue.withOpacity(0.1), - child: vendor.photoUrl != null - ? ClipOval(child: Image.network(vendor.photoUrl!, width: 40, height: 40, fit: BoxFit.cover)) - : const Icon(LucideIcons.truck, color: Colors.blue), + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => AddVendorSheet(vendor: vendor), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: Colors.orange.withOpacity( + 0.1, + ), + child: vendor.photoUrl != null + ? ClipOval( + child: Image.network( + vendor.photoUrl!, + width: 48, + height: 48, + fit: BoxFit.cover, + ), + ) + : const Icon( + LucideIcons.truck, + color: Colors.orange, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + vendor.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + vendor.phone ?? 'No phone number', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + const Icon( + LucideIcons.chevronRight, + color: Colors.grey, + ), + ], + ), + ), ), - title: Text(vendor.name, style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(vendor.phone ?? 'No phone number'), - trailing: const Icon(LucideIcons.chevronRight, color: Colors.grey), - onTap: () { - // View vendor details (TBD) - }, ), ); }, diff --git a/kifi-app/lib/features/vendor/presentation/widgets/quick_add_vendor_sheet.dart b/kifi-app/lib/features/vendor/presentation/widgets/quick_add_vendor_sheet.dart new file mode 100644 index 0000000..c1edb8e --- /dev/null +++ b/kifi-app/lib/features/vendor/presentation/widgets/quick_add_vendor_sheet.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/widgets/premium_text_field.dart'; +import '../../../../core/widgets/smart_search_dropdown.dart'; +import '../../providers/vendors_provider.dart'; +import '../../domain/vendor.dart'; +import '../../../business/providers/indian_states_provider.dart'; + +class QuickAddVendorSheet extends ConsumerStatefulWidget { + const QuickAddVendorSheet({super.key}); + + @override + ConsumerState createState() => _QuickAddVendorSheetState(); +} + +class _QuickAddVendorSheetState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + int? _selectedStateId; + bool _isLoading = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _addressCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + try { + final vendor = Vendor( + name: _nameCtrl.text, + address: _addressCtrl.text.isEmpty ? null : _addressCtrl.text, + stateId: _selectedStateId, + ); + + await ref.read(vendorsProvider.notifier).addVendor(vendor); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + children: [ + IconButton( + icon: const Icon(LucideIcons.chevronLeft), + onPressed: () => Navigator.pop(context), + ), + const Expanded( + child: Text( + 'Quick Add Vendor', + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + const Divider(), + Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PremiumTextField( + controller: _nameCtrl, + labelText: 'Vendor Name *', + prefixIcon: const Icon(LucideIcons.building), + textCapitalization: TextCapitalization.words, + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, ref, _) { + final statesState = ref.watch(indianStatesProvider); + return statesState.when( + data: (states) => SmartSearchDropdown( + labelText: 'State (Optional)', + hintText: 'Select State', + value: _selectedStateId, + items: states.map((s) => s.id).toList(), + itemAsString: (id) { + final s = states.where((st) => st.id == id).firstOrNull; + if (s == null) return 'Unknown'; + return '${s.name} (${s.gstCode})'; + }, + onChanged: (val) => setState(() => _selectedStateId = val), + ), + loading: () => const CircularProgressIndicator(), + error: (e, _) => Text('Error: $e'), + ); + }, + ), + const SizedBox(height: 16), + PremiumTextField( + controller: _addressCtrl, + labelText: 'Address (Optional)', + prefixIcon: const Icon(LucideIcons.mapPin), + maxLines: 2, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _isLoading ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isLoading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('Save Vendor', style: TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart index c7ea474..b3c408f 100644 --- a/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart +++ b/kifi-app/lib/features/vendor/providers/purchase_orders_provider.dart @@ -19,7 +19,7 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { } return []; } catch (e) { - throw Exception('Failed to load purchase orders: $e'); + throw Exception('Failed to load purchase invoices: $e'); } } @@ -29,9 +29,9 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { if (response.statusCode == 200) { return PurchaseOrder.fromJson(response.data); } - throw Exception('Purchase order not found'); + throw Exception('Purchase invoice not found'); } catch (e) { - throw Exception('Failed to load purchase order: $e'); + throw Exception('Failed to load purchase invoice: $e'); } } @@ -47,9 +47,9 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { state = AsyncValue.data([newPo, ...current]); return newPo; } - throw Exception('Failed to create purchase order'); + throw Exception('Failed to create purchase invoice'); } catch (e) { - throw Exception('Error creating purchase order: $e'); + throw Exception('Error creating purchase invoice: $e'); } } @@ -67,9 +67,9 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { ); return updatedPo; } - throw Exception('Failed to update purchase order'); + throw Exception('Failed to update purchase invoice'); } catch (e) { - throw Exception('Error updating purchase order: $e'); + throw Exception('Error updating purchase invoice: $e'); } } @@ -87,9 +87,9 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { ); return updatedPo; } - throw Exception('Failed to receive purchase order'); + throw Exception('Failed to receive purchase invoice'); } catch (e) { - throw Exception('Error receiving purchase order: $e'); + throw Exception('Error receiving purchase invoice: $e'); } } @@ -112,7 +112,9 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { Future> fetchPaymentsForPurchaseOrder(int poId) async { try { - final response = await DioClient().dio.get('/purchase-orders/$poId/payments'); + final response = await DioClient().dio.get( + '/purchase-orders/$poId/payments', + ); if (response.statusCode == 200) { final List data = response.data; return data.map((json) => PurchasePayment.fromJson(json)).toList(); @@ -124,6 +126,7 @@ class PurchaseOrdersNotifier extends AsyncNotifier> { } } -final purchaseOrdersProvider = AsyncNotifierProvider>(() { - return PurchaseOrdersNotifier(); -}); +final purchaseOrdersProvider = + AsyncNotifierProvider>(() { + return PurchaseOrdersNotifier(); + }); diff --git a/kifi-app/lib/features/vendor/providers/vendors_provider.dart b/kifi-app/lib/features/vendor/providers/vendors_provider.dart index 47fb3f1..5d4a23c 100644 --- a/kifi-app/lib/features/vendor/providers/vendors_provider.dart +++ b/kifi-app/lib/features/vendor/providers/vendors_provider.dart @@ -62,7 +62,9 @@ class VendorsNotifier extends AsyncNotifier> { final updatedVendor = Vendor.fromJson(response.data); final current = state.value ?? []; state = AsyncValue.data( - current.map((c) => c.id == updatedVendor.id ? updatedVendor : c).toList(), + current + .map((c) => c.id == updatedVendor.id ? updatedVendor : c) + .toList(), ); return updatedVendor; } @@ -83,6 +85,8 @@ class VendorsNotifier extends AsyncNotifier> { } } -final vendorsProvider = AsyncNotifierProvider>(() { - return VendorsNotifier(); -}); +final vendorsProvider = AsyncNotifierProvider>( + () { + return VendorsNotifier(); + }, +); diff --git a/kifi-app/lib/test_dio.dart b/kifi-app/lib/test_dio.dart new file mode 100644 index 0000000..4903588 --- /dev/null +++ b/kifi-app/lib/test_dio.dart @@ -0,0 +1,6 @@ +import 'package:dio/dio.dart'; +void main() { + final dio = Dio(BaseOptions(baseUrl: 'https://app.technobeesolutions.in/api/kifi-v2')); + final req = RequestOptions(path: '/account/setup/status', baseUrl: dio.options.baseUrl); + print(req.uri); +} diff --git a/kifi-app/macos/Podfile.lock b/kifi-app/macos/Podfile.lock index c147388..de444c5 100644 --- a/kifi-app/macos/Podfile.lock +++ b/kifi-app/macos/Podfile.lock @@ -1,4 +1,7 @@ PODS: + - file_picker_darwin (1.0.0): + - Flutter + - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - flutter_image_compress_macos (1.0.0): @@ -12,23 +15,36 @@ PODS: - local_auth_darwin (0.0.1): - Flutter - FlutterMacOS + - mobile_scanner (7.0.0): + - Flutter + - FlutterMacOS + - printing (1.0.0): + - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS DEPENDENCIES: + - file_picker_darwin (from `Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_image_compress_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_image_compress_macos/macos`) - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - FlutterMacOS (from `Flutter/ephemeral`) - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin`) + - printing (from `Flutter/ephemeral/.symlinks/plugins/printing/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: + file_picker_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_image_compress_macos: @@ -41,20 +57,30 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral local_auth_darwin: :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + mobile_scanner: + :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/darwin + printing: + :path: Flutter/ephemeral/.symlinks/plugins/printing/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + file_picker_darwin: 65c8ed1d70cc2ea0b81ae1840db93f8d9c051d19 file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_image_compress_macos: 525b2993bb7d77af94af6ccf1f5848c6a111de02 flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb + mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + printing: c4cf83c78fd684f9bc318e6aadc18972aa48f617 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/kifi-app/update_add_product.py b/kifi-app/update_add_product.py new file mode 100644 index 0000000..6fb330b --- /dev/null +++ b/kifi-app/update_add_product.py @@ -0,0 +1,477 @@ +import os + +new_content = """import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; +import '../../../core/network/dio_client.dart'; +import '../../../../core/widgets/smart_search_dropdown.dart'; +import '../domain/product.dart'; +import '../providers/products_provider.dart'; +import '../providers/product_categories_provider.dart'; +import '../../business/providers/business_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; + +class AddProductScreen extends ConsumerStatefulWidget { + final Product? product; + const AddProductScreen({super.key, this.product}); + + @override + ConsumerState createState() => _AddProductScreenState(); +} + +class _AddProductScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _hsnController = TextEditingController(); + final _gstController = TextEditingController(); + + // Basic + String _name = ''; + String _sku = ''; + ProductCategory? _selectedCategory; + int? _uomId; + + // Properties + String _color = ''; + + // Pricing & Inventory + double _purchasePrice = 0; + double _sellingPrice = 0; + + // Media + final List _images = []; + final List _existingImageIds = []; + bool _isSaving = false; + String? _token; + + @override + void initState() { + super.initState(); + import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { + if (mounted) setState(() => _token = val); + }); + + if (widget.product != null) { + final p = widget.product!; + _name = p.name; + _sku = p.sku ?? ''; + _hsnController.text = p.hsnCode ?? ''; + _uomId = p.uomId; + _color = p.color ?? ''; + _gstController.text = p.gstRate != null && p.gstRate! > 0 ? p.gstRate.toString() : ''; + _sellingPrice = p.sellingPrice ?? 0.0; + + if (p.imageIds.isNotEmpty) { + _existingImageIds.addAll(p.imageIds); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + final cats = ref.read(productCategoriesProvider).value ?? []; + if (cats.isNotEmpty) { + setState(() { + _selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first); + }); + } + }); + } + } + + @override + void dispose() { + _hsnController.dispose(); + _gstController.dispose(); + super.dispose(); + } + + Future _pickImages() async { + if ((_images.length + _existingImageIds.length) >= 4) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed'))); + return; + } + final ImagePicker picker = ImagePicker(); + final List picked = await picker.pickMultiImage(); + if (picked.isNotEmpty) { + setState(() { + _images.addAll(picked.take(4 - (_images.length + _existingImageIds.length))); + }); + } + } + + void _removeImage(int index) { + setState(() { + _images.removeAt(index); + }); + } + + void _previewImage(int index) { + List allImages = []; + for (final imageId in _existingImageIds) { + if (_token != null) { + allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'})); + } else { + allImages.add(const AssetImage('assets/images/placeholder.png')); + } + } + for (final file in _images) { + allImages.add(FileImage(File(file.path))); + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentGalleryScreen( + images: allImages, + initialIndex: index, + ), + ), + ); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + + if (_selectedCategory == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category'))); + return; + } + + setState(() => _isSaving = true); + try { + final product = Product( + id: widget.product?.id, + name: _name, + hsnCode: _hsnController.text.isNotEmpty ? _hsnController.text : null, + sku: _sku.isNotEmpty ? _sku : null, + categoryId: _selectedCategory?.id, + uomId: _uomId, + color: _color.isNotEmpty ? _color : null, + sellingPrice: _sellingPrice, + gstRate: double.tryParse(_gstController.text) ?? 0, + priceCalcRule: 'MANUAL', + autoCalculatePrice: false, + ); + + if (widget.product != null) { + await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images); + } else { + await ref.read(productsProvider.notifier).createProduct(product, images: _images); + } + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final categoriesState = ref.watch(productCategoriesProvider); + final taxInclusive = ref.watch(businessProfileProvider).value?.taxIncludedInPrice ?? false; + + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + appBar: AppBar( + title: Text(widget.product != null ? 'Edit Product' : 'New Product', style: const TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Column( + children: [ + Expanded( + child: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Basic Information', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + + _buildPremiumTextField( + label: 'Product Name*', + initialValue: _name, + 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( + hintText: 'Select Category*', + value: _selectedCategory, + items: leafCategories, + itemAsString: (c) => c.name, + itemBuilder: (context, item) { + List 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( + 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), + _buildPremiumTextField( + label: taxInclusive ? 'Selling Price (Inc. Tax)*' : 'Selling Price (Exc. Tax)*', + initialValue: _sellingPrice == 0 ? '' : _sellingPrice.toString(), + prefixText: '₹ ', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (val) => _sellingPrice = double.tryParse(val) ?? 0, + ), + 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)), + ], + ), + ), + ), + ] + ], + ), + ), + ), + ), + _buildBottomBar(), + ], + ), + ), + ); + } + + Widget _buildBottomBar() { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isSaving ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + elevation: 4, + ), + 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)), + ), + ), + ), + ); + } + + Widget _buildImageThumbnail({required bool isNetwork, String? url, File? file, required VoidCallback onDelete, required VoidCallback onTap}) { + return Stack( + fit: StackFit.expand, + children: [ + GestureDetector( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Container( + color: Colors.grey[200], + child: isNetwork + ? (_token == null + ? const Icon(LucideIcons.image, color: Colors.grey) + : Image.network( + url!, + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $_token'}, + errorBuilder: (ctx, err, stack) => const Icon(LucideIcons.imageOff, color: Colors.grey), + )) + : Image.file(file!, fit: BoxFit.cover), + ), + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: onDelete, + child: Container( + decoration: BoxDecoration(color: Colors.black.withOpacity(0.6), shape: BoxShape.circle), + padding: const EdgeInsets.all(6), + child: const Icon(LucideIcons.x, color: Colors.white, size: 16), + ), + ), + ) + ], + ); + } + + Widget _buildPremiumTextField({ + required String label, + String? initialValue, + String? prefixText, + String? suffixText, + TextInputType? keyboardType, + void Function(String)? onChanged, + void Function(String?)? onSaved, + String? Function(String?)? validator, + }) { + return TextFormField( + initialValue: initialValue, + decoration: InputDecoration( + labelText: label, + prefixText: prefixText, + suffixText: suffixText, + ), + keyboardType: keyboardType, + onChanged: onChanged, + onSaved: onSaved, + validator: validator, + ); + } +} +""" + +with open('lib/features/inventory/presentation/add_product_screen.dart', 'w') as f: + f.write(new_content) diff --git a/prompt.md b/prompt.md new file mode 100644 index 0000000..acc8736 --- /dev/null +++ b/prompt.md @@ -0,0 +1,1373 @@ +You are working on the existing Kifi application. + +Use the senior architect, Flutter UI/UX, Spring Boot/R2DBC, PostgreSQL/DBA, accounting/finance, AI and Indian jewellery-domain rules defined in the active skill. + +Keep responses short and token-efficient. + +After each logical checkpoint, report: + +```text +Progress: XX% +``` + +Do not repeat requirements already understood. + +## Objective + +When account setup is completed with: + +```text +Account Type = BUSINESS +Nature of Business = JEWELLERY +``` + +the application must operate in **Jewellery Business Mode**. + +This mode must provide a robust jewellery retail/inventory/accounting system suitable for Indian jewellery businesses. + +This specification is directional, not exhaustive. + +Do not blindly implement only the fields listed here. + +Use your jewellery, accounting, GST, inventory and ERP expertise to identify missing requirements, correct flawed assumptions and build the smallest robust production-grade architecture. + +Before changing anything: + +1. Inspect existing jewellery/inventory modules. +2. Inspect current category/product/purchase/customer/invoice flows. +3. Inspect existing double-entry accounting implementation. +4. Inspect current ledger/chart-of-accounts design. +5. Inspect daily metal-price synchronization. +6. Inspect barcode/SKU/EAN logic. +7. Inspect existing settings/configuration. +8. Inspect reports and dashboard statistics. +9. Reuse existing architecture where sound. +10. Refactor flawed areas instead of creating duplicate systems. + +--- + +# 1. Jewellery Business Mode + +When: + +```text +BUSINESS + JEWELLERY +``` + +is selected during setup: + +* Persist this business nature in backend. +* Load Jewellery-specific navigation/modules after login. +* Do not rely only on Flutter state. +* Backend remains source of truth. + +Jewellery mode should expose relevant modules such as: + +```text +Dashboard +Inventory +Categories +Products +Purchases +Vendors +Customers +Sales / Invoices +Reports +Configuration +``` + +Adjust based on existing architecture. + +--- + +# 2. Category Hierarchy + +Support hierarchical jewellery categories up to 3 nested levels. + +Example: + +```text +Gold Jewellery + ↓ +Rings + ↓ +Ladies Rings +``` + +or: + +```text +Diamond Jewellery + ↓ +Necklace + ↓ +Bridal Necklace +``` + +Each category should support: + +* Name +* Parent category +* Active/inactive +* Sort order if useful +* `hasChild` +* Default HSN code +* Default GST rate +* Calculation unit/type +* Default rate-calculation behaviour +* Other domain-relevant configuration + +## Leaf Category Rule + +Configuration intended for actual saleable products should only be collected on the **leaf category**. + +If: + +```text +hasChild = true +``` + +do not require product-level defaults such as: + +* HSN +* GST +* calculation unit +* auto-rate calculation +* other leaf-only product rules + +Those should be configured at the last category level where products are actually created. + +Do not duplicate inherited configuration unnecessarily. + +Consider inheritance/fallback where appropriate. + +--- + +# 3. Calculation Unit + +Support jewellery-specific calculation modes. + +Examples: + +```text +Quantity × Gram +Quantity × Piece +Weight-based +Piece-based +Fixed +``` + +Do not hardcode only these values if the domain requires more. + +Design calculation units as controlled/configurable values. + +Examples of later use: + +```text +Gold jewellery → weight-based +Loose items → piece-based +Accessories → quantity-based +``` + +--- + +# 4. Rate Auto Calculation + +Leaf categories/products should support: + +```text +Auto Calculate Rate = ON/OFF +``` + +When enabled, selling value may be derived from: + +```text +Current metal rate +× purity +× applicable net/fine weight ++ making charges ++ stone/diamond value ++ certification/other charges +``` + +Do not overwrite original purchase cost. + +Current market rate and purchase cost are different concepts. + +Keep pricing rules configurable. + +--- + +# 5. Product / Inventory Master + +Refactor Inventory/Product Master so creating a product definition produces a: + +```text +product_id +``` + +Product master should contain attributes such as: + +* Product name +* Category +* Subcategory hierarchy +* Photo +* SKU +* EAN where applicable +* Barcode configuration +* Metal/type +* Default purity if appropriate +* Default HSN +* Default GST +* Default making charges +* Making-charge calculation type +* Default certification charge +* HUID required toggle +* Rate auto-calculation toggle +* Calculation unit +* Active/inactive +* Other useful jewellery attributes + +Making-charge calculation types must support at least: + +```text +PERCENTAGE +FLAT +PER_GRAM +``` + +Use stable backend enum values and user-friendly labels. + +Do not confuse: + +```text +Product Master +``` + +with: + +```text +Physical Inventory Item / Tag +``` + +A product represents the design/type. + +Purchases populate actual stock instances under that product. + +--- + +# 6. Inventory Item / Tag Level + +Each purchased physical jewellery item should create a unique inventory record when required. + +Track relevant attributes such as: + +```text +product_id +tag/barcode +SKU +HUID +purity +gross weight +net weight +stone weight +diamond weight +fine weight +making charges +making charge type +purchase cost +metal cost +stone cost +certification cost +tax +branch/location +vendor +purchase reference +status +``` + +Apply jewellery-domain rules. + +Do not store only aggregate quantity if uniquely identifiable pieces require tag-level traceability. + +Support statuses such as: + +```text +AVAILABLE +RESERVED +SOLD +TRANSFERRED +RETURNED +REPAIR +MELTED +DAMAGED +``` + +as appropriate. + +--- + +# 7. Vendor Module + +Vendor form already exists. + +Inspect it first. + +Add only missing fields needed for: + +* Jewellery purchasing +* GST +* Payments +* Accounts payable +* Double-entry accounting +* Vendor ledger +* Purchase returns + +Potential information may include: + +```text +Business name +Contact person +Mobile +Email +Address +State +GSTIN +PAN +Payment terms +Opening balance +Credit limit +Bank details if needed +Ledger/account mapping +``` + +Do not add unnecessary fields. + +Sensitive identifiers must follow existing PII encryption/masking rules. + +Each vendor must be able to participate in accounting entries. + +--- + +# 8. Purchases + +Existing Purchases module must be inspected and refactored. + +A purchase should support: + +## Header + +* Vendor +* Vendor invoice number +* Invoice date +* Purchase date/business date if different +* GST treatment +* Place/state of supply where required +* Discounts +* Shipping/freight charges +* Other charges +* Tax +* Round-off +* Payment status +* Payment mode if immediate +* Credit purchase +* Notes +* Attachments if existing architecture supports them + +## Purchase Items + +User selects: + +```text +Product +``` + +and adds one or more physical inventory items. + +For each item capture relevant values including: + +* Purity +* Gross weight +* Net weight +* Stone weight where applicable +* Fine weight if applicable +* Quantity/pieces +* Making charges +* Making-charge calculation type +* Certification charges +* HSN +* GST rate +* HUID when required +* Stone/diamond values where applicable +* Rate +* Discount where appropriate +* Other jewellery-specific costing components + +Defaults may inherit from product/category but must remain editable where business rules allow. + +If: + +```text +HUID Required = true +``` + +HUID must be validated before the item can be finalized, unless a legitimate workflow requires temporary pending status. + +--- + +# 9. Multi-Product Purchase Invoice + +One vendor purchase invoice may contain multiple products. + +Flow: + +```text +Select Vendor + ↓ +Select Product A + ↓ +Add one/multiple inventory items + ↓ +Select Product B + ↓ +Add items + ↓ +... + ↓ +Review + ↓ +Post Purchase +``` + +Do not require separate purchase invoices for each product. + +--- + +# 10. Immutable Purchase Cost + +The original purchase cost is critical. + +Never overwrite historical purchase cost because: + +* Metal rates change +* Making charges change +* Selling rate changes +* Product defaults change + +Preserve immutable purchase-cost components needed for: + +```text +COGS +Gross profit +Net profit +Margin analysis +Stock valuation +Audit +``` + +If corrections are required, use controlled edit/reversal/adjustment rules with audit history. + +--- + +# 11. Purchase Accounting + +Every posted purchase must generate correct accounting entries. + +Depending on GST/payment mode, conceptually: + +```text +Dr Inventory Asset +Dr Input GST +Dr Freight/Eligible Cost if applicable +Cr Vendor Payable / Cash / Bank +``` + +Actual journal structure must follow the project's chart of accounts and accounting configuration. + +Do not hardcode accounting account IDs. + +Use configurable accounting mappings. + +Purchase posting must be transactionally consistent with inventory creation. + +If accounting fails: + +```text +Purchase + Inventory +``` + +must not remain partially posted. + +--- + +# 12. Inventory Asset Mapping + +Inventory is a Current Asset. + +Do not automatically create one GL ledger for every jewellery category. + +Prefer configurable mapping such as: + +```text +Category / Product Type + ↓ +Inventory Asset Account +COGS Account +Sales Account +Purchase Return Account +Sales Return Account +``` + +Allow multiple categories to map to the same inventory account. + +Example: + +```text +Gold Rings +Gold Bangles +Gold Chains + ↓ +Gold Jewellery Inventory +``` + +A business may optionally configure more granular ledgers later. + +Support sensible fallback mapping. + +--- + +# 13. Customer Module + +Customer form already exists. + +Inspect and extend only where necessary. + +Potential requirements: + +* Name +* Mobile +* Email +* Address +* State +* GSTIN for B2B +* PAN where required +* Customer type +* Credit limit +* Opening balance +* Customer ledger +* Notes + +Support: + +* Cash sales +* Credit sales +* Customer receivables +* Payments +* Sales returns +* Old-gold exchange if implemented + +Maintain a queryable customer ledger. + +--- + +# 14. Sales / Invoice Refactor + +Existing invoice logic must be inspected and refactored around the new inventory model. + +Invoice should allow: + +```text +Select Customer + ↓ +Scan/Search Inventory + ↓ +Add Items + ↓ +Calculate Current Selling Value + ↓ +Discount / Charges + ↓ +GST + ↓ +Payment + ↓ +Post Invoice +``` + +Item lookup should support where applicable: + +* Barcode +* SKU +* EAN +* Tag +* HUID +* Product search + +A sold unique inventory item must transition from: + +```text +AVAILABLE → SOLD +``` + +within the same transaction as invoice/accounting posting. + +Prevent double selling through backend concurrency controls. + +--- + +# 15. Sales Price Calculation + +Current selling value may be calculated using the latest applicable rate. + +Do not modify the item's historical purchase cost. + +Conceptually: + +```text +Current Selling Value += +Current Metal Value ++ Making Charges ++ Stone/Diamond Value ++ Certification/Other Charges +- Discount ++ GST +``` + +Apply correct purity/weight logic. + +Support manual override where business rules permit, with audit if necessary. + +--- + +# 16. Sales Accounting + +Posting an invoice generally produces two accounting effects. + +Revenue: + +```text +Dr Customer / Cash / Bank +Cr Sales Revenue +Cr Output GST +``` + +COGS: + +```text +Dr Cost of Goods Sold +Cr Inventory Asset +``` + +Use actual mapped accounts. + +Ensure: + +```text +Total Debit = Total Credit +``` + +for every posted journal. + +Do not calculate COGS using today's metal rate. + +Use appropriate historical inventory cost/valuation logic. + +--- + +# 17. Gross Profit and Net Profit + +Preserve data required to calculate: + +```text +Gross Profit += +Sales Revenue +- Cost of Goods Sold +``` + +and: + +```text +Net Profit += +Gross Profit ++ Other Income +- Operating Expenses +- Other Expenses +``` + +Do not calculate profit simply as: + +```text +Invoice Total - Purchase Invoice Total +``` + +because taxes, discounts, inventory cost allocation, freight, returns and expenses may affect the result. + +Apply proper accounting principles. + +--- + +# 18. Daily Price Sync + +Daily price synchronization already exists. + +Inspect it. + +Refactor so new market rates are stored as history. + +Never overwrite historical pricing. + +Conceptual structure: + +```text +metal_rate_history + +metal +purity/basis +rate +effective_at +source +created_at +``` + +Latest applicable rate should be used for real-time valuation/selling calculations. + +Inventory's original cost must remain unchanged. + +--- + +# 19. Inventory Valuation + +Clearly distinguish: + +```text +Historical Cost Value +Current Market/Estimated Value +Selling Value +``` + +These must not overwrite one another. + +Dashboard and reports must state which valuation is being shown. + +For accounting balance sheet/current assets, use the appropriate accounting valuation method configured by the system/business. + +Do not silently use live gold price as the accounting book value. + +--- + +# 20. Barcode / SKU / EAN + +Existing barcode scan, SKU and EAN logic already exists. + +Inspect before modifying. + +Adapt to the new model: + +```text +Product Master + ↓ +Physical Inventory Item / Tag +``` + +Determine whether barcode belongs to: + +```text +Product +or +Individual Inventory Item +``` + +based on use case. + +For unique jewellery, tag/barcode will commonly identify the physical inventory item. + +Preserve existing functionality where compatible. + +--- + +# 21. Configuration Module + +Move relevant business configuration currently located in: + +```text +User Profile +``` + +or scattered elsewhere into the dedicated: + +```text +Configuration +``` + +navigation destination. + +Potential configuration groups: + +```text +Business +Jewellery +Tax/GST +Pricing +Inventory +Accounting +Invoice +Numbering +Barcode +Metal Rates +Making Charges +Reports +``` + +Do not move identity/profile fields into Configuration. + +Profile and Configuration remain separate. + +--- + +# 22. Chart of Accounts / Ledgers + +Create or correct the required accounting structure so every transaction is queryable. + +Do not call accounting accounts "wallets" internally if they are actually GL accounts. + +Use proper concepts: + +```text +Chart of Accounts +Ledger Accounts +Journal Entries +Journal Lines +Sub-ledgers +``` + +If the existing system uses the term Wallet for a valid payment account abstraction, preserve it only where appropriate. + +At minimum jewellery businesses may require accounts such as: + +```text +ASSETS + Current Assets + Cash + Bank + Accounts Receivable + Inventory + +LIABILITIES + Accounts Payable + GST Payable + +INCOME + Jewellery Sales + Other Income + +EXPENSES + Cost of Goods Sold + Making/Labour Expenses + Freight + Operating Expenses +``` + +Do not hardcode this exact chart if an existing configurable COA exists. + +--- + +# 23. Sub-Ledgers + +Vendor and customer transactions should remain individually queryable. + +Support: + +```text +Vendor Ledger +Customer Ledger +Inventory Ledger +Cash Ledger +Bank Ledger +GST Ledger +``` + +without creating unnecessary GL accounts for every entity if a sub-ledger architecture is more appropriate. + +The user must be able to query transaction history at any time. + +--- + +# 24. Double-Entry Accounting Audit + +Inspect the current accounting implementation for flaws. + +Validate: + +* Debit = Credit +* No orphan journal lines +* Proper transaction references +* Purchase posting +* Sales posting +* Payments +* Vendor payable +* Customer receivable +* Purchase returns +* Sales returns +* Discounts +* Freight +* GST +* Round-off +* Inventory/COGS +* Cancellation/reversal + +Fix incorrect architecture where necessary. + +Do not silently patch symptoms. + +--- + +# 25. Reversal Instead of Delete + +Posted financial/inventory transactions should generally not be destructively deleted. + +Use: + +```text +Cancel +Reverse +Return +Adjustment +``` + +with audit history. + +Examples: + +```text +Purchase → Purchase Return / Reversal +Invoice → Sales Return / Cancellation +Payment → Reversal +Inventory → Adjustment +Journal → Reversal +``` + +--- + +# 26. Reports Module + +In Business Mode, show a dedicated: + +```text +Reports +``` + +card/module. + +At minimum support or architect for: + +* GSTR-1 +* GSTR-3B +* Profit & Loss +* Balance Sheet +* Trial Balance +* Current Assets +* Inventory Valuation +* Inventory Movement +* Sales Report +* Purchase Report +* Customer Ledger +* Vendor Ledger +* GST Summary +* Stock Ageing +* Gross Profit +* Product/category profitability + +Use correct term: + +```text +Profit & Loss +``` + +not "Profile and Loss". + +GST reports should derive from transaction data, not manually maintained totals. + +--- + +# 27. Monthly GST Reports + +Design transaction records so monthly GST reports can be generated accurately. + +Consider: + +* B2B +* B2C +* CGST +* SGST +* IGST +* Taxable value +* HSN +* GST rate +* Credit/debit notes +* Returns + +Do not claim statutory filing compliance without validating current GST requirements. + +Build report architecture capable of adapting to regulatory changes. + +--- + +# 28. Dashboard Statistics + +For Jewellery Business Mode, the Stats section should include at least: + +```text +Total Sales +Total Asset / Inventory Value +``` + +Existing dashboard filters must apply consistently. + +For example: + +```text +Today +This Week +This Month +This Financial Year +Custom Range +Branch +``` + +where supported. + +Clearly distinguish: + +```text +Book Inventory Value +Current Estimated/Market Value +``` + +Do not label current gold-price valuation as accounting assets unless that is the configured accounting basis. + +--- + +# 29. Jewellery Dashboard + +Consider useful jewellery business metrics such as: + +```text +Today's Sales +Monthly Sales +Inventory Cost Value +Current Estimated Inventory Value +Gross Profit +Outstanding Receivables +Outstanding Payables +Gold Stock Weight +Silver Stock Weight +Low/Old Stock +Recent Purchases +Recent Sales +``` + +Do not overload the dashboard. + +Use strong information hierarchy and follow UI/UX skill rules. + +--- + +# 30. Inventory History + +Maintain complete inventory movement history. + +Examples: + +```text +PURCHASE +SALE +SALE_RETURN +PURCHASE_RETURN +TRANSFER +RESERVATION +RELEASE +REPAIR +MELTING +ADJUSTMENT +``` + +Each movement should record enough information to answer: + +```text +What changed? +Why? +When? +Who? +Which transaction caused it? +``` + +--- + +# 31. Jewellery-Specific Domain Gaps + +The specification above is incomplete by design. + +Use domain expertise to evaluate whether architecture should support concepts such as: + +* Gross/net/fine weight +* Purity +* Stone weight +* Diamond carat +* Stone/diamond valuation +* Hallmark/HUID +* Old-gold exchange +* Repairs +* Memo/approval stock +* Branch transfer +* Tagging +* Purchase returns +* Sales returns +* Stock ageing +* Wastage +* Labour/job work +* Certification +* Multiple payment modes +* Advance payments +* Credit sales +* Rate locking +* Manual rate override +* Audit history + +Do not implement every advanced feature immediately. + +But ensure today's database/architecture does not block sensible future expansion. + +--- + +# 32. Security + +Protect sensitive data such as: + +* GST +* PAN +* Bank details +* Customer identifiers +* Vendor identifiers + +Follow existing PII encryption/masking rules. + +Do not log sensitive values. + +Invoice/legal documents may display values in plain form where required. + +--- + +# 33. Transactions and Concurrency + +Important operations must be atomic. + +Examples: + +```text +Purchase posting += +Purchase ++ Inventory creation ++ GST ++ Vendor payable ++ Journal +``` + +and: + +```text +Sales posting += +Invoice ++ Inventory status change ++ Customer/payment ++ GST ++ Revenue journal ++ COGS journal +``` + +If one critical component fails, do not leave partially posted records. + +Prevent two users from selling the same inventory tag simultaneously. + +--- + +# 34. Database Requirements + +Inspect schema first. + +Use proper: + +* Primary keys +* Foreign keys +* Unique constraints +* Check constraints +* Monetary NUMERIC types +* Weight precision +* Audit timestamps +* Status enums/controlled values +* Useful indexes + +Do not store money using floating-point types. + +Do not introduce unnecessary indexes. + +--- + +# 35. Migration Strategy + +This is an existing application. + +Preserve existing: + +* Inventory +* Products +* Purchases +* Invoices +* Customers +* Vendors +* Journals +* Rates +* Barcodes + +Create migration/backfill strategy where necessary. + +Do not drop/recreate business data simply to fit the new design. + +--- + +# 36. UI/UX + +Follow the active skill's mandatory Material 3 modern UI rules. + +Do not create generic CRUD screens. + +Jewellery workflows should be: + +* Fast +* Searchable +* Scanner-friendly +* Keyboard-friendly +* Responsive +* High information density without clutter + +For purchase/invoice item entry, prioritize operational speed. + +--- + +# 37. Implementation Strategy + +Do NOT implement everything blindly in one uncontrolled pass. + +First produce a concise architecture assessment. + +Then execute in logical phases. + +Suggested phases: + +```text +Phase 1 +Audit existing architecture/accounting + +Phase 2 +Category + Product Master + +Phase 3 +Inventory/Tag model + +Phase 4 +Vendor + Purchase refactor + +Phase 5 +Customer + Invoice/Sales refactor + +Phase 6 +Double-entry accounting correction + +Phase 7 +Metal-rate history/valuation + +Phase 8 +Barcode/SKU/EAN integration + +Phase 9 +Configuration migration + +Phase 10 +Reports + Dashboard + +Phase 11 +Migration/testing/security review +``` + +Adjust if project dependencies require another order. + +At every phase report: + +```text +Progress: XX% +``` + +--- + +# 38. Before Coding + +First return only a concise assessment containing: + +```text +1. Existing architecture found +2. Major flaws/risks +3. Proposed target architecture +4. Tables/entities that likely need change +5. Accounting model +6. Jewellery inventory model +7. Implementation phases +``` + +Keep it short. + +Then proceed with implementation. + +Do not ask for confirmation unless a genuinely blocking business decision cannot be safely inferred. + +--- + +# 39. Final Quality Gate + +Before marking complete, verify: + +* Jewellery business mode works from account type +* Category hierarchy works +* Leaf-category configuration works +* Product master is distinct from stock items +* Purchases create inventory correctly +* Original cost is preserved +* Live rates do not overwrite cost +* Sales consume exact inventory +* COGS is correct +* Debit = Credit +* Customer/vendor ledgers work +* GST data is reportable +* Barcode/tag logic works +* Settings moved appropriately +* Dashboard filters work +* Reports derive from source transactions +* Existing users/data remain valid +* Concurrency is safe +* Audit trail exists +* UI follows modern Material 3 standards + +Do not claim completion if important parts remain broken. + +## Final Directive + +This is not merely an inventory feature. + +Build the foundation of a **production-grade jewellery ERP/accounting system for Indian jewellery shop owners**. + +Use domain expertise to correct our assumptions where needed. + +Prefer accounting correctness, inventory traceability and operational usability over quick CRUD implementation. diff --git a/scratch/rewrite_add_product.py b/scratch/rewrite_add_product.py new file mode 100644 index 0000000..eea398b --- /dev/null +++ b/scratch/rewrite_add_product.py @@ -0,0 +1,481 @@ +import os + +file_path = "/Users/maddy/Projects/Kifi/kifi-app/lib/features/inventory/presentation/add_product_screen.dart" + +content = """import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as import_storage; +import '../../../core/network/dio_client.dart'; +import '../../../../core/widgets/smart_search_dropdown.dart'; +import '../domain/product.dart'; +import '../providers/products_provider.dart'; +import '../providers/product_categories_provider.dart'; +import '../../transactions/presentation/widgets/attachment_gallery_screen.dart'; + +class AddProductScreen extends ConsumerStatefulWidget { + final Product? product; + const AddProductScreen({super.key, this.product}); + + @override + ConsumerState createState() => _AddProductScreenState(); +} + +class _AddProductScreenState extends ConsumerState { + final _formKey = GlobalKey(); + + // Basic + String _name = ''; + String _sku = ''; + String _hsnCode = ''; + ProductCategory? _selectedCategory; + + // Jewellery Properties + double _weight = 0; // Gross Weight + double _purityFactor = 1.0; + double _wastagePercentage = 0; + + // Pricing + double _makingCharges = 0; + String _makingChargesType = 'FLAT'; // FLAT, PER_GRAM + double _gstRate = 3.0; // Default 3% for jewellery + + // Inventory + bool _trackInventory = true; + + // Media + final List _images = []; + final List _existingImageIds = []; + bool _isSaving = false; + String? _token; + + @override + void initState() { + super.initState(); + import_storage.FlutterSecureStorage().read(key: 'jwt_token').then((val) { + if (mounted) setState(() => _token = val); + }); + + if (widget.product != null) { + final p = widget.product!; + _name = p.name; + _sku = p.sku ?? ''; + _hsnCode = p.hsnCode ?? ''; + _weight = p.weight ?? 0; + _gstRate = p.gstRate ?? 3.0; + _trackInventory = p.trackInventory; + _purityFactor = p.purityFactor ?? 1.0; + _makingCharges = p.makingCharges ?? 0.0; + _makingChargesType = p.makingChargesType ?? 'FLAT'; + _wastagePercentage = p.wastagePercentage ?? 0.0; + + if (p.imageIds.isNotEmpty) { + _existingImageIds.addAll(p.imageIds); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + final cats = ref.read(productCategoriesProvider).value ?? []; + if (cats.isNotEmpty && p.categoryId != null) { + setState(() { + _selectedCategory = cats.firstWhere((c) => c.id == p.categoryId, orElse: () => cats.first); + }); + } + }); + } + } + + Future _pickImages() async { + if ((_images.length + _existingImageIds.length) >= 4) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Maximum 4 images allowed'))); + return; + } + final ImagePicker picker = ImagePicker(); + final List picked = await picker.pickMultiImage(); + if (picked.isNotEmpty) { + setState(() { + _images.addAll(picked.take(4 - (_images.length + _existingImageIds.length))); + }); + } + } + + void _removeImage(int index) { + setState(() { + _images.removeAt(index); + }); + } + + void _previewImage(int index) { + List allImages = []; + for (final imageId in _existingImageIds) { + if (_token != null) { + allImages.add(NetworkImage('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', headers: {'Authorization': 'Bearer $_token'})); + } else { + allImages.add(const AssetImage('assets/images/placeholder.png')); + } + } + for (final file in _images) { + allImages.add(FileImage(File(file.path))); + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AttachmentGalleryScreen( + images: allImages, + initialIndex: index, + ), + ), + ); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + + if (_selectedCategory == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Please select a category'))); + return; + } + + setState(() => _isSaving = true); + try { + final product = Product( + id: widget.product?.id, + name: _name, + hsnCode: _hsnCode.isNotEmpty ? _hsnCode : null, + sku: _sku.isNotEmpty ? _sku : null, + categoryId: _selectedCategory?.id, + gstRate: _gstRate, + weight: _weight, + purityFactor: _purityFactor, + makingCharges: _makingCharges, + makingChargesType: _makingChargesType, + wastagePercentage: _wastagePercentage, + trackInventory: _trackInventory, + ); + + if (widget.product != null) { + await ref.read(productsProvider.notifier).updateProduct(widget.product!.id!, product, newImages: _images); + } else { + await ref.read(productsProvider.notifier).createProduct(product, images: _images); + } + if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed: $e'))); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + double get _netWeight { + final weightAfterWastage = _weight * (1 - (_wastagePercentage / 100)); + return weightAfterWastage; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.surface, + appBar: AppBar( + title: Text(widget.product != null ? 'Edit Jewellery Product' : 'New Jewellery Product', style: const TextStyle(fontWeight: FontWeight.bold)), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + _buildSectionTitle('Basic Details'), + _buildPremiumTextField( + label: 'Product Name*', + initialValue: _name, + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + onChanged: (val) => _name = val, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'SKU / HUID', + initialValue: _sku, + onChanged: (val) => _sku = val, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildCategoryDropdown(), + ), + ], + ), + + const SizedBox(height: 32), + _buildSectionTitle('Metal & Weight'), + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'Gross Weight (g)*', + initialValue: _weight > 0 ? _weight.toString() : '', + keyboardType: TextInputType.number, + validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null, + onChanged: (val) { + _weight = double.tryParse(val) ?? 0; + setState(() {}); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildPremiumTextField( + label: 'Purity Factor (e.g. 0.916)*', + initialValue: _purityFactor.toString(), + keyboardType: TextInputType.number, + validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null, + onChanged: (val) => _purityFactor = double.tryParse(val) ?? 1.0, + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'Wastage (%)', + initialValue: _wastagePercentage.toString(), + keyboardType: TextInputType.number, + onChanged: (val) { + _wastagePercentage = double.tryParse(val) ?? 0.0; + setState(() {}); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Net Weight', style: TextStyle(fontSize: 12, color: Colors.blue.shade700)), + const SizedBox(height: 4), + Text('${_netWeight.toStringAsFixed(3)} g', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.blue)), + ], + ), + ), + ), + ], + ), + + const SizedBox(height: 32), + _buildSectionTitle('Pricing Details'), + Row( + children: [ + Expanded( + child: _buildPremiumTextField( + label: 'Making Charges*', + initialValue: _makingCharges.toString(), + keyboardType: TextInputType.number, + validator: (val) => val == null || double.tryParse(val) == null ? 'Invalid' : null, + onChanged: (val) => _makingCharges = double.tryParse(val) ?? 0, + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: _makingChargesType, + decoration: InputDecoration( + labelText: 'Charge Type', + filled: true, + fillColor: Colors.grey.shade50, + 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).colorScheme.primary, width: 2)), + ), + items: const [ + DropdownMenuItem(value: 'FLAT', child: Text('Flat Amount')), + DropdownMenuItem(value: 'PER_GRAM', child: Text('Per Gram')), + ], + onChanged: (val) => setState(() => _makingChargesType = val!), + ), + ), + ], + ), + const SizedBox(height: 16), + 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.toString(), + keyboardType: TextInputType.number, + onChanged: (val) => _gstRate = double.tryParse(val) ?? 3.0, + ), + ), + ], + ), + + const SizedBox(height: 32), + _buildSectionTitle('Media'), + _buildImagePicker(), + + const SizedBox(height: 32), + SwitchListTile( + title: const Text('Track Inventory', style: TextStyle(fontWeight: FontWeight.bold)), + subtitle: const Text('Manage stock levels for this item'), + value: _trackInventory, + onChanged: (val) => setState(() => _trackInventory = val), + activeColor: Theme.of(context).colorScheme.primary, + contentPadding: EdgeInsets.zero, + ), + + const SizedBox(height: 32), + ElevatedButton( + onPressed: _isSaving ? null : _save, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + 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)), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87)), + ); + } + + Widget _buildPremiumTextField({ + required String label, + required String initialValue, + required Function(String) onChanged, + TextInputType? keyboardType, + String? Function(String?)? validator, + }) { + return TextFormField( + initialValue: initialValue, + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: Colors.grey.shade600), + filled: true, + fillColor: Colors.grey.shade50, + 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).colorScheme.primary, width: 2)), + errorBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Colors.red)), + ), + keyboardType: keyboardType, + validator: validator, + onChanged: onChanged, + ); + } + + Widget _buildCategoryDropdown() { + final categoriesState = ref.watch(productCategoriesProvider); + return categoriesState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, s) => Text('Error: $e'), + data: (categories) { + if (categories.isEmpty) return const Text('No categories available'); + final items = categories.where((c) => !c.hasChild).map((c) => SmartSearchItem(id: c.id.toString(), label: c.name, subtitle: c.parentCategoryId != null ? 'Subcategory' : 'Root Category')).toList(); + return SmartSearchDropdown( + label: 'Category*', + items: items, + initialValue: _selectedCategory?.id.toString(), + onChanged: (val) { + if (val != null) setState(() => _selectedCategory = categories.firstWhere((c) => c.id.toString() == val)); + }, + validator: (val) => val == null || val.isEmpty ? 'Required' : null, + ); + }, + ); + } + + Widget _buildImagePicker() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...List.generate(_existingImageIds.length, (index) { + final imageId = _existingImageIds[index]; + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Stack( + children: [ + GestureDetector( + onTap: () => _previewImage(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + width: 100, height: 100, color: Colors.grey.shade200, + child: _token == null ? const Icon(LucideIcons.image, color: Colors.grey) + : Image.network('${DioClient().dio.options.baseUrl}/inventory/products/${widget.product!.id}/images/$imageId/content', fit: BoxFit.cover, headers: {'Authorization': 'Bearer $_token'}, + errorBuilder: (ctx, err, trace) => const Icon(LucideIcons.imageOff, color: Colors.grey)), + ), + ), + ), + Positioned(top: 4, right: 4, child: GestureDetector(onTap: () => setState(() => _existingImageIds.removeAt(index)), child: Container(padding: const EdgeInsets.all(4), decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), child: const Icon(LucideIcons.x, color: Colors.white, size: 16)))), + ], + ), + ); + }), + ...List.generate(_images.length, (index) { + return Padding( + padding: const EdgeInsets.only(right: 12), + child: Stack( + children: [ + GestureDetector(onTap: () => _previewImage(_existingImageIds.length + index), child: ClipRRect(borderRadius: BorderRadius.circular(12), child: Image.file(File(_images[index].path), width: 100, height: 100, fit: BoxFit.cover))), + Positioned(top: 4, right: 4, child: GestureDetector(onTap: () => _removeImage(index), child: Container(padding: const EdgeInsets.all(4), decoration: const BoxDecoration(color: Colors.red, shape: BoxShape.circle), child: const Icon(LucideIcons.x, color: Colors.white, size: 16)))), + ], + ), + ); + }), + if ((_images.length + _existingImageIds.length) < 4) + GestureDetector( + onTap: _pickImages, + child: Container( + width: 100, height: 100, + decoration: BoxDecoration(color: Colors.grey.shade100, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.grey.shade300, style: BorderStyle.solid)), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(LucideIcons.plus, color: Colors.grey.shade600), const SizedBox(height: 4), Text('Add Photo', style: TextStyle(color: Colors.grey.shade600, fontSize: 12))]), + ), + ), + ], + ), + ), + ], + ); + } +} +""" + +with open(file_path, "w") as f: + f.write(content) diff --git a/scratch/rewrite_product_detail.py b/scratch/rewrite_product_detail.py new file mode 100644 index 0000000..7c6b7b9 --- /dev/null +++ b/scratch/rewrite_product_detail.py @@ -0,0 +1,211 @@ +import os + +file_path = "/Users/maddy/Projects/Kifi/kifi-app/lib/features/inventory/presentation/product_detail_screen.dart" + +content = """import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:kifi_app/features/inventory/providers/products_provider.dart'; +import 'package:kifi_app/core/network/dio_client.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/stock_ledger_tab.dart'; +import 'package:kifi_app/features/inventory/presentation/widgets/adjust_stock_sheet.dart'; + +class ProductDetailScreen extends ConsumerWidget { + final Product product; + + const ProductDetailScreen({super.key, required this.product}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Watch products to get latest updates (like stock changes) + final productsState = ref.watch(productsProvider); + final currentProduct = productsState.value?.firstWhere((p) => p.id == product.id, orElse: () => product) ?? product; + + return DefaultTabController( + length: 2, + child: Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.white, + foregroundColor: Colors.black, + title: Text(currentProduct.name, style: const TextStyle(fontWeight: FontWeight.bold)), + actions: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.black), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AddProductScreen(product: currentProduct), + ), + ); + }, + ), + ], + bottom: const TabBar( + labelColor: Colors.blue, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.blue, + tabs: [ + Tab(text: "Overview"), + Tab(text: "Stock Ledger"), + ], + ), + ), + body: TabBarView( + children: [ + _buildOverviewTab(context, currentProduct, ref), + 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"), + ), + ), + ); + } + + Widget _buildOverviewTab(BuildContext context, Product p, WidgetRef ref) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image Header + if (p.imageIds.isNotEmpty) + Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.grey[200], + ), + clipBehavior: Clip.antiAlias, + child: FutureBuilder( + future: DioClient().storage.read(key: 'jwt_token'), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + final token = snapshot.data; + if (token == null) { + return const Icon(Icons.image, size: 50, color: Colors.grey); + } + return Image.network( + '${DioClient().dio.options.baseUrl}/inventory/products/${p.id}/images/${p.imageIds.first}/content', + fit: BoxFit.cover, + headers: {'Authorization': 'Bearer $token'}, + errorBuilder: (context, error, stackTrace) => const Icon(Icons.image, size: 50, color: Colors.grey), + ); + }, + ), + ), + const SizedBox(height: 24), + + // Stock Summary Card + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + 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( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Metal Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const Divider(), + _buildDetailRow("SKU / HUID", p.sku ?? "-"), + _buildDetailRow("Gross Weight", "${p.weight ?? 0} g"), + _buildDetailRow("Purity Factor", "${p.purityFactor ?? 1.0}"), + _buildDetailRow("Wastage", "${p.wastagePercentage ?? 0}%"), + _buildDetailRow("Net Weight", "${(p.weight ?? 0) * (1 - (p.wastagePercentage ?? 0) / 100)} g"), + ], + ), + ), + ), + + const SizedBox(height: 16), + // Pricing Card + Card( + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("Pricing Details", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const Divider(), + _buildDetailRow("Making Charges", "₹${p.makingCharges?.toStringAsFixed(2) ?? '0.00'} (${p.makingChargesType})"), + _buildDetailRow("HSN Code", p.hsnCode ?? "-"), + _buildDetailRow("GST Rate", "${p.gstRate?.toStringAsFixed(1) ?? '0'}%"), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600])), + Text(value, style: const TextStyle(fontWeight: FontWeight.w500)), + ], + ), + ); + } +} +""" + +with open(file_path, "w") as f: + f.write(content) diff --git a/test_api.js b/test_api.js new file mode 100644 index 0000000..445b7cb --- /dev/null +++ b/test_api.js @@ -0,0 +1,22 @@ +const items = [ + { + "productId": 1, + "quantity": 2, + "unitPrice": 100, + "total": 200 + } +]; +const po = { + "poNumber": "TEST-001", + "issueDate": "2026-08-27T00:00:00Z", + "items": items +}; + +fetch('http://localhost:8080/api/purchase-orders', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test' + }, + body: JSON.stringify(po) +}).then(r => r.text()).then(console.log).catch(console.error);