reports fix

This commit is contained in:
Surendiran 2026-07-13 15:22:41 +05:30
parent b740edf0e8
commit 4b4a51e553
30 changed files with 2929 additions and 192 deletions

View File

@ -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';

View File

@ -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';

View File

@ -19,6 +19,10 @@ const Map<String, String> 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(

View File

@ -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<AssetFormPanel> {
}
Future<void> _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));

View File

@ -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<AddAmcPanel> {
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<LogServiceVisitPanel> {
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<AddInsurancePanel> {
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<TransferAssetPanel> {
}
Future<void> _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);

View File

@ -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<AuditLogsScreen> {
? 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<String?>(
@ -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<Widget> 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),
]),
],
);
}

View File

@ -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'),
];

View File

@ -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<GrnFormScreen> {
required DateTime? current,
required ValueChanged<DateTime?> 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);
}

View File

@ -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<DateTime?> 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);
}

View File

@ -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<PurchaseOrderFormScree
required DateTime? current,
required ValueChanged<DateTime?> 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);
}

View File

@ -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<DepreciationReportFilters> getAssetReportFilters() async {
final response = await dio.get(ApiEndpoints.reportAssetDepreciationFilters);
final data = response.data['data'];
if (data is Map<String, dynamic>) {
return DepreciationReportFilters.fromJson(data);
}
if (data is Map) {
return DepreciationReportFilters.fromJson(
Map<String, dynamic>.from(data),
);
}
return const DepreciationReportFilters();
}
Future<DepreciationReportPage> getDepreciationReport(
DepreciationReportQuery query,
) async {
final response = await dio.get(
ApiEndpoints.reportAssetDepreciation,
queryParameters: _queryToMap(query, includePagination: true),
);
final body = response.data as Map<String, dynamic>;
final rawData = body['data'];
final meta = body['meta'] is Map
? Map<String, dynamic>.from(body['meta'] as Map)
: <String, dynamic>{};
final rawItems = rawData is List ? rawData : const [];
final items = rawItems
.whereType<Map>()
.map(
(item) => DepreciationReportRow.fromJson(
Map<String, dynamic>.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<String, dynamic>.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<ExportFileResult> exportAssetReport(
DepreciationReportQuery query,
) async {
final response = await dio.get<List<int>>(
ApiEndpoints.reportAssetDepreciationExport,
queryParameters: _queryToMap(query, includePagination: false),
options: Options(responseType: ResponseType.bytes),
);
return ExportFileResult(
bytes: response.data ?? <int>[],
fileName: _fileNameFromResponse(response),
);
}
Map<String, dynamic> _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<List<int>> 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';
}
}

View File

@ -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<ReportsRemoteDataSource>((ref) {
return ReportsRemoteDataSource(dio: ref.watch(dioProvider));
});
final reportsRepositoryProvider = Provider<ReportsRepository>((ref) {
return ReportsRepositoryImpl(remote: ref.watch(reportsRemoteDataSourceProvider));
});
class ReportsRepositoryImpl implements ReportsRepository {
ReportsRepositoryImpl({required this.remote});
final ReportsRemoteDataSource remote;
@override
Future<Result<DepreciationReportFilters>> getAssetReportFilters() =>
safeApiCall(remote.getAssetReportFilters);
@override
Future<Result<DepreciationReportPage>> getDepreciationReport(
DepreciationReportQuery query,
) =>
safeApiCall(() => remote.getDepreciationReport(query));
@override
Future<Result<ExportFileResult>> exportAssetReport(
DepreciationReportQuery query,
) =>
safeApiCall(() => remote.exportAssetReport(query));
}

View File

@ -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<String, dynamic>.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<ReportFilterOption> plants;
final List<ReportFilterOption> categories;
final List<ReportFilterOption> subcategories;
final List<ReportFilterOption> departments;
final List<ReportFilterOption> statuses;
final List<ReportFilterOption> depreciationMethods;
factory DepreciationReportFilters.fromJson(Map<String, dynamic> json) {
List<ReportFilterOption> parse(List<String> 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<String, dynamic>? 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<String, dynamic> 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<String> keys, [Map<String, dynamic>? 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<String, dynamic>.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<String, dynamic>.from(depreciation)
: <String, dynamic>{};
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<DepreciationReportRow> items;
final int page;
final int limit;
final int total;
final int totalPages;
final DateTime? asOfDate;
final DepreciationReportSummary summary;
}

View File

@ -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<String> get reportCategories {
final seen = <String>{};
final categories = <String>[];
for (final def in reportDefinitions) {
if (seen.add(def.category)) categories.add(def.category);
}
return categories;
}
const reportDefinitions = <ReportDefinition>[
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),
// ),
];

View File

@ -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<Result<DepreciationReportFilters>> getAssetReportFilters();
Future<Result<DepreciationReportPage>> getDepreciationReport(
DepreciationReportQuery query,
);
Future<Result<ExportFileResult>> exportAssetReport(
DepreciationReportQuery query,
);
}

View File

@ -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<DepreciationReportRow> 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<DepreciationReportRow>? 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<DepreciationReportState> {
@override
Future<DepreciationReportState> build() async {
ref.keepAlive();
return _load(
const DepreciationReportQuery(limit: AppConstants.defaultPageSize),
);
}
Future<DepreciationReportState> _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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> setPage(int page) async {
final current = state.valueOrNull?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
await applyQuery(current.copyWith(page: page));
}
Future<void> setPageSize(int limit) async {
final current = state.valueOrNull?.query ??
const DepreciationReportQuery(limit: AppConstants.defaultPageSize);
await applyQuery(current.copyWith(page: 1, limit: limit));
}
Future<void> resetFilters() async {
await applyQuery(
DepreciationReportQuery(
limit: state.valueOrNull?.query.limit ?? AppConstants.defaultPageSize,
),
);
}
Future<ExportFileResult?> 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;
}
}

