diff --git a/lib/core/constants/api_endpoints.dart b/lib/core/constants/api_endpoints.dart index 2e32d50..ff43d9c 100644 --- a/lib/core/constants/api_endpoints.dart +++ b/lib/core/constants/api_endpoints.dart @@ -146,7 +146,12 @@ class ApiEndpoints { static const String dashboardKpis = '/dashboard/kpis'; static const String dashboardCharts = '/dashboard/charts'; - // Reports + // Reports (see https://demo.venbait.in/api-docs/#/Reports) + static const String reportAssetDepreciation = '/reports/assets/depreciation'; + static const String reportAssetDepreciationFilters = + '/reports/assets/depreciation/filters'; + static const String reportAssetDepreciationExport = + '/reports/assets/depreciation/export'; static const String reportAssetRegister = '/reports/asset-register'; static const String reportAllocation = '/reports/allocation'; static const String reportMaintenance = '/reports/maintenance'; diff --git a/lib/core/constants/route_constants.dart b/lib/core/constants/route_constants.dart index 5d53966..982da9c 100644 --- a/lib/core/constants/route_constants.dart +++ b/lib/core/constants/route_constants.dart @@ -77,6 +77,8 @@ class RouteConstants { // Reports static const String reports = '/reports'; + static String reportDetail(String key) => '$reports/$key'; + static const String reportDepreciation = '/reports/depreciation'; // Settings static const String settings = '/settings'; diff --git a/lib/core/utils/permission_utils.dart b/lib/core/utils/permission_utils.dart index 3d178ea..755d80d 100644 --- a/lib/core/utils/permission_utils.dart +++ b/lib/core/utils/permission_utils.dart @@ -19,6 +19,10 @@ const Map permissionModuleAliases = { 'purchase_orders': 'PURCHASE_ORDER', 'purchase_order': 'PURCHASE_ORDER', 'grn': 'GRN', + 'reports': 'REPORTS', + 'audit_logs': 'AUDIT_LOGS', + 'audit': 'AUDIT_LOGS', + 'settings': 'SETTINGS', }; /// Modules that are always visible in navigation (no API permission key). @@ -85,7 +89,9 @@ bool canSeeMenuModule({ required UserRole role, }) { if (alwaysVisibleMenuModules.contains(module)) return true; - if (isSuperAdmin(role) || permissions.contains('*')) return true; + // Full access only via wildcard — role alone must not reveal menus the + // user cannot open (e.g. Reports without REPORTS:view). + if (permissions.contains('*')) return true; if (module == 'users') { return hasPermission( diff --git a/lib/modules/assets/presentation/widgets/asset_form_panel.dart b/lib/modules/assets/presentation/widgets/asset_form_panel.dart index f81a176..f46c2c0 100644 --- a/lib/modules/assets/presentation/widgets/asset_form_panel.dart +++ b/lib/modules/assets/presentation/widgets/asset_form_panel.dart @@ -10,6 +10,7 @@ import '../../../../core/utils/validators.dart'; import '../../../../shared/models/asset_model.dart'; import '../../../../shared/models/user_management_models.dart' show FilterOptionModel; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; @@ -364,11 +365,12 @@ class _AssetFormPanelState extends ConsumerState { } Future _pickDate(void Function(DateTime) onPicked, DateTime? current) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); diff --git a/lib/modules/assets/presentation/widgets/asset_side_panels.dart b/lib/modules/assets/presentation/widgets/asset_side_panels.dart index 07d311a..7057374 100644 --- a/lib/modules/assets/presentation/widgets/asset_side_panels.dart +++ b/lib/modules/assets/presentation/widgets/asset_side_panels.dart @@ -8,6 +8,7 @@ import '../../../../shared/models/asset_model.dart'; import '../../../../shared/widgets/api_feedback.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_card.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; @@ -134,11 +135,12 @@ class _AddAmcPanelState extends ConsumerState { required DateTime? current, required void Function(DateTime date) onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); @@ -513,11 +515,12 @@ class _LogServiceVisitPanelState extends ConsumerState { required DateTime? current, required void Function(DateTime date) onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); @@ -947,11 +950,12 @@ class _AddInsurancePanelState extends ConsumerState { required DateTime? current, required void Function(DateTime date) onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2000), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) { setState(() => onPicked(picked)); @@ -1254,11 +1258,12 @@ class _TransferAssetPanelState extends ConsumerState { } Future _pickDate() async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: _transferDate, firstDate: DateTime(2000), lastDate: DateTime(2100), + helpText: 'Transfer date', ); if (picked != null) { setState(() => _transferDate = picked); diff --git a/lib/modules/audit/presentation/screens/audit_logs_screen.dart b/lib/modules/audit/presentation/screens/audit_logs_screen.dart index cd24a94..10d5197 100644 --- a/lib/modules/audit/presentation/screens/audit_logs_screen.dart +++ b/lib/modules/audit/presentation/screens/audit_logs_screen.dart @@ -10,11 +10,12 @@ import '../../../../shared/providers/permissions_provider.dart'; import '../../../../shared/utils/file_download_helper.dart'; import '../../../../shared/widgets/app_card.dart'; import '../../../../shared/widgets/app_data_table.dart'; +import '../../../../shared/widgets/app_date_range_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_empty_state.dart'; +import '../../../../shared/widgets/app_filter_date_field.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_pagination.dart'; -import '../../../../shared/widgets/app_search_field.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_status_chip.dart'; @@ -81,7 +82,7 @@ class _AuditLogsScreenState extends ConsumerState { ? DateTimeRange(start: query.dateFrom!, end: query.dateTo!) : null; - final picked = await showDateRangePicker( + final picked = await showAppDateRangePopup( context: context, firstDate: DateTime(now.year - 5), lastDate: DateTime(now.year + 1), @@ -258,19 +259,35 @@ class _FiltersBar extends StatelessWidget { final VoidCallback onClearDateRange; final VoidCallback onReset; - String get _dateLabel { - if (query.dateFrom == null && query.dateTo == null) return 'Date range'; + String get _dateValue { + if (query.dateFrom == null && query.dateTo == null) return ''; final from = DateFormatter.displayDate(query.dateFrom); final to = DateFormatter.displayDate(query.dateTo); return '$from – $to'; } + bool get _dateEmpty => query.dateFrom == null && query.dateTo == null; + @override Widget build(BuildContext context) { - final searchField = AppSearchField( + final searchField = TextField( controller: searchController, - hint: 'Search table, action, request ID...', onChanged: onSearch, + decoration: InputDecoration( + labelText: 'Search', + hintText: 'Search table, action, request ID...', + prefixIcon: const Icon(Icons.search), + isDense: true, + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + onSearch(''); + }, + ) + : null, + ), ); final tableDropdown = AppSearchableDropdown( @@ -318,20 +335,16 @@ class _FiltersBar extends StatelessWidget { onChanged: onPerformerChanged, ); - final dateButton = OutlinedButton.icon( - onPressed: onPickDateRange, - icon: const Icon(Icons.date_range_outlined, size: 18), - label: Text(_dateLabel, overflow: TextOverflow.ellipsis), + final dateField = AppFilterDateField( + label: 'Date range', + value: _dateValue, + placeholder: 'Select range', + icon: Icons.date_range_outlined, + isEmpty: _dateEmpty, + onTap: onPickDateRange, + onClear: _dateEmpty ? null : onClearDateRange, ); - final clearDate = query.dateFrom != null || query.dateTo != null - ? IconButton( - tooltip: 'Clear date range', - onPressed: onClearDateRange, - icon: const Icon(Icons.clear, size: 18), - ) - : null; - final resetButton = TextButton( onPressed: query.hasActiveFilter ? onReset : null, child: const Text('Reset'), @@ -351,8 +364,7 @@ class _FiltersBar extends StatelessWidget { const SizedBox(height: 12), Row( children: [ - Expanded(child: dateButton), - if (clearDate != null) clearDate, + Expanded(child: dateField), resetButton, ], ), @@ -360,28 +372,29 @@ class _FiltersBar extends StatelessWidget { ); } + Widget row(List cells) { + assert(cells.length == 4); + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < cells.length; i++) ...[ + if (i > 0) const SizedBox(width: 12), + Expanded(child: cells[i]), + ], + ], + ); + } + return Column( children: [ - Row( - children: [ - Expanded(flex: 3, child: searchField), - const SizedBox(width: 12), - Expanded(flex: 2, child: tableDropdown), - const SizedBox(width: 12), - Expanded(flex: 2, child: actionDropdown), - const SizedBox(width: 12), - Expanded(flex: 2, child: performerDropdown), - ], - ), + row([searchField, tableDropdown, actionDropdown, performerDropdown]), const SizedBox(height: 12), - Row( - children: [ - Flexible(child: dateButton), - if (clearDate != null) clearDate, - const Spacer(), - resetButton, - ], - ), + row([ + dateField, + const SizedBox.shrink(), + const SizedBox.shrink(), + Align(alignment: Alignment.centerRight, child: resetButton), + ]), ], ); } diff --git a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart index bff0c50..f0048e0 100644 --- a/lib/modules/dev/presentation/screens/screen_gallery_screen.dart +++ b/lib/modules/dev/presentation/screens/screen_gallery_screen.dart @@ -112,6 +112,11 @@ final _entries = [ _GalleryEntry(title: 'Security', route: RouteConstants.settingsSecurity, group: 'Settings'), // Other _GalleryEntry(title: 'Reports', route: RouteConstants.reports, group: 'Other'), + _GalleryEntry( + title: 'Depreciation Report', + route: RouteConstants.reportDepreciation, + group: 'Other', + ), _GalleryEntry(title: 'Audit Logs', route: RouteConstants.auditLogs, group: 'Other'), ]; diff --git a/lib/modules/grn/presentation/screens/grn_form_screen.dart b/lib/modules/grn/presentation/screens/grn_form_screen.dart index 975fadc..4736f12 100644 --- a/lib/modules/grn/presentation/screens/grn_form_screen.dart +++ b/lib/modules/grn/presentation/screens/grn_form_screen.dart @@ -11,6 +11,7 @@ import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; @@ -271,11 +272,12 @@ class _GrnFormScreenState extends ConsumerState { required DateTime? current, required ValueChanged onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) onPicked(picked); } diff --git a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart index 0afc379..faa2aa0 100644 --- a/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart +++ b/lib/modules/grn/presentation/widgets/grn_line_items_editor.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../../../../core/utils/formatters.dart'; import '../../../../shared/models/grn_model.dart'; import '../../../../shared/models/purchase_order_model.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_side_panel.dart'; import '../../../../shared/widgets/app_text_field.dart'; @@ -245,11 +246,12 @@ class _GrnLineItemCardState extends State<_GrnLineItemCard> { required DateTime? current, required ValueChanged onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) onPicked(picked); } diff --git a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart index e24ba94..702265d 100644 --- a/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart +++ b/lib/modules/purchase_orders/presentation/screens/purchase_order_form_screen.dart @@ -11,6 +11,7 @@ import '../../../../shared/models/purchase_order_model.dart'; import '../../../../shared/models/user_management_models.dart'; import '../../../../shared/utils/navigation_utils.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; import '../../../../shared/widgets/app_dropdown.dart'; import '../../../../shared/widgets/app_loading_view.dart'; import '../../../../shared/widgets/app_searchable_dropdown.dart'; @@ -312,11 +313,12 @@ class _PurchaseOrderFormScreenState extends ConsumerState onPicked, }) async { - final picked = await showDatePicker( + final picked = await showAppDatePopup( context: context, initialDate: current ?? DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2100), + helpText: 'Select date', ); if (picked != null) onPicked(picked); } diff --git a/lib/modules/reports/data/datasources/reports_remote_data_source.dart b/lib/modules/reports/data/datasources/reports_remote_data_source.dart new file mode 100644 index 0000000..189e9fb --- /dev/null +++ b/lib/modules/reports/data/datasources/reports_remote_data_source.dart @@ -0,0 +1,142 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/constants/api_endpoints.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../../domain/entities/depreciation_report.dart'; + +class ReportsRemoteDataSource { + ReportsRemoteDataSource({required this.dio}); + + final Dio dio; + + Future getAssetReportFilters() async { + final response = await dio.get(ApiEndpoints.reportAssetDepreciationFilters); + final data = response.data['data']; + if (data is Map) { + return DepreciationReportFilters.fromJson(data); + } + if (data is Map) { + return DepreciationReportFilters.fromJson( + Map.from(data), + ); + } + return const DepreciationReportFilters(); + } + + Future getDepreciationReport( + DepreciationReportQuery query, + ) async { + final response = await dio.get( + ApiEndpoints.reportAssetDepreciation, + queryParameters: _queryToMap(query, includePagination: true), + ); + final body = response.data as Map; + final rawData = body['data']; + final meta = body['meta'] is Map + ? Map.from(body['meta'] as Map) + : {}; + + final rawItems = rawData is List ? rawData : const []; + final items = rawItems + .whereType() + .map( + (item) => DepreciationReportRow.fromJson( + Map.from(item), + ), + ) + .toList(); + + final page = (meta['page'] as num?)?.toInt() ?? query.page; + final limit = (meta['limit'] as num?)?.toInt() ?? query.limit; + final total = (meta['total'] as num?)?.toInt() ?? items.length; + final totalPages = limit > 0 + ? ((total + limit - 1) ~/ limit).clamp(1, 999999) + : 1; + + final summaryRaw = meta['summary']; + final summary = summaryRaw is Map + ? DepreciationReportSummary.fromJson( + Map.from(summaryRaw), + ) + : const DepreciationReportSummary(); + + final asOfRaw = meta['as_of_date'] ?? meta['asOfDate']; + final asOfDate = asOfRaw == null ? null : DateTime.tryParse(asOfRaw.toString()); + + return DepreciationReportPage( + items: items, + page: page, + limit: limit, + total: total, + totalPages: totalPages, + asOfDate: asOfDate, + summary: summary, + ); + } + + Future exportAssetReport( + DepreciationReportQuery query, + ) async { + final response = await dio.get>( + ApiEndpoints.reportAssetDepreciationExport, + queryParameters: _queryToMap(query, includePagination: false), + options: Options(responseType: ResponseType.bytes), + ); + return ExportFileResult( + bytes: response.data ?? [], + fileName: _fileNameFromResponse(response), + ); + } + + Map _queryToMap( + DepreciationReportQuery query, { + required bool includePagination, + }) { + return { + if (includePagination) 'page': query.page, + if (includePagination) 'limit': query.limit, + if (query.search != null && query.search!.isNotEmpty) 'search': query.search, + if (query.status != null && query.status!.isNotEmpty) 'status': query.status, + if (query.depreciationMethod != null && + query.depreciationMethod!.isNotEmpty) + 'depreciation_method': query.depreciationMethod, + if (query.itemCategoryId != null && query.itemCategoryId!.isNotEmpty) + 'item_category_id': query.itemCategoryId, + if (query.itemSubcategoryId != null && + query.itemSubcategoryId!.isNotEmpty) + 'item_subcategory_id': query.itemSubcategoryId, + if (query.plantId != null && query.plantId!.isNotEmpty) + 'plant_id': query.plantId, + if (query.departmentId != null && query.departmentId!.isNotEmpty) + 'department_id': query.departmentId, + if (query.isActive != null) 'is_active': query.isActive, + if (query.asOfDate != null) + 'as_of_date': DateFormatter.toApiDate(query.asOfDate!), + if (query.purchaseDateFrom != null) + 'purchase_date_from': DateFormatter.toApiDate(query.purchaseDateFrom!), + if (query.purchaseDateTo != null) + 'purchase_date_to': DateFormatter.toApiDate(query.purchaseDateTo!), + }; + } + + String _fileNameFromResponse(Response> response) { + final disposition = response.headers.value('content-disposition'); + if (disposition != null) { + final utf8Match = RegExp( + r"filename\*=UTF-8''([^;\n]+)", + caseSensitive: false, + ).firstMatch(disposition); + if (utf8Match != null) { + return Uri.decodeComponent(utf8Match.group(1)!); + } + + final match = RegExp(r'filename="?([^";\n]+)"?').firstMatch(disposition); + if (match != null) { + return match.group(1)!.trim(); + } + } + + return 'depreciation_report.csv'; + } +} diff --git a/lib/modules/reports/data/repositories/reports_repository_impl.dart b/lib/modules/reports/data/repositories/reports_repository_impl.dart new file mode 100644 index 0000000..da08bb9 --- /dev/null +++ b/lib/modules/reports/data/repositories/reports_repository_impl.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/network/api_handler.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../../domain/entities/depreciation_report.dart'; +import '../../domain/repositories/reports_repository.dart'; +import '../datasources/reports_remote_data_source.dart'; + +final reportsRemoteDataSourceProvider = Provider((ref) { + return ReportsRemoteDataSource(dio: ref.watch(dioProvider)); +}); + +final reportsRepositoryProvider = Provider((ref) { + return ReportsRepositoryImpl(remote: ref.watch(reportsRemoteDataSourceProvider)); +}); + +class ReportsRepositoryImpl implements ReportsRepository { + ReportsRepositoryImpl({required this.remote}); + + final ReportsRemoteDataSource remote; + + @override + Future> getAssetReportFilters() => + safeApiCall(remote.getAssetReportFilters); + + @override + Future> getDepreciationReport( + DepreciationReportQuery query, + ) => + safeApiCall(() => remote.getDepreciationReport(query)); + + @override + Future> exportAssetReport( + DepreciationReportQuery query, + ) => + safeApiCall(() => remote.exportAssetReport(query)); +} diff --git a/lib/modules/reports/domain/entities/depreciation_report.dart b/lib/modules/reports/domain/entities/depreciation_report.dart new file mode 100644 index 0000000..2818176 --- /dev/null +++ b/lib/modules/reports/domain/entities/depreciation_report.dart @@ -0,0 +1,424 @@ +import '../../../../shared/models/asset_model.dart'; + +class ReportFilterOption { + const ReportFilterOption({ + required this.value, + required this.label, + this.parentId, + }); + + final String value; + final String label; + final String? parentId; + + factory ReportFilterOption.fromDynamic(dynamic item) { + if (item is Map) { + final map = Map.from(item); + final value = (map['value'] ?? map['id'] ?? map['code'] ?? map['key'] ?? '') + .toString() + .trim(); + final label = (map['label'] ?? map['name'] ?? map['display'] ?? value) + .toString() + .trim(); + final code = map['code']?.toString().trim(); + final display = (code != null && + code.isNotEmpty && + label.isNotEmpty && + code != label) + ? '$label ($code)' + : (label.isEmpty ? value : label); + final parentId = (map['item_category_id'] ?? map['parent_id']) + ?.toString() + .trim(); + return ReportFilterOption( + value: value, + label: display, + parentId: parentId?.isEmpty == true ? null : parentId, + ); + } + + final option = AssetDropdownOption.fromDynamic(item); + return ReportFilterOption(value: option.value, label: option.label); + } +} + +class DepreciationReportFilters { + const DepreciationReportFilters({ + this.plants = const [], + this.categories = const [], + this.subcategories = const [], + this.departments = const [], + this.statuses = const [], + this.depreciationMethods = const [], + }); + + final List plants; + final List categories; + final List subcategories; + final List departments; + final List statuses; + final List depreciationMethods; + + factory DepreciationReportFilters.fromJson(Map json) { + List parse(List keys) { + for (final key in keys) { + final raw = json[key]; + if (raw is List && raw.isNotEmpty) { + return raw + .map(ReportFilterOption.fromDynamic) + .where((o) => o.value.isNotEmpty) + .toList(); + } + } + return const []; + } + + return DepreciationReportFilters( + plants: parse(['plants', 'plant_options', 'plant']), + categories: parse([ + 'item_categories', + 'categories', + 'asset_categories', + 'category_options', + ]), + subcategories: parse([ + 'item_subcategories', + 'subcategories', + 'asset_subcategories', + ]), + departments: parse(['departments', 'department_options', 'department']), + statuses: parse(['statuses', 'status_options', 'status']), + depreciationMethods: parse([ + 'depreciation_methods', + 'methods', + 'depreciationMethods', + ]), + ); + } +} + +class DepreciationReportSummary { + const DepreciationReportSummary({ + this.assetCount = 0, + this.totalPurchaseCost = 0, + this.totalAccumulatedDepreciation = 0, + this.totalBookValue = 0, + this.totalAnnualDepreciation = 0, + }); + + final int assetCount; + final double totalPurchaseCost; + final double totalAccumulatedDepreciation; + final double totalBookValue; + final double totalAnnualDepreciation; + + factory DepreciationReportSummary.fromJson(Map? json) { + if (json == null) return const DepreciationReportSummary(); + + double toDouble(dynamic value) { + if (value is num) return value.toDouble(); + return double.tryParse(value?.toString() ?? '') ?? 0; + } + + int toInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? '') ?? 0; + } + + return DepreciationReportSummary( + assetCount: toInt(json['asset_count'] ?? json['assetCount']), + totalPurchaseCost: + toDouble(json['total_purchase_cost'] ?? json['totalPurchaseCost']), + totalAccumulatedDepreciation: toDouble( + json['total_accumulated_depreciation'] ?? + json['totalAccumulatedDepreciation'], + ), + totalBookValue: + toDouble(json['total_book_value'] ?? json['totalBookValue']), + totalAnnualDepreciation: toDouble( + json['total_annual_depreciation'] ?? json['totalAnnualDepreciation'], + ), + ); + } +} + +class DepreciationReportRow { + const DepreciationReportRow({ + required this.id, + this.assetCode, + this.assetName, + this.categoryName, + this.subcategoryName, + this.plantName, + this.departmentName, + this.status, + this.purchaseDate, + this.purchaseCost, + this.depreciationMethod, + this.depreciationRate, + this.usefulLifeYears, + this.yearsElapsed, + this.salvageValue, + this.annualDepreciation, + this.accumulatedDepreciation, + this.bookValue, + }); + + final String id; + final String? assetCode; + final String? assetName; + final String? categoryName; + final String? subcategoryName; + final String? plantName; + final String? departmentName; + final String? status; + final DateTime? purchaseDate; + final double? purchaseCost; + final String? depreciationMethod; + final double? depreciationRate; + final int? usefulLifeYears; + final double? yearsElapsed; + final double? salvageValue; + final double? annualDepreciation; + final double? accumulatedDepreciation; + final double? bookValue; + + factory DepreciationReportRow.fromJson(Map json) { + double? toDouble(dynamic value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + + int? toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()); + } + + DateTime? toDate(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + return DateTime.tryParse(value.toString()); + } + + String? readString(List keys, [Map? source]) { + final map = source ?? json; + for (final key in keys) { + final value = map[key]; + if (value == null) continue; + final text = value.toString().trim(); + if (text.isNotEmpty) return text; + } + return null; + } + + String? readNestedName(String key) { + final nested = json[key]; + if (nested is Map) { + final map = Map.from(nested); + final name = map['name'] ?? map['label'] ?? map['title']; + if (name != null && name.toString().trim().isNotEmpty) { + return name.toString().trim(); + } + } + return null; + } + + final depreciation = json['depreciation']; + final depMap = depreciation is Map + ? Map.from(depreciation) + : {}; + + final id = (json['id'] ?? + json['asset_id'] ?? + json['assetId'] ?? + json['asset_code'] ?? + '') + .toString(); + + return DepreciationReportRow( + id: id, + assetCode: readString(['asset_code', 'assetCode', 'code']), + assetName: readString(['asset_name', 'assetName', 'name']), + categoryName: readString([ + 'category_name', + 'asset_category_name', + 'item_category_name', + ]) ?? + readNestedName('item_category') ?? + readNestedName('category') ?? + readNestedName('asset_category'), + subcategoryName: readString([ + 'subcategory_name', + 'item_subcategory_name', + ]) ?? + readNestedName('item_subcategory') ?? + readNestedName('subcategory'), + plantName: + readString(['plant_name', 'plantName']) ?? readNestedName('plant'), + departmentName: readString(['department_name', 'departmentName']) ?? + readNestedName('department'), + status: readString(['status']), + purchaseDate: toDate(json['purchase_date'] ?? json['purchaseDate']), + purchaseCost: toDouble( + json['purchase_cost'] ?? json['purchaseCost'], + ) ?? + toDouble(depMap['purchase_cost']), + depreciationMethod: readString([ + 'depreciation_method', + 'depreciationMethod', + 'method', + ]) ?? + readString(['depreciation_method', 'depreciationMethod'], depMap), + depreciationRate: toDouble( + json['depreciation_rate'] ?? json['depreciationRate'], + ) ?? + toDouble(depMap['depreciation_rate']), + usefulLifeYears: toInt( + json['useful_life_years'] ?? json['usefulLifeYears'], + ) ?? + toInt(depMap['useful_life_years']), + yearsElapsed: toDouble( + json['years_elapsed'] ?? json['yearsElapsed'], + ) ?? + toDouble(depMap['years_elapsed']), + salvageValue: toDouble( + json['salvage_value'] ?? json['salvageValue'], + ) ?? + toDouble(depMap['salvage_value']), + annualDepreciation: toDouble( + json['annual_depreciation'] ?? json['annualDepreciation'], + ) ?? + toDouble(depMap['annual_depreciation']), + accumulatedDepreciation: toDouble( + json['accumulated_depreciation'] ?? json['accumulatedDepreciation'], + ) ?? + toDouble(depMap['accumulated_depreciation']), + bookValue: toDouble( + json['book_value'] ?? json['bookValue'] ?? json['nbv'], + ) ?? + toDouble(depMap['book_value']), + ); + } +} + +class DepreciationReportQuery { + const DepreciationReportQuery({ + this.page = 1, + this.limit = 20, + this.search, + this.plantId, + this.itemCategoryId, + this.itemSubcategoryId, + this.departmentId, + this.status, + this.depreciationMethod, + this.isActive, + this.asOfDate, + this.purchaseDateFrom, + this.purchaseDateTo, + }); + + final int page; + final int limit; + final String? search; + final String? plantId; + final String? itemCategoryId; + final String? itemSubcategoryId; + final String? departmentId; + final String? status; + final String? depreciationMethod; + final bool? isActive; + final DateTime? asOfDate; + final DateTime? purchaseDateFrom; + final DateTime? purchaseDateTo; + + bool get hasActiveFilter => + (search?.isNotEmpty ?? false) || + (plantId?.isNotEmpty ?? false) || + (itemCategoryId?.isNotEmpty ?? false) || + (itemSubcategoryId?.isNotEmpty ?? false) || + (departmentId?.isNotEmpty ?? false) || + (status?.isNotEmpty ?? false) || + (depreciationMethod?.isNotEmpty ?? false) || + isActive != null || + asOfDate != null || + purchaseDateFrom != null || + purchaseDateTo != null; + + DepreciationReportQuery copyWith({ + int? page, + int? limit, + String? search, + String? plantId, + String? itemCategoryId, + String? itemSubcategoryId, + String? departmentId, + String? status, + String? depreciationMethod, + bool? isActive, + DateTime? asOfDate, + DateTime? purchaseDateFrom, + DateTime? purchaseDateTo, + bool clearSearch = false, + bool clearPlantId = false, + bool clearItemCategoryId = false, + bool clearItemSubcategoryId = false, + bool clearDepartmentId = false, + bool clearStatus = false, + bool clearDepreciationMethod = false, + bool clearIsActive = false, + bool clearAsOfDate = false, + bool clearPurchaseDateFrom = false, + bool clearPurchaseDateTo = false, + }) { + return DepreciationReportQuery( + page: page ?? this.page, + limit: limit ?? this.limit, + search: clearSearch ? null : search ?? this.search, + plantId: clearPlantId ? null : plantId ?? this.plantId, + itemCategoryId: clearItemCategoryId + ? null + : itemCategoryId ?? this.itemCategoryId, + itemSubcategoryId: clearItemSubcategoryId + ? null + : itemSubcategoryId ?? this.itemSubcategoryId, + departmentId: + clearDepartmentId ? null : departmentId ?? this.departmentId, + status: clearStatus ? null : status ?? this.status, + depreciationMethod: clearDepreciationMethod + ? null + : depreciationMethod ?? this.depreciationMethod, + isActive: clearIsActive ? null : isActive ?? this.isActive, + asOfDate: clearAsOfDate ? null : asOfDate ?? this.asOfDate, + purchaseDateFrom: clearPurchaseDateFrom + ? null + : purchaseDateFrom ?? this.purchaseDateFrom, + purchaseDateTo: + clearPurchaseDateTo ? null : purchaseDateTo ?? this.purchaseDateTo, + ); + } +} + +class DepreciationReportPage { + const DepreciationReportPage({ + required this.items, + required this.page, + required this.limit, + required this.total, + required this.totalPages, + this.asOfDate, + this.summary = const DepreciationReportSummary(), + }); + + final List items; + final int page; + final int limit; + final int total; + final int totalPages; + final DateTime? asOfDate; + final DepreciationReportSummary summary; +} diff --git a/lib/modules/reports/domain/entities/report_definition.dart b/lib/modules/reports/domain/entities/report_definition.dart new file mode 100644 index 0000000..de57917 --- /dev/null +++ b/lib/modules/reports/domain/entities/report_definition.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +/// Catalog entry for a report on the Reports hub. +/// Add new reports here — hub + router pick them up automatically. +class ReportDefinition { + const ReportDefinition({ + required this.id, + required this.title, + required this.description, + required this.category, + required this.routeKey, + required this.icon, + this.accentColor = const Color(0xFF2563EB), + }); + + final String id; + final String title; + final String description; + final String category; + final String routeKey; + final IconData icon; + final Color accentColor; +} + +ReportDefinition? reportDefinitionByRouteKey(String routeKey) { + for (final def in reportDefinitions) { + if (def.routeKey == routeKey) return def; + } + return null; +} + +List get reportCategories { + final seen = {}; + final categories = []; + for (final def in reportDefinitions) { + if (seen.add(def.category)) categories.add(def.category); + } + return categories; +} + +const reportDefinitions = [ + ReportDefinition( + id: 'depreciation', + title: 'Depreciation Report', + description: + 'Track purchase cost, accumulated depreciation, and current book value by asset.', + category: 'Assets', + routeKey: 'depreciation', + icon: Icons.trending_down_outlined, + accentColor: Color(0xFF2563EB), + ), + // Future reports (uncomment / add when ready): + // ReportDefinition( + // id: 'asset-register', + // title: 'Asset Register', + // description: 'Full inventory of assets with status and location.', + // category: 'Assets', + // routeKey: 'asset-register', + // icon: Icons.inventory_2_outlined, + // accentColor: Color(0xFF16A34A), + // ), +]; diff --git a/lib/modules/reports/domain/repositories/reports_repository.dart b/lib/modules/reports/domain/repositories/reports_repository.dart new file mode 100644 index 0000000..272b38f --- /dev/null +++ b/lib/modules/reports/domain/repositories/reports_repository.dart @@ -0,0 +1,15 @@ +import '../../../../core/network/api_handler.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../entities/depreciation_report.dart'; + +abstract class ReportsRepository { + Future> getAssetReportFilters(); + + Future> getDepreciationReport( + DepreciationReportQuery query, + ); + + Future> exportAssetReport( + DepreciationReportQuery query, + ); +} diff --git a/lib/modules/reports/presentation/providers/depreciation_report_provider.dart b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart new file mode 100644 index 0000000..6940846 --- /dev/null +++ b/lib/modules/reports/presentation/providers/depreciation_report_provider.dart @@ -0,0 +1,306 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/constants/app_constants.dart'; +import '../../../../shared/models/export_file_result.dart'; +import '../../data/repositories/reports_repository_impl.dart'; +import '../../domain/entities/depreciation_report.dart'; + +class DepreciationReportState { + const DepreciationReportState({ + this.items = const [], + this.filters = const DepreciationReportFilters(), + this.query = const DepreciationReportQuery( + limit: AppConstants.defaultPageSize, + ), + this.total = 0, + this.totalPages = 1, + this.asOfDate, + this.summary = const DepreciationReportSummary(), + this.isRefreshing = false, + this.isExporting = false, + this.actionError, + this.actionSuccess, + }); + + final List items; + final DepreciationReportFilters filters; + final DepreciationReportQuery query; + final int total; + final int totalPages; + final DateTime? asOfDate; + final DepreciationReportSummary summary; + final bool isRefreshing; + final bool isExporting; + final String? actionError; + final String? actionSuccess; + + DepreciationReportState copyWith({ + List? items, + DepreciationReportFilters? filters, + DepreciationReportQuery? query, + int? total, + int? totalPages, + DateTime? asOfDate, + DepreciationReportSummary? summary, + bool? isRefreshing, + bool? isExporting, + String? actionError, + String? actionSuccess, + bool clearMessages = false, + bool clearAsOfDate = false, + }) { + return DepreciationReportState( + items: items ?? this.items, + filters: filters ?? this.filters, + query: query ?? this.query, + total: total ?? this.total, + totalPages: totalPages ?? this.totalPages, + asOfDate: clearAsOfDate ? null : asOfDate ?? this.asOfDate, + summary: summary ?? this.summary, + isRefreshing: isRefreshing ?? this.isRefreshing, + isExporting: isExporting ?? this.isExporting, + actionError: clearMessages ? null : actionError ?? this.actionError, + actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess, + ); + } +} + +final depreciationReportProvider = AsyncNotifierProvider< + DepreciationReportNotifier, DepreciationReportState>( + DepreciationReportNotifier.new, +); + +class DepreciationReportNotifier + extends AsyncNotifier { + @override + Future build() async { + ref.keepAlive(); + return _load( + const DepreciationReportQuery(limit: AppConstants.defaultPageSize), + ); + } + + Future _load(DepreciationReportQuery query) async { + final repository = ref.read(reportsRepositoryProvider); + final filtersResult = await repository.getAssetReportFilters(); + final listResult = await repository.getDepreciationReport(query); + + if (listResult.failure != null) throw listResult.failure!; + + final page = listResult.data!; + return DepreciationReportState( + items: page.items, + filters: filtersResult.data ?? const DepreciationReportFilters(), + query: query.copyWith(page: page.page, limit: page.limit), + total: page.total, + totalPages: page.totalPages, + asOfDate: page.asOfDate, + summary: page.summary, + ); + } + + Future refresh() async { + final current = state.valueOrNull ?? const DepreciationReportState(); + state = AsyncData(current.copyWith(isRefreshing: true, clearMessages: true)); + try { + state = AsyncData(await _load(current.query)); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + Future applyQuery(DepreciationReportQuery query) async { + final previous = state.valueOrNull; + if (previous == null) { + state = const AsyncLoading(); + } else { + state = AsyncData(previous.copyWith(clearMessages: true)); + } + + try { + final next = await _load(query); + state = AsyncData(next); + } catch (e, st) { + if (previous != null) { + state = AsyncData( + previous.copyWith(actionError: e.toString()), + ); + } else { + state = AsyncError(e, st); + } + } + } + + Future setSearch(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + final trimmed = value?.trim(); + await applyQuery( + current.copyWith( + page: 1, + search: trimmed, + clearSearch: trimmed == null || trimmed.isEmpty, + ), + ); + } + + Future setPlantId(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + plantId: value, + clearPlantId: value == null || value.isEmpty, + ), + ); + } + + Future setItemCategoryId(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + itemCategoryId: value, + clearItemCategoryId: value == null || value.isEmpty, + clearItemSubcategoryId: true, + ), + ); + } + + Future setItemSubcategoryId(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + itemSubcategoryId: value, + clearItemSubcategoryId: value == null || value.isEmpty, + ), + ); + } + + Future setDepartmentId(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + departmentId: value, + clearDepartmentId: value == null || value.isEmpty, + ), + ); + } + + Future setStatus(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + status: value, + clearStatus: value == null || value.isEmpty, + ), + ); + } + + Future setDepreciationMethod(String? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + depreciationMethod: value, + clearDepreciationMethod: value == null || value.isEmpty, + ), + ); + } + + Future setIsActive(bool? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + isActive: value, + clearIsActive: value == null, + ), + ); + } + + Future setAsOfDate(DateTime? value) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + asOfDate: value, + clearAsOfDate: value == null, + ), + ); + } + + Future setPurchaseDateRange(DateTime? from, DateTime? to) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery( + current.copyWith( + page: 1, + purchaseDateFrom: from, + purchaseDateTo: to, + clearPurchaseDateFrom: from == null, + clearPurchaseDateTo: to == null, + ), + ); + } + + Future setPage(int page) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery(current.copyWith(page: page)); + } + + Future setPageSize(int limit) async { + final current = state.valueOrNull?.query ?? + const DepreciationReportQuery(limit: AppConstants.defaultPageSize); + await applyQuery(current.copyWith(page: 1, limit: limit)); + } + + Future resetFilters() async { + await applyQuery( + DepreciationReportQuery( + limit: state.valueOrNull?.query.limit ?? AppConstants.defaultPageSize, + ), + ); + } + + Future exportReport() async { + final current = state.valueOrNull; + if (current == null) return null; + + state = AsyncData(current.copyWith(isExporting: true, clearMessages: true)); + final result = await ref + .read(reportsRepositoryProvider) + .exportAssetReport(current.query); + + final latest = state.valueOrNull ?? current; + if (result.failure != null) { + state = AsyncData( + latest.copyWith( + isExporting: false, + actionError: result.failure!.message, + ), + ); + return null; + } + + state = AsyncData( + latest.copyWith( + isExporting: false, + actionSuccess: 'Export ready', + ), + ); + return result.data; + } +} diff --git a/lib/modules/reports/presentation/screens/depreciation_report_screen.dart b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart new file mode 100644 index 0000000..d0dc7f3 --- /dev/null +++ b/lib/modules/reports/presentation/screens/depreciation_report_screen.dart @@ -0,0 +1,744 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/enums.dart'; +import '../../../../core/constants/route_constants.dart'; +import '../../../../core/errors/failure.dart'; +import '../../../../core/utils/formatters.dart'; +import '../../../../core/utils/responsive_utils.dart'; +import '../../../../shared/providers/permissions_provider.dart'; +import '../../../../shared/utils/file_download_helper.dart'; +import '../../../rbac/presentation/widgets/rbac_widgets.dart'; +import '../../../../shared/widgets/app_data_table.dart'; +import '../../../../shared/widgets/app_date_popup.dart'; +import '../../../../shared/widgets/app_date_range_popup.dart'; +import '../../../../shared/widgets/app_dropdown.dart'; +import '../../../../shared/widgets/app_empty_state.dart'; +import '../../../../shared/widgets/app_filter_date_field.dart'; +import '../../../../shared/widgets/app_loading_view.dart'; +import '../../../../shared/widgets/app_pagination.dart'; +import '../../../../shared/widgets/app_searchable_dropdown.dart'; +import '../../../../shared/widgets/app_table_shell.dart'; +import '../../../../shared/widgets/error_view.dart'; +import '../../../../shared/widgets/page_header.dart'; +import '../../domain/entities/depreciation_report.dart'; +import '../providers/depreciation_report_provider.dart'; + +class DepreciationReportScreen extends ConsumerStatefulWidget { + const DepreciationReportScreen({super.key}); + + @override + ConsumerState createState() => + _DepreciationReportScreenState(); +} + +class _DepreciationReportScreenState + extends ConsumerState { + final _searchController = TextEditingController(); + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _export() async { + final file = + await ref.read(depreciationReportProvider.notifier).exportReport(); + if (!mounted) return; + + if (file == null) { + final error = + ref.read(depreciationReportProvider).valueOrNull?.actionError; + if (error != null) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error))); + } + return; + } + + final saved = await downloadFile( + bytes: file.bytes, + fileName: file.fileName, + ); + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + saved ? 'Downloaded ${file.fileName}' : 'Export cancelled', + ), + ), + ); + } + + Future _pickAsOfDate(DepreciationReportQuery query) async { + final now = DateTime.now(); + final picked = await showAppDatePopup( + context: context, + initialDate: query.asOfDate ?? now, + firstDate: DateTime(now.year - 20), + lastDate: DateTime(now.year + 1), + helpText: 'As of date', + ); + if (picked == null) return; + ref.read(depreciationReportProvider.notifier).setAsOfDate(picked); + } + + Future _pickPurchaseDateRange(DepreciationReportQuery query) async { + final now = DateTime.now(); + final initial = (query.purchaseDateFrom != null && + query.purchaseDateTo != null) + ? DateTimeRange( + start: query.purchaseDateFrom!, + end: query.purchaseDateTo!, + ) + : null; + + final picked = await showAppDateRangePopup( + context: context, + firstDate: DateTime(now.year - 30), + lastDate: DateTime(now.year + 1), + initialDateRange: initial, + helpText: 'Purchase date range', + ); + if (picked == null) return; + + ref.read(depreciationReportProvider.notifier).setPurchaseDateRange( + DateTime(picked.start.year, picked.start.month, picked.start.day), + DateTime(picked.end.year, picked.end.month, picked.end.day), + ); + } + + @override + Widget build(BuildContext context) { + final reportAsync = ref.watch(depreciationReportProvider); + final canExport = ref.can('reports', PermissionAction.export); + + ref.listen(depreciationReportProvider, (prev, next) { + final error = next.valueOrNull?.actionError; + if (error != null && error != prev?.valueOrNull?.actionError) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(error))); + } + }); + + return Padding( + padding: const EdgeInsets.all(24), + child: reportAsync.when( + loading: () => + const AppLoadingView(message: 'Loading depreciation report...'), + error: (error, _) => ErrorView.fromFailure( + error is Failure ? error : Failure.unknown(message: error.toString()), + onRetry: () => ref.invalidate(depreciationReportProvider), + ), + data: (state) { + final notifier = ref.read(depreciationReportProvider.notifier); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PageHeader( + title: 'Depreciation Report', + subtitle: + 'Purchase cost, accumulated depreciation, and book value', + leading: IconButton( + tooltip: 'Back to reports', + onPressed: () => context.go(RouteConstants.reports), + icon: const Icon(Icons.arrow_back), + ), + actions: [ + if (canExport) + OutlinedButton.icon( + onPressed: state.isExporting ? null : _export, + icon: state.isExporting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text( + state.isExporting ? 'Exporting...' : 'Export CSV', + ), + ), + ], + ), + _SummaryStrip(summary: state.summary, asOfDate: state.asOfDate), + const SizedBox(height: 16), + Expanded( + child: AppTableShell( + toolbar: LayoutBuilder( + builder: (context, constraints) { + return _FiltersBar( + searchController: _searchController, + filters: state.filters, + query: state.query, + wrapped: constraints.maxWidth < 1100, + onSearch: notifier.setSearch, + onPlantChanged: notifier.setPlantId, + onCategoryChanged: notifier.setItemCategoryId, + onSubcategoryChanged: notifier.setItemSubcategoryId, + onDepartmentChanged: notifier.setDepartmentId, + onStatusChanged: notifier.setStatus, + onMethodChanged: notifier.setDepreciationMethod, + onIsActiveChanged: notifier.setIsActive, + onPickAsOfDate: () => _pickAsOfDate(state.query), + onClearAsOfDate: () => notifier.setAsOfDate(null), + onPickPurchaseRange: () => + _pickPurchaseDateRange(state.query), + onClearPurchaseRange: () => + notifier.setPurchaseDateRange(null, null), + onReset: () { + _searchController.clear(); + notifier.resetFilters(); + }, + ); + }, + ), + footer: AppPagination( + currentPage: state.query.page, + totalPages: state.totalPages, + totalItems: state.total, + pageSize: state.query.limit, + itemLabel: 'assets', + onPageChanged: notifier.setPage, + onPageSizeChanged: notifier.setPageSize, + ), + child: RefreshIndicator( + onRefresh: notifier.refresh, + child: state.items.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox( + height: 260, + child: AppEmptyState( + title: 'No depreciation data', + description: + 'Try adjusting filters or the as-of date.', + icon: Icons.trending_down_outlined, + ), + ), + ], + ) + : context.isMobile + ? _MobileList(items: state.items) + : _ReportTable(items: state.items), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _SummaryStrip extends StatelessWidget { + const _SummaryStrip({required this.summary, this.asOfDate}); + + final DepreciationReportSummary summary; + final DateTime? asOfDate; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final cards = [ + ( + label: 'Assets', + value: summary.assetCount.toString(), + icon: Icons.inventory_2_outlined, + color: const Color(0xFF2563EB), + ), + ( + label: 'Purchase Cost', + value: CurrencyFormatter.format(summary.totalPurchaseCost), + icon: Icons.payments_outlined, + color: const Color(0xFF16A34A), + ), + ( + label: 'Accumulated', + value: CurrencyFormatter.format(summary.totalAccumulatedDepreciation), + icon: Icons.trending_down_outlined, + color: const Color(0xFFEA580C), + ), + ( + label: 'Book Value', + value: CurrencyFormatter.format(summary.totalBookValue), + icon: Icons.account_balance_wallet_outlined, + color: const Color(0xFF7C3AED), + ), + ( + label: 'Annual', + value: CurrencyFormatter.format(summary.totalAnnualDepreciation), + icon: Icons.calendar_today_outlined, + color: const Color(0xFF0891B2), + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (asOfDate != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'As of ${DateFormatter.displayDate(asOfDate)}', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxWidth < 900; + final cardWidth = isCompact + ? constraints.maxWidth + : (constraints.maxWidth - 48) / 5; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: cards + .map( + (card) => SizedBox( + width: cardWidth.clamp(160, constraints.maxWidth), + child: RbacStatCard( + label: card.label, + value: card.value, + icon: card.icon, + color: card.color, + ), + ), + ) + .toList(), + ); + }, + ), + ], + ); + } +} + +class _FiltersBar extends StatelessWidget { + const _FiltersBar({ + required this.searchController, + required this.filters, + required this.query, + required this.wrapped, + required this.onSearch, + required this.onPlantChanged, + required this.onCategoryChanged, + required this.onSubcategoryChanged, + required this.onDepartmentChanged, + required this.onStatusChanged, + required this.onMethodChanged, + required this.onIsActiveChanged, + required this.onPickAsOfDate, + required this.onClearAsOfDate, + required this.onPickPurchaseRange, + required this.onClearPurchaseRange, + required this.onReset, + }); + + final TextEditingController searchController; + final DepreciationReportFilters filters; + final DepreciationReportQuery query; + final bool wrapped; + final ValueChanged onSearch; + final ValueChanged onPlantChanged; + final ValueChanged onCategoryChanged; + final ValueChanged onSubcategoryChanged; + final ValueChanged onDepartmentChanged; + final ValueChanged onStatusChanged; + final ValueChanged onMethodChanged; + final ValueChanged onIsActiveChanged; + final VoidCallback onPickAsOfDate; + final VoidCallback onClearAsOfDate; + final VoidCallback onPickPurchaseRange; + final VoidCallback onClearPurchaseRange; + final VoidCallback onReset; + + @override + Widget build(BuildContext context) { + final subcategoryOptions = query.itemCategoryId == null + ? filters.subcategories + : filters.subcategories + .where((o) => o.parentId == query.itemCategoryId) + .toList(); + + final searchField = TextField( + controller: searchController, + onChanged: onSearch, + decoration: InputDecoration( + labelText: 'Search', + hintText: 'Search asset code or name...', + prefixIcon: const Icon(Icons.search), + isDense: true, + suffixIcon: searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + searchController.clear(); + onSearch(''); + }, + ) + : null, + ), + ); + + Widget dropdown({ + required String label, + required String? value, + required List options, + required ValueChanged onChanged, + }) { + return AppSearchableDropdown( + label: label, + value: value, + searchHint: 'Search $label...', + isDense: true, + options: [ + const AppDropdownOption(value: null, label: 'All'), + ...options.map( + (option) => AppDropdownOption( + value: option.value, + label: option.label, + ), + ), + ], + onChanged: onChanged, + ); + } + + final plant = dropdown( + label: 'Plant', + value: query.plantId, + options: filters.plants, + onChanged: onPlantChanged, + ); + final category = dropdown( + label: 'Category', + value: query.itemCategoryId, + options: filters.categories, + onChanged: onCategoryChanged, + ); + final subcategory = dropdown( + label: 'Subcategory', + value: query.itemSubcategoryId, + options: subcategoryOptions, + onChanged: onSubcategoryChanged, + ); + final department = dropdown( + label: 'Department', + value: query.departmentId, + options: filters.departments, + onChanged: onDepartmentChanged, + ); + final status = dropdown( + label: 'Status', + value: query.status, + options: filters.statuses, + onChanged: onStatusChanged, + ); + final method = dropdown( + label: 'Method', + value: query.depreciationMethod, + options: filters.depreciationMethods, + onChanged: onMethodChanged, + ); + + final active = AppSearchableDropdown( + label: 'Active', + value: query.isActive, + searchHint: 'Filter active...', + isDense: true, + options: const [ + AppDropdownOption(value: null, label: 'All'), + AppDropdownOption(value: true, label: 'Active'), + AppDropdownOption(value: false, label: 'Inactive'), + ], + onChanged: onIsActiveChanged, + ); + + final asOfEmpty = query.asOfDate == null; + final asOfValue = asOfEmpty + ? '' + : DateFormatter.displayDate(query.asOfDate); + + final purchaseEmpty = + query.purchaseDateFrom == null && query.purchaseDateTo == null; + final purchaseValue = purchaseEmpty + ? '' + : '${DateFormatter.displayDate(query.purchaseDateFrom)} – ${DateFormatter.displayDate(query.purchaseDateTo)}'; + + final asOfField = AppFilterDateField( + label: 'As of date', + value: asOfValue, + placeholder: 'Select date', + icon: Icons.calendar_today_outlined, + isEmpty: asOfEmpty, + onTap: onPickAsOfDate, + onClear: asOfEmpty ? null : onClearAsOfDate, + ); + + final purchaseField = AppFilterDateField( + label: 'Purchase dates', + value: purchaseValue, + placeholder: 'Select range', + icon: Icons.date_range_outlined, + isEmpty: purchaseEmpty, + onTap: onPickPurchaseRange, + onClear: purchaseEmpty ? null : onClearPurchaseRange, + ); + + final reset = TextButton( + onPressed: query.hasActiveFilter ? onReset : null, + child: const Text('Reset'), + ); + + if (wrapped) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + searchField, + const SizedBox(height: 12), + plant, + const SizedBox(height: 12), + category, + const SizedBox(height: 12), + subcategory, + const SizedBox(height: 12), + department, + const SizedBox(height: 12), + status, + const SizedBox(height: 12), + method, + const SizedBox(height: 12), + active, + const SizedBox(height: 12), + asOfField, + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: purchaseField), + reset, + ], + ), + ], + ); + } + + // Consistent 4-column grid so every row lines up vertically. + Widget row(List cells) { + assert(cells.length == 4); + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < cells.length; i++) ...[ + if (i > 0) const SizedBox(width: 12), + Expanded(child: cells[i]), + ], + ], + ); + } + + return Column( + children: [ + row([searchField, plant, category, subcategory]), + const SizedBox(height: 12), + row([department, status, method, active]), + const SizedBox(height: 12), + row([ + asOfField, + purchaseField, + const SizedBox.shrink(), + Align(alignment: Alignment.centerRight, child: reset), + ]), + ], + ); + } +} + +class _ReportTable extends StatelessWidget { + const _ReportTable({required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + return AppDataTable( + wrapInCard: false, + rows: items, + columns: [ + AppDataColumn( + label: 'Asset Code', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.assetCode), + ), + AppDataColumn( + label: 'Asset Name', + flex: 3, + cellBuilder: (_, row) => AppTableCell.text(row.assetName), + ), + AppDataColumn( + label: 'Category', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.categoryName), + ), + AppDataColumn( + label: 'Plant', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.plantName), + ), + AppDataColumn( + label: 'Purchase Date', + flex: 2, + cellBuilder: (_, row) => + AppTableCell.text(DateFormatter.displayDate(row.purchaseDate)), + ), + AppDataColumn( + label: 'Purchase Cost', + flex: 2, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.purchaseCost), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + label: 'Method', + flex: 2, + cellBuilder: (_, row) => AppTableCell.text(row.depreciationMethod), + ), + AppDataColumn( + label: 'Rate %', + flex: 1, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableCell.text( + row.depreciationRate?.toStringAsFixed(2), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + label: 'Annual', + flex: 2, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.annualDepreciation), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + label: 'Accumulated', + flex: 2, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.accumulatedDepreciation), + textAlign: TextAlign.right, + ), + ), + AppDataColumn( + label: 'Book Value', + flex: 2, + alignment: Alignment.centerRight, + cellBuilder: (_, row) => AppTableCell.text( + CurrencyFormatter.format(row.bookValue), + textAlign: TextAlign.right, + ), + ), + ], + ); + } +} + +class _MobileList extends StatelessWidget { + const _MobileList({required this.items}); + + final List items; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final row = items[index]; + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.12), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.assetName ?? row.assetCode ?? 'Asset', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (row.assetCode != null) ...[ + const SizedBox(height: 2), + Text( + row.assetCode!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _metric( + context, + 'Cost', + CurrencyFormatter.format(row.purchaseCost), + ), + _metric( + context, + 'Accumulated', + CurrencyFormatter.format(row.accumulatedDepreciation), + ), + _metric( + context, + 'Book Value', + CurrencyFormatter.format(row.bookValue), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + Widget _metric(BuildContext context, String label, String value) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text(value, style: theme.textTheme.bodyMedium), + ], + ); + } +} diff --git a/lib/modules/reports/presentation/screens/reports_hub_screen.dart b/lib/modules/reports/presentation/screens/reports_hub_screen.dart new file mode 100644 index 0000000..75d745c --- /dev/null +++ b/lib/modules/reports/presentation/screens/reports_hub_screen.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../../core/constants/enums.dart'; +import '../../../../core/constants/route_constants.dart'; +import '../../../../shared/providers/permissions_provider.dart'; +import '../../../../shared/widgets/app_hover_effect.dart'; +import '../../domain/entities/report_definition.dart'; + +/// Reports landing — same hub flow as Master Data, different visual design. +/// Uses full-width list rows (icon + title + description) instead of square tiles. +class ReportsHubScreen extends ConsumerWidget { + const ReportsHubScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final canView = ref.can('reports', PermissionAction.read); + final categories = reportCategories; + + return SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Reports', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Browse asset and operational reports by category.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 28), + if (!canView) + Padding( + padding: const EdgeInsets.only(top: 48), + child: Center( + child: Text( + 'You do not have permission to view reports.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ) + else + ...categories.map((category) { + final items = reportDefinitions + .where((def) => def.category == category) + .toList(); + return Padding( + padding: const EdgeInsets.only(bottom: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category.toUpperCase(), + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + ...items.map( + (def) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _ReportListCard( + definition: def, + onTap: () => context.push( + RouteConstants.reportDetail(def.routeKey), + ), + ), + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +class _ReportListCard extends StatelessWidget { + const _ReportListCard({ + required this.definition, + required this.onTap, + }); + + final ReportDefinition definition; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = definition.accentColor; + + return AppHoverEffect( + onTap: onTap, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 4, + decoration: BoxDecoration( + color: accent, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(12), + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(definition.icon, color: accent, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + definition.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + definition.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/reports/presentation/screens/reports_screen.dart b/lib/modules/reports/presentation/screens/reports_screen.dart index 176c4e0..c1ca0d3 100644 --- a/lib/modules/reports/presentation/screens/reports_screen.dart +++ b/lib/modules/reports/presentation/screens/reports_screen.dart @@ -1,6 +1 @@ -import '../../../../shared/widgets/placeholder_screen.dart'; - -class ReportsScreen extends PlaceholderScreen { - const ReportsScreen({super.key}) - : super(title: 'Reports', description: 'Asset and operational reports'); -} +export 'reports_hub_screen.dart'; diff --git a/lib/modules/settings/presentation/providers/settings_provider.dart b/lib/modules/settings/presentation/providers/settings_provider.dart index 280dd68..a28a692 100644 --- a/lib/modules/settings/presentation/providers/settings_provider.dart +++ b/lib/modules/settings/presentation/providers/settings_provider.dart @@ -82,6 +82,7 @@ class AppSettingsNotifier extends StateNotifier { final SettingsRepository _repository; final FaviconStore _faviconStore; final Future Function(String? logoUrl, String? companyName) _syncAppLogo; + Future? _companyProfileInFlight; Future _syncMainLogo(CompanyProfileSettings profile) async { final logo = resolveMediaUrl(profile.logoUrl); @@ -98,13 +99,25 @@ class AppSettingsNotifier extends StateNotifier { await _syncMainLogo(state.companyProfile); } - Future refreshCompanyProfile() async { - final result = await _repository.fetchCompanyProfile(); - if (result.failure == null && result.data != null) { - state = state.copyWith(companyProfile: result.data!); - await _syncMainLogo(result.data!); - } - return result.failure; + Future refreshCompanyProfile() { + final existing = _companyProfileInFlight; + if (existing != null) return existing; + + final future = () async { + try { + final result = await _repository.fetchCompanyProfile(); + if (result.failure == null && result.data != null) { + state = state.copyWith(companyProfile: result.data!); + await _syncMainLogo(result.data!); + } + return result.failure; + } finally { + _companyProfileInFlight = null; + } + }(); + + _companyProfileInFlight = future; + return future; } Future refreshEmailSettings() async { diff --git a/lib/modules/settings/presentation/screens/settings_screen.dart b/lib/modules/settings/presentation/screens/settings_screen.dart index a0a8e8b..348d7aa 100644 --- a/lib/modules/settings/presentation/screens/settings_screen.dart +++ b/lib/modules/settings/presentation/screens/settings_screen.dart @@ -14,42 +14,45 @@ class SettingsScreen extends StatelessWidget { final visiblePhase1 = visiblePhase1SettingsSections; final visiblePhase2 = visiblePhase2SettingsSections; - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: context.contentMaxWidth), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const PageHeader( - title: 'Settings', - subtitle: - 'Configure company, security, assets, and system behavior', - ), - if (visiblePhase1.isNotEmpty) - SettingsSectionGrid( - sections: visiblePhase1, - onSectionTap: (section) => context.go(section.route), - ), - if (visiblePhase2.isNotEmpty) ...[ - const SizedBox(height: 24), - Text( - 'Coming in Phase 2', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 16), - SettingsSectionGrid( - sections: visiblePhase2, - enabled: false, - badge: 'Phase 2', - onSectionTap: (_) {}, + return Material( + color: Theme.of(context).colorScheme.surface, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: context.contentMaxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const PageHeader( + title: 'Settings', + subtitle: + 'Configure company, security, assets, and system behavior', ), + if (visiblePhase1.isNotEmpty) + SettingsSectionGrid( + sections: visiblePhase1, + onSectionTap: (section) => context.go(section.route), + ), + if (visiblePhase2.isNotEmpty) ...[ + const SizedBox(height: 24), + Text( + 'Coming in Phase 2', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 16), + SettingsSectionGrid( + sections: visiblePhase2, + enabled: false, + badge: 'Phase 2', + onSectionTap: (_) {}, + ), + ], ], - ], + ), ), ), ), diff --git a/lib/modules/settings/presentation/widgets/settings_widgets.dart b/lib/modules/settings/presentation/widgets/settings_widgets.dart index 992d65c..ff6e53c 100644 --- a/lib/modules/settings/presentation/widgets/settings_widgets.dart +++ b/lib/modules/settings/presentation/widgets/settings_widgets.dart @@ -24,32 +24,37 @@ class SettingsPageLayout extends StatelessWidget { @override Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back), - tooltip: 'Back to Settings', - onPressed: () => context.go('/settings'), - ), - Expanded( - child: PageHeader( - title: title, - subtitle: subtitle, - actions: actions, + final theme = Theme.of(context); + + return Material( + color: theme.colorScheme.surface, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Back to Settings', + onPressed: () => context.go('/settings'), ), - ), - ], - ), - child, - ], + Expanded( + child: PageHeader( + title: title, + subtitle: subtitle, + actions: actions, + ), + ), + ], + ), + child, + ], + ), ), ), ), diff --git a/lib/shared/routes/app_router.dart b/lib/shared/routes/app_router.dart index 8e155ab..04202c5 100644 --- a/lib/shared/routes/app_router.dart +++ b/lib/shared/routes/app_router.dart @@ -16,7 +16,9 @@ import '../../modules/auth/presentation/screens/verify_otp_screen.dart'; import '../../modules/master_data/domain/entities/master_definition.dart'; import '../../modules/master_data/presentation/screens/master_list_screen.dart'; import '../../modules/master_data/presentation/screens/masters_hub_screen.dart'; -import '../../modules/reports/presentation/screens/reports_screen.dart'; +import '../../modules/reports/domain/entities/report_definition.dart'; +import '../../modules/reports/presentation/screens/depreciation_report_screen.dart'; +import '../../modules/reports/presentation/screens/reports_hub_screen.dart'; import '../../modules/audit/presentation/screens/audit_logs_screen.dart'; import '../../modules/branch/presentation/screens/branch_form_screen.dart'; import '../../modules/branch/presentation/screens/branch_list_screen.dart'; @@ -310,7 +312,22 @@ final routerProvider = Provider((ref) { GoRoute( path: RouteConstants.reports, pageBuilder: (context, state) => - shellPage(state, const ReportsScreen()), + shellPage(state, const ReportsHubScreen()), + routes: [ + ...reportDefinitions.map( + (def) => GoRoute( + path: def.routeKey, + pageBuilder: (context, state) { + // Map known report keys to screens; extend as reports grow. + final Widget screen = switch (def.id) { + 'depreciation' => const DepreciationReportScreen(), + _ => const ReportsHubScreen(), + }; + return shellPage(state, screen); + }, + ), + ), + ], ), GoRoute( path: RouteConstants.auditLogs, @@ -321,40 +338,47 @@ final routerProvider = Provider((ref) { path: RouteConstants.settings, pageBuilder: (context, state) => shellPage(state, const SettingsScreen()), - routes: [ - GoRoute( - path: 'general', - builder: (context, state) => const GeneralSettingsScreen(), - ), - GoRoute( - path: 'company-profile', - builder: (context, state) => const CompanyProfileSettingsScreen(), - ), - GoRoute( - path: 'appearance', - builder: (context, state) => const AppearanceSettingsScreen(), - ), - GoRoute( - path: 'roles', - builder: (context, state) => const RolesPermissionsSettingsScreen(), - ), - GoRoute( - path: 'asset', - builder: (context, state) => const AssetSettingsScreen(), - ), - GoRoute( - path: 'notifications', - builder: (context, state) => const NotificationSettingsScreen(), - ), - GoRoute( - path: 'email', - builder: (context, state) => const EmailConfigurationScreen(), - ), - GoRoute( - path: 'security', - builder: (context, state) => const SecuritySettingsScreen(), - ), - ], + ), + // Sibling routes (not nested) so Settings hub is not stacked underneath. + GoRoute( + path: RouteConstants.settingsGeneral, + pageBuilder: (context, state) => + shellPage(state, const GeneralSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsCompanyProfile, + pageBuilder: (context, state) => + shellPage(state, const CompanyProfileSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsAppearance, + pageBuilder: (context, state) => + shellPage(state, const AppearanceSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsRoles, + pageBuilder: (context, state) => + shellPage(state, const RolesPermissionsSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsAsset, + pageBuilder: (context, state) => + shellPage(state, const AssetSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsNotifications, + pageBuilder: (context, state) => + shellPage(state, const NotificationSettingsScreen()), + ), + GoRoute( + path: RouteConstants.settingsEmail, + pageBuilder: (context, state) => + shellPage(state, const EmailConfigurationScreen()), + ), + GoRoute( + path: RouteConstants.settingsSecurity, + pageBuilder: (context, state) => + shellPage(state, const SecuritySettingsScreen()), ), if (DevConfig.screenPreviewEnabled) GoRoute( diff --git a/lib/shared/routes/menu_config.dart b/lib/shared/routes/menu_config.dart index be9abe4..f911f3b 100644 --- a/lib/shared/routes/menu_config.dart +++ b/lib/shared/routes/menu_config.dart @@ -116,7 +116,9 @@ List getVisibleMenuItems({ required List permissions, required UserRole role, }) { - if (isSuperAdmin(role) || permissions.contains('*')) { + // Only '*' grants every menu. Do not unlock all items by role alone — + // otherwise items like Reports appear without REPORTS:view. + if (permissions.contains('*')) { return appMenuItems; } diff --git a/lib/shared/widgets/app_data_table.dart b/lib/shared/widgets/app_data_table.dart index b5a83f7..fa665a2 100644 --- a/lib/shared/widgets/app_data_table.dart +++ b/lib/shared/widgets/app_data_table.dart @@ -89,30 +89,42 @@ class AppDataTable extends StatelessWidget { return AppCard(clipBehavior: Clip.antiAlias, child: empty); } - final table = LayoutBuilder( - builder: (context, constraints) { - return ListView( - padding: EdgeInsets.zero, - shrinkWrap: shrinkWrap, - physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, - children: [ - _TableHeaderRow( - columns: columns, - sortColumn: sortColumn, - sortAscending: sortAscending, - onSort: onSort, - ), - ...rows.map( - (row) => _TableDataRow( - columns: columns, - row: row, - ), - ), - ], - ); - }, + final header = _TableHeaderRow( + columns: columns, + sortColumn: sortColumn, + sortAscending: sortAscending, + onSort: onSort, ); + final body = ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: shrinkWrap, + physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, + itemCount: rows.length, + itemBuilder: (context, index) => _TableDataRow( + columns: columns, + row: rows[index], + ), + ); + + // Header stays fixed; only body rows scroll (when not shrink-wrapped). + final table = shrinkWrap + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + body, + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + Expanded(child: body), + ], + ); + if (!wrapInCard) return table; return AppCard( @@ -142,8 +154,19 @@ class _TableHeaderRow extends StatelessWidget { return SizedBox( height: kAppTableRowHeight, width: double.infinity, - child: ColoredBox( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), + child: DecoratedBox( + decoration: BoxDecoration( + // Opaque so scrolling rows never show through the sticky header. + color: Color.alphaBlend( + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + theme.colorScheme.surface, + ), + border: Border( + bottom: BorderSide( + color: theme.colorScheme.outline.withValues(alpha: 0.12), + ), + ), + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( diff --git a/lib/shared/widgets/app_date_popup.dart b/lib/shared/widgets/app_date_popup.dart new file mode 100644 index 0000000..67019e7 --- /dev/null +++ b/lib/shared/widgets/app_date_popup.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; + +/// Compact dialog date picker (avoids the full-page Material picker on web). +Future showAppDatePopup({ + required BuildContext context, + DateTime? initialDate, + DateTime? firstDate, + DateTime? lastDate, + String helpText = 'Select date', +}) { + final now = DateTime.now(); + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _AppDateDialog( + helpText: helpText, + initialDate: initialDate ?? now, + firstDate: firstDate ?? DateTime(now.year - 30), + lastDate: lastDate ?? DateTime(now.year + 10), + ), + ); +} + +class _AppDateDialog extends StatefulWidget { + const _AppDateDialog({ + required this.helpText, + required this.initialDate, + required this.firstDate, + required this.lastDate, + }); + + final String helpText; + final DateTime initialDate; + final DateTime firstDate; + final DateTime lastDate; + + @override + State<_AppDateDialog> createState() => _AppDateDialogState(); +} + +class _AppDateDialogState extends State<_AppDateDialog> { + late DateTime _selected; + late DateTime _displayedMonth; + + @override + void initState() { + super.initState(); + _selected = DateTime( + widget.initialDate.year, + widget.initialDate.month, + widget.initialDate.day, + ); + _displayedMonth = DateTime(_selected.year, _selected.month); + } + + void _shiftMonth(int delta) { + setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + delta, + ); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.helpText, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + ), + ], + ), + const SizedBox(height: 8), + _MonthHeader( + month: _displayedMonth, + onPrev: () => _shiftMonth(-1), + onNext: () => _shiftMonth(1), + ), + const SizedBox(height: 8), + _CalendarGrid( + month: _displayedMonth, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + selected: _selected, + onSelected: (day) => setState(() => _selected = day), + ), + const SizedBox(height: 16), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () => Navigator.of(context).pop(_selected), + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _MonthHeader extends StatelessWidget { + const _MonthHeader({ + required this.month, + required this.onPrev, + required this.onNext, + }); + + final DateTime month; + final VoidCallback onPrev; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + final title = '${months[month.month - 1]} ${month.year}'; + + return Row( + children: [ + IconButton( + onPressed: onPrev, + icon: const Icon(Icons.chevron_left), + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: onNext, + icon: const Icon(Icons.chevron_right), + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} + +class _CalendarGrid extends StatelessWidget { + const _CalendarGrid({ + required this.month, + required this.firstDate, + required this.lastDate, + required this.selected, + required this.onSelected, + }); + + final DateTime month; + final DateTime firstDate; + final DateTime lastDate; + final DateTime selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final firstOfMonth = DateTime(month.year, month.month); + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + final leading = firstOfMonth.weekday % 7; + const weekdays = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + return Column( + children: [ + Row( + children: weekdays + .map( + (d) => Expanded( + child: Center( + child: Text( + d, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 6), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: leading + daysInMonth, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemBuilder: (context, index) { + if (index < leading) return const SizedBox.shrink(); + final day = index - leading + 1; + final date = DateTime(month.year, month.month, day); + final enabled = !date.isBefore( + DateTime(firstDate.year, firstDate.month, firstDate.day), + ) && + !date.isAfter( + DateTime(lastDate.year, lastDate.month, lastDate.day), + ); + final isSelected = date.year == selected.year && + date.month == selected.month && + date.day == selected.day; + + return Material( + color: isSelected + ? theme.colorScheme.primary + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: enabled ? () => onSelected(date) : null, + child: Center( + child: Text( + '$day', + style: theme.textTheme.bodyMedium?.copyWith( + color: !enabled + ? theme.disabledColor + : isSelected + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + fontWeight: + isSelected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/shared/widgets/app_date_range_popup.dart b/lib/shared/widgets/app_date_range_popup.dart new file mode 100644 index 0000000..69e16eb --- /dev/null +++ b/lib/shared/widgets/app_date_range_popup.dart @@ -0,0 +1,343 @@ +import 'package:flutter/material.dart'; + +import '../../core/utils/formatters.dart'; + +/// Compact dialog date-range picker (avoids the full-page Material picker on web). +Future showAppDateRangePopup({ + required BuildContext context, + DateTimeRange? initialDateRange, + DateTime? firstDate, + DateTime? lastDate, + String helpText = 'Select date range', +}) { + final now = DateTime.now(); + return showDialog( + context: context, + barrierDismissible: true, + builder: (context) => _AppDateRangeDialog( + helpText: helpText, + initialDateRange: initialDateRange, + firstDate: firstDate ?? DateTime(now.year - 5), + lastDate: lastDate ?? DateTime(now.year + 1), + ), + ); +} + +class _AppDateRangeDialog extends StatefulWidget { + const _AppDateRangeDialog({ + required this.helpText, + required this.firstDate, + required this.lastDate, + this.initialDateRange, + }); + + final String helpText; + final DateTime firstDate; + final DateTime lastDate; + final DateTimeRange? initialDateRange; + + @override + State<_AppDateRangeDialog> createState() => _AppDateRangeDialogState(); +} + +class _AppDateRangeDialogState extends State<_AppDateRangeDialog> { + DateTime? _start; + DateTime? _end; + late DateTime _displayedMonth; + + @override + void initState() { + super.initState(); + _start = widget.initialDateRange?.start; + _end = widget.initialDateRange?.end; + _displayedMonth = DateTime( + (_start ?? DateTime.now()).year, + (_start ?? DateTime.now()).month, + ); + } + + void _onDaySelected(DateTime day) { + final selected = DateTime(day.year, day.month, day.day); + setState(() { + if (_start == null || (_start != null && _end != null)) { + _start = selected; + _end = null; + } else if (selected.isBefore(_start!)) { + _end = _start; + _start = selected; + } else { + _end = selected; + } + }); + } + + bool _isInRange(DateTime day) { + if (_start == null || _end == null) return false; + final d = DateTime(day.year, day.month, day.day); + return !d.isBefore(_start!) && !d.isAfter(_end!); + } + + bool _isEndpoint(DateTime day) { + final d = DateTime(day.year, day.month, day.day); + return (_start != null && d == _start) || (_end != null && d == _end); + } + + void _shiftMonth(int delta) { + setState(() { + _displayedMonth = DateTime( + _displayedMonth.year, + _displayedMonth.month + delta, + ); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final canApply = _start != null && _end != null; + final rangeLabel = _start == null + ? 'Select start date' + : _end == null + ? '${DateFormatter.displayDate(_start)} – Select end date' + : '${DateFormatter.displayDate(_start)} – ${DateFormatter.displayDate(_end)}'; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + widget.helpText, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close, size: 20), + ), + ], + ), + const SizedBox(height: 4), + Text( + rangeLabel, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + _MonthHeader( + month: _displayedMonth, + onPrev: () => _shiftMonth(-1), + onNext: () => _shiftMonth(1), + ), + const SizedBox(height: 8), + _CalendarGrid( + month: _displayedMonth, + firstDate: widget.firstDate, + lastDate: widget.lastDate, + isInRange: _isInRange, + isEndpoint: _isEndpoint, + onSelected: _onDaySelected, + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => setState(() { + _start = null; + _end = null; + }), + child: const Text('Clear'), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: canApply + ? () => Navigator.of(context).pop( + DateTimeRange(start: _start!, end: _end!), + ) + : null, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _MonthHeader extends StatelessWidget { + const _MonthHeader({ + required this.month, + required this.onPrev, + required this.onNext, + }); + + final DateTime month; + final VoidCallback onPrev; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + final title = '${months[month.month - 1]} ${month.year}'; + + return Row( + children: [ + IconButton( + onPressed: onPrev, + icon: const Icon(Icons.chevron_left), + visualDensity: VisualDensity.compact, + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + onPressed: onNext, + icon: const Icon(Icons.chevron_right), + visualDensity: VisualDensity.compact, + ), + ], + ); + } +} + +class _CalendarGrid extends StatelessWidget { + const _CalendarGrid({ + required this.month, + required this.firstDate, + required this.lastDate, + required this.isInRange, + required this.isEndpoint, + required this.onSelected, + }); + + final DateTime month; + final DateTime firstDate; + final DateTime lastDate; + final bool Function(DateTime day) isInRange; + final bool Function(DateTime day) isEndpoint; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final firstOfMonth = DateTime(month.year, month.month); + final daysInMonth = DateTime(month.year, month.month + 1, 0).day; + // DateTime.weekday: Mon=1..Sun=7 → make Sunday-first grid + final leading = firstOfMonth.weekday % 7; + + final weekdays = const ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + return Column( + children: [ + Row( + children: weekdays + .map( + (d) => Expanded( + child: Center( + child: Text( + d, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 6), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: leading + daysInMonth, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: 4, + crossAxisSpacing: 4, + ), + itemBuilder: (context, index) { + if (index < leading) return const SizedBox.shrink(); + final day = index - leading + 1; + final date = DateTime(month.year, month.month, day); + final enabled = !date.isBefore( + DateTime(firstDate.year, firstDate.month, firstDate.day), + ) && + !date.isAfter( + DateTime(lastDate.year, lastDate.month, lastDate.day), + ); + final endpoint = isEndpoint(date); + final inRange = isInRange(date); + + return Material( + color: endpoint + ? theme.colorScheme.primary + : inRange + ? theme.colorScheme.primary.withValues(alpha: 0.12) + : Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: enabled ? () => onSelected(date) : null, + child: Center( + child: Text( + '$day', + style: theme.textTheme.bodyMedium?.copyWith( + color: !enabled + ? theme.disabledColor + : endpoint + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + fontWeight: endpoint ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/shared/widgets/app_filter_date_field.dart b/lib/shared/widgets/app_filter_date_field.dart new file mode 100644 index 0000000..9d62623 --- /dev/null +++ b/lib/shared/widgets/app_filter_date_field.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +/// Outlined filter date / date-range field matching dropdown filter styling. +/// +/// Height stays constant whether empty or filled (clear icon is always reserved). +class AppFilterDateField extends StatelessWidget { + const AppFilterDateField({ + super.key, + required this.label, + required this.value, + required this.onTap, + this.icon = Icons.calendar_today_outlined, + this.placeholder = 'Select date', + this.onClear, + this.isEmpty = false, + }); + + final String label; + final String value; + final VoidCallback onTap; + final IconData icon; + final String placeholder; + final VoidCallback? onClear; + final bool isEmpty; + + static const _suffixConstraints = BoxConstraints( + minWidth: 40, + minHeight: 40, + maxWidth: 40, + maxHeight: 40, + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final showClear = onClear != null && !isEmpty; + + return InputDecorator( + decoration: InputDecoration( + labelText: label, + isDense: true, + contentPadding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + suffixIconConstraints: _suffixConstraints, + // Always reserve trailing space so height matches empty ↔ filled + // and lines up with dense searchable dropdowns. + suffixIcon: showClear + ? IconButton( + tooltip: 'Clear', + onPressed: onClear, + padding: EdgeInsets.zero, + constraints: _suffixConstraints, + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.close, size: 18), + ) + : const SizedBox(width: 40, height: 40), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(4), + child: SizedBox( + height: 24, + child: Row( + children: [ + Icon(icon, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + isEmpty ? placeholder : value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.2, + color: isEmpty ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/app_hover_effect.dart b/lib/shared/widgets/app_hover_effect.dart index 0fef504..17ed2a5 100644 --- a/lib/shared/widgets/app_hover_effect.dart +++ b/lib/shared/widgets/app_hover_effect.dart @@ -7,8 +7,8 @@ class AppHoverStyle { static const Duration duration = Duration(milliseconds: 180); static const Curve curve = Curves.easeOutCubic; static const double borderRadius = 12; - static const double idleBorderWidth = 1; - static const double hoverBorderWidth = 1.5; + /// Fixed for idle and hover so decoration never changes widget size. + static const double borderWidth = 1; static Color idleBorderColor(ColorScheme scheme) => scheme.outline.withValues(alpha: 0.18); @@ -16,15 +16,23 @@ class AppHoverStyle { static Color hoverBorderColor(ColorScheme scheme) => scheme.primary.withValues(alpha: 0.45); - static List hoverShadow(ColorScheme scheme) => [ + static List _shadow({required Color color}) => [ BoxShadow( - color: scheme.primary.withValues(alpha: 0.2), + color: color, blurRadius: 14, spreadRadius: 0, offset: const Offset(0, 4), ), ]; + static List hoverShadow(ColorScheme scheme) => + _shadow(color: scheme.primary.withValues(alpha: 0.2)); + + /// Same geometry as [hoverShadow] but invisible — keeps layout stable while + /// [AnimatedContainer] interpolates shadow color on hover. + static List idleShadow() => + _shadow(color: Colors.transparent); + static BoxDecoration decoration( ThemeData theme, { required bool hovered, @@ -40,10 +48,12 @@ class AppHoverStyle { border: showBorder ? Border.all( color: hovered ? hoverBorderColor(scheme) : idleBorderColor(scheme), - width: hovered ? hoverBorderWidth : idleBorderWidth, + width: borderWidth, ) : null, - boxShadow: hovered && showShadow ? hoverShadow(scheme) : null, + boxShadow: showShadow + ? (hovered ? hoverShadow(scheme) : idleShadow()) + : const [], ); } } diff --git a/lib/shared/widgets/app_sidebar.dart b/lib/shared/widgets/app_sidebar.dart index a8e1e23..50f0b64 100644 --- a/lib/shared/widgets/app_sidebar.dart +++ b/lib/shared/widgets/app_sidebar.dart @@ -25,7 +25,7 @@ const _sidebarChildIndent = 28.0; /// Set to `true` to show the Light/Dark toggle in the sidebar again. /// Kept hidden for now — do not delete `_buildThemeToggle`. -const showSidebarThemeToggle = false; +const showSidebarThemeToggle = true; class AppSidebar extends ConsumerStatefulWidget { const AppSidebar({ @@ -153,9 +153,7 @@ class _AppSidebarState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context, isNarrow: isNarrow), - // NOTE: Light/Dark theme toggle is temporarily hidden from the - // sidebar. Do not remove `_buildThemeToggle` — restore by - // setting [showSidebarThemeToggle] to true. + // Light/Dark theme toggle. Hide with [showSidebarThemeToggle] = false. if (!isNarrow && showSidebarThemeToggle) ...[ const SizedBox(height: 16), _buildThemeToggle(context, isLightActive),