View File

@ -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<DepreciationReportScreen> createState() =>
_DepreciationReportScreenState();
}
class _DepreciationReportScreenState
extends ConsumerState<DepreciationReportScreen> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _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<void> _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<void> _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<String> onSearch;
final ValueChanged<String?> onPlantChanged;
final ValueChanged<String?> onCategoryChanged;
final ValueChanged<String?> onSubcategoryChanged;
final ValueChanged<String?> onDepartmentChanged;
final ValueChanged<String?> onStatusChanged;
final ValueChanged<String?> onMethodChanged;
final ValueChanged<bool?> 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<ReportFilterOption> options,
required ValueChanged<String?> onChanged,
}) {
return AppSearchableDropdown<String?>(
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<bool?>(
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<Widget> 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<DepreciationReportRow> items;
@override
Widget build(BuildContext context) {
return AppDataTable<DepreciationReportRow>(
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<DepreciationReportRow> 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),
],
);
}
}

View File

@ -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,
),
],
),
),
),
],
),
),
);
}
}

View File

@ -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';

View File

@ -82,6 +82,7 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
final SettingsRepository _repository;
final FaviconStore _faviconStore;
final Future<void> Function(String? logoUrl, String? companyName) _syncAppLogo;
Future<Failure?>? _companyProfileInFlight;
Future<void> _syncMainLogo(CompanyProfileSettings profile) async {
final logo = resolveMediaUrl(profile.logoUrl);
@ -98,13 +99,25 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await _syncMainLogo(state.companyProfile);
}
Future<Failure?> 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<Failure?> 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<Failure?> refreshEmailSettings() async {

View File

@ -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: (_) {},
),
],
],
],
),
),
),
),

View File

@ -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,
],
),
),
),
),

View File

@ -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<GoRouter>((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<GoRouter>((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(

View File

@ -116,7 +116,9 @@ List<MenuItem> getVisibleMenuItems({
required List<String> 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;
}

View File

@ -89,30 +89,42 @@ class AppDataTable<T> 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<T>(
columns: columns,
sortColumn: sortColumn,
sortAscending: sortAscending,
onSort: onSort,
),
...rows.map(
(row) => _TableDataRow<T>(
columns: columns,
row: row,
),
),
],
);
},
final header = _TableHeaderRow<T>(
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<T>(
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<T> 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(

View File

@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
/// Compact dialog date picker (avoids the full-page Material picker on web).
Future<DateTime?> showAppDatePopup({
required BuildContext context,
DateTime? initialDate,
DateTime? firstDate,
DateTime? lastDate,
String helpText = 'Select date',
}) {
final now = DateTime.now();
return showDialog<DateTime>(
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<DateTime> 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,
),
),
),
),
);
},
),
],
);
}
}

View File

@ -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<DateTimeRange?> showAppDateRangePopup({
required BuildContext context,
DateTimeRange? initialDateRange,
DateTime? firstDate,
DateTime? lastDate,
String helpText = 'Select date range',
}) {
final now = DateTime.now();
return showDialog<DateTimeRange>(
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<DateTime> 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,
),
),
),
),
);
},
),
],
);
}
}

View File

@ -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,
),
),
),
],
),
),
),
);
}
}

View File

@ -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<BoxShadow> hoverShadow(ColorScheme scheme) => [
static List<BoxShadow> _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<BoxShadow> 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<BoxShadow> 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 [],
);
}
}

View File

@ -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<AppSidebar> {
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),