asset form popup fix

This commit is contained in:
Surendiran 2026-06-23 09:27:09 +05:30
parent fa310dd87c
commit f496be0c7f
20 changed files with 4887 additions and 1613 deletions

View File

@ -73,25 +73,16 @@ class ApiEndpoints {
// Assets
static const String assets = '/assets';
static String assetById(String id) => '/assets/$id';
static String assetQrCode(String id) => '/assets/$id/qr-code';
static const String assetSearch = '/assets/search';
// Asset Allocations
static const String assetAllocations = '/asset-allocations';
static String assetAllocationById(String id) => '/asset-allocations/$id';
static String returnAsset(String id) => '/asset-allocations/$id/return';
static String reassignAsset(String id) => '/asset-allocations/$id/reassign';
static String transferAsset(String id) => '/asset-allocations/$id/transfer';
static const String allocationHistory = '/asset-allocations/history';
// Asset Maintenance
static const String assetMaintenance = '/asset-maintenance';
static String maintenanceById(String id) => '/asset-maintenance/$id';
// Asset Disposal
static const String assetDisposal = '/asset-disposal';
static String disposalById(String id) => '/asset-disposal/$id';
static String approveDisposal(String id) => '/asset-disposal/$id/approve';
static String assetTransfer(String id) => '/assets/$id/transfer';
static const String assetAlertsExpiry = '/assets/alerts/expiry';
static const String assetAlertsService = '/assets/alerts/service';
static String assetAmc(String assetId) => '/assets/$assetId/amc';
static String assetAmcRenew(String assetId, String contractId) =>
'/assets/$assetId/amc/$contractId/renew';
static String assetServiceVisits(String assetId) => '/assets/$assetId/service-visits';
static String assetInsurance(String assetId) => '/assets/$assetId/insurance';
static String assetInsuranceRenew(String assetId, String policyId) =>
'/assets/$assetId/insurance/$policyId/renew';
// Dashboard
static const String dashboardKpis = '/dashboard/kpis';

View File

@ -45,11 +45,7 @@ class RouteConstants {
static const String assetEdit = '/assets/:id/edit';
static const String assetDetail = '/assets/:id';
static const String assetCategories = '/assets/categories';
static const String assetAllocations = '/assets/allocations';
static const String assetMaintenance = '/assets/maintenance';
static const String assetDisposal = '/assets/disposal';
static const String assetQrScan = '/assets/qr-scan';
static const String assetQrGenerate = '/assets/qr-generate';
static const String assetAlerts = '/assets/alerts';
// Master Data
static const String masterData = '/master-data';

View File

@ -0,0 +1,213 @@
import 'package:dio/dio.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart';
class AssetRemoteDataSource {
AssetRemoteDataSource({required this.dio});
final Dio dio;
Future<PaginatedResponse<AssetModel>> getAssets(PaginationParams params) async {
final response = await dio.get(
ApiEndpoints.assets,
queryParameters: {
'page': params.page,
'limit': params.limit,
if (params.search != null && params.search!.isNotEmpty) 'search': params.search,
},
);
return _parsePaginated(response.data, AssetModel.fromJson);
}
Future<AssetModel> getAssetById(String id) async {
final response = await dio.get(ApiEndpoints.assetById(id));
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AssetModel> createAsset(Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.assets, data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AssetModel> updateAsset(String id, Map<String, dynamic> data) async {
final response = await dio.put(ApiEndpoints.assetById(id), data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<void> deleteAsset(String id) async {
await dio.delete(ApiEndpoints.assetById(id));
}
Future<AssetModel> transferAsset(String id, Map<String, dynamic> data) async {
final response = await dio.post(ApiEndpoints.assetTransfer(id), data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<AssetCategoryModel>> getCategories() async {
final response = await dio.get(
ApiEndpoints.assetCategories,
queryParameters: const {'limit': 100, 'is_active': true},
);
return _parseList(response.data, AssetCategoryModel.fromJson);
}
Future<PaginatedResponse<AssetAlertModel>> getExpiryAlerts({
int? days,
String? type,
int page = 1,
int limit = 20,
}) async {
final response = await dio.get(
ApiEndpoints.assetAlertsExpiry,
queryParameters: {
'page': page,
'limit': limit,
if (days != null) 'days': days,
if (type != null) 'type': type,
},
);
return _parsePaginated(response.data, AssetAlertModel.fromJson);
}
Future<List<AssetAlertModel>> getServiceAlerts({String? status}) async {
final response = await dio.get(
ApiEndpoints.assetAlertsService,
queryParameters: {if (status != null) 'status': status},
);
return _parseList(response.data, AssetAlertModel.fromJson);
}
Future<List<AmcContractModel>> getAmcContracts(String assetId) async {
final response = await dio.get(ApiEndpoints.assetAmc(assetId));
return _parseList(response.data, AmcContractModel.fromJson);
}
Future<AmcContractModel> createAmcContract(
String assetId,
Map<String, dynamic> data,
) async {
final response = await dio.post(ApiEndpoints.assetAmc(assetId), data: data);
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<AmcContractModel> renewAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
) async {
final response = await dio.patch(
ApiEndpoints.assetAmcRenew(assetId, contractId),
data: data,
);
return AmcContractModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<ServiceVisitModel>> getServiceVisits(String assetId) async {
final response = await dio.get(ApiEndpoints.assetServiceVisits(assetId));
return _parseList(response.data, ServiceVisitModel.fromJson);
}
Future<ServiceVisitModel> logServiceVisit(
String assetId,
Map<String, dynamic> data,
) async {
final response = await dio.post(ApiEndpoints.assetServiceVisits(assetId), data: data);
return ServiceVisitModel.fromJson(response.data['data'] as Map<String, dynamic>);
}
Future<List<InsurancePolicyModel>> getInsurancePolicies(String assetId) async {
final response = await dio.get(ApiEndpoints.assetInsurance(assetId));
return _parseList(response.data, InsurancePolicyModel.fromJson);
}
Future<InsurancePolicyModel> createInsurancePolicy(
String assetId,
Map<String, dynamic> data,
) async {
final response = await dio.post(ApiEndpoints.assetInsurance(assetId), data: data);
return InsurancePolicyModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
Future<InsurancePolicyModel> renewInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
) async {
final response = await dio.patch(
ApiEndpoints.assetInsuranceRenew(assetId, policyId),
data: data,
);
return InsurancePolicyModel.fromJson(
response.data['data'] as Map<String, dynamic>,
);
}
List<T> _parseList<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) return [];
final raw = body['data'];
if (raw is List) {
return raw
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList();
}
if (raw is Map<String, dynamic>) {
final items = raw['items'];
if (items is List) {
return items
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList();
}
}
return [];
}
PaginatedResponse<T> _parsePaginated<T>(
dynamic body,
T Function(Map<String, dynamic>) fromJson,
) {
if (body is! Map<String, dynamic>) {
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
final raw = body['data'];
final meta = body['meta'] as Map<String, dynamic>? ?? {};
if (raw is List) {
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
return PaginatedResponse(
items: items,
page: (meta['page'] as num?)?.toInt() ?? 1,
limit: (meta['limit'] as num?)?.toInt() ?? items.length,
total: (meta['total'] as num?)?.toInt() ?? items.length,
totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1,
);
}
if (raw is Map<String, dynamic>) {
return PaginatedResponse.fromJson(raw, (json) => fromJson(json! as Map<String, dynamic>));
}
return const PaginatedResponse(
items: [],
page: 1,
limit: 20,
total: 0,
totalPages: 1,
);
}
}

View File

@ -1,129 +1,131 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart';
import '../../domain/repositories/asset_repository.dart';
import '../datasources/asset_remote_data_source.dart';
final assetRemoteDataSourceProvider = Provider<AssetRemoteDataSource>((ref) {
return AssetRemoteDataSource(dio: ref.watch(dioProvider));
});
final assetRepositoryProvider = Provider<AssetRepository>((ref) {
return AssetRepositoryImpl(dio: ref.watch(dioProvider));
return AssetRepositoryImpl(dataSource: ref.watch(assetRemoteDataSourceProvider));
});
class AssetRepositoryImpl implements AssetRepository {
AssetRepositoryImpl({required this.dio});
AssetRepositoryImpl({required this.dataSource});
final Dio dio;
final AssetRemoteDataSource dataSource;
@override
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assets, queryParameters: {
'page': params.page,
'limit': params.limit,
if (params.search != null) 'search': params.search,
});
return PaginatedResponse<AssetModel>.fromJson(
response.data['data'] as Map<String, dynamic>,
(json) => AssetModel.fromJson(json! as Map<String, dynamic>),
);
});
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params) {
return safeApiCall(() => dataSource.getAssets(params));
}
@override
Future<Result<AssetModel>> getAssetById(String id) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetById(id));
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
});
Future<Result<AssetModel>> getAssetById(String id) {
return safeApiCall(() => dataSource.getAssetById(id));
}
@override
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data) async {
return safeApiCall(() async {
final response = await dio.post(ApiEndpoints.assets, data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
});
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data) {
return safeApiCall(() => dataSource.createAsset(data));
}
@override
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data) async {
return safeApiCall(() async {
final response = await dio.put(ApiEndpoints.assetById(id), data: data);
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
});
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data) {
return safeApiCall(() => dataSource.updateAsset(id, data));
}
@override
Future<Result<List<AssetCategoryModel>>> getCategories() async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetCategories);
final rawData = response.data['data'];
final list = rawData is List<dynamic>
? rawData
: (rawData is Map<String, dynamic> ? rawData['items'] as List<dynamic>? : null) ??
<dynamic>[];
return list
.map((e) => AssetCategoryModel.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<Result<void>> deleteAsset(String id) {
return safeApiCall(() => dataSource.deleteAsset(id));
}
@override
Future<Result<PaginatedResponse<AssetAllocationModel>>> getAllocations(
PaginationParams params,
) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetAllocations, queryParameters: {
'page': params.page,
'limit': params.limit,
});
return PaginatedResponse<AssetAllocationModel>.fromJson(
response.data['data'] as Map<String, dynamic>,
(json) => AssetAllocationModel.fromJson(json! as Map<String, dynamic>),
);
});
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data) {
return safeApiCall(() => dataSource.transferAsset(id, data));
}
@override
Future<Result<PaginatedResponse<AssetMaintenanceModel>>> getMaintenance(
PaginationParams params,
) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetMaintenance, queryParameters: {
'page': params.page,
'limit': params.limit,
});
return PaginatedResponse<AssetMaintenanceModel>.fromJson(
response.data['data'] as Map<String, dynamic>,
(json) => AssetMaintenanceModel.fromJson(json! as Map<String, dynamic>),
);
});
Future<Result<List<AssetCategoryModel>>> getCategories() {
return safeApiCall(() => dataSource.getCategories());
}
@override
Future<Result<PaginatedResponse<AssetDisposalModel>>> getDisposals(
PaginationParams params,
) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetDisposal, queryParameters: {
'page': params.page,
'limit': params.limit,
});
return PaginatedResponse<AssetDisposalModel>.fromJson(
response.data['data'] as Map<String, dynamic>,
(json) => AssetDisposalModel.fromJson(json! as Map<String, dynamic>),
);
});
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,
String? type,
int page = 1,
int limit = 20,
}) {
return safeApiCall(
() => dataSource.getExpiryAlerts(days: days, type: type, page: page, limit: limit),
);
}
@override
Future<Result<AssetModel>> searchByQrCode(String code) async {
return safeApiCall(() async {
final response = await dio.get(ApiEndpoints.assetSearch, queryParameters: {'q': code});
return AssetModel.fromJson(response.data['data'] as Map<String, dynamic>);
});
Future<Result<List<AssetAlertModel>>> getServiceAlerts({String? status}) {
return safeApiCall(() => dataSource.getServiceAlerts(status: status));
}
@override
Future<Result<List<AmcContractModel>>> getAmcContracts(String assetId) {
return safeApiCall(() => dataSource.getAmcContracts(assetId));
}
@override
Future<Result<AmcContractModel>> createAmcContract(
String assetId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.createAmcContract(assetId, data));
}
@override
Future<Result<AmcContractModel>> renewAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.renewAmcContract(assetId, contractId, data));
}
@override
Future<Result<List<ServiceVisitModel>>> getServiceVisits(String assetId) {
return safeApiCall(() => dataSource.getServiceVisits(assetId));
}
@override
Future<Result<ServiceVisitModel>> logServiceVisit(
String assetId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.logServiceVisit(assetId, data));
}
@override
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId) {
return safeApiCall(() => dataSource.getInsurancePolicies(assetId));
}
@override
Future<Result<InsurancePolicyModel>> createInsurancePolicy(
String assetId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.createInsurancePolicy(assetId, data));
}
@override
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
) {
return safeApiCall(() => dataSource.renewInsurancePolicy(assetId, policyId, data));
}
}

View File

@ -1,4 +1,3 @@
import '../../../../core/errors/failure.dart';
import '../../../../core/network/api_handler.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart';
@ -8,9 +7,39 @@ abstract class AssetRepository {
Future<Result<AssetModel>> getAssetById(String id);
Future<Result<AssetModel>> createAsset(Map<String, dynamic> data);
Future<Result<AssetModel>> updateAsset(String id, Map<String, dynamic> data);
Future<Result<void>> deleteAsset(String id);
Future<Result<AssetModel>> transferAsset(String id, Map<String, dynamic> data);
Future<Result<List<AssetCategoryModel>>> getCategories();
Future<Result<PaginatedResponse<AssetAllocationModel>>> getAllocations(PaginationParams params);
Future<Result<PaginatedResponse<AssetMaintenanceModel>>> getMaintenance(PaginationParams params);
Future<Result<PaginatedResponse<AssetDisposalModel>>> getDisposals(PaginationParams params);
Future<Result<AssetModel>> searchByQrCode(String code);
Future<Result<PaginatedResponse<AssetAlertModel>>> getExpiryAlerts({
int? days,
String? type,
int page,
int limit,
});
Future<Result<List<AssetAlertModel>>> getServiceAlerts({String? status});
Future<Result<List<AmcContractModel>>> getAmcContracts(String assetId);
Future<Result<AmcContractModel>> createAmcContract(
String assetId,
Map<String, dynamic> data,
);
Future<Result<AmcContractModel>> renewAmcContract(
String assetId,
String contractId,
Map<String, dynamic> data,
);
Future<Result<List<ServiceVisitModel>>> getServiceVisits(String assetId);
Future<Result<ServiceVisitModel>> logServiceVisit(
String assetId,
Map<String, dynamic> data,
);
Future<Result<List<InsurancePolicyModel>>> getInsurancePolicies(String assetId);
Future<Result<InsurancePolicyModel>> createInsurancePolicy(
String assetId,
Map<String, dynamic> data,
);
Future<Result<InsurancePolicyModel>> renewInsurancePolicy(
String assetId,
String policyId,
Map<String, dynamic> data,
);
}

View File

@ -0,0 +1,408 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../shared/models/api_response.dart';
import '../../../../shared/models/asset_model.dart';
import '../../data/repositories/asset_repository_impl.dart';
class AssetsListState {
const AssetsListState({
this.assets = const [],
this.query = const PaginationParams(limit: 20),
this.total = 0,
this.totalPages = 1,
this.isRefreshing = false,
this.actionError,
this.actionSuccess,
});
final List<AssetModel> assets;
final PaginationParams query;
final int total;
final int totalPages;
final bool isRefreshing;
final String? actionError;
final String? actionSuccess;
AssetsListState copyWith({
List<AssetModel>? assets,
PaginationParams? query,
int? total,
int? totalPages,
bool? isRefreshing,
String? actionError,
String? actionSuccess,
bool clearMessages = false,
}) {
return AssetsListState(
assets: assets ?? this.assets,
query: query ?? this.query,
total: total ?? this.total,
totalPages: totalPages ?? this.totalPages,
isRefreshing: isRefreshing ?? this.isRefreshing,
actionError: clearMessages ? null : actionError ?? this.actionError,
actionSuccess: clearMessages ? null : actionSuccess ?? this.actionSuccess,
);
}
}
final assetsListProvider =
AsyncNotifierProvider.autoDispose<AssetsListNotifier, AssetsListState>(
AssetsListNotifier.new,
);
class AssetsListNotifier extends AutoDisposeAsyncNotifier<AssetsListState> {
@override
Future<AssetsListState> build() async {
return _load(const PaginationParams(limit: 20));
}
Future<AssetsListState> _load(PaginationParams query) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getAssets(query);
if (result.failure != null) throw result.failure!;
final page = result.data!;
return AssetsListState(
assets: page.items,
query: query,
total: page.total,
totalPages: page.totalPages,
);
}
Future<void> refresh() async {
final current = state.valueOrNull ?? const AssetsListState();
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(PaginationParams query) async {
final previous = state.valueOrNull;
if (previous == null) {
state = const AsyncLoading();
}
try {
state = AsyncData(await _load(query));
} catch (e, st) {
state = AsyncError(e, st);
}
}
void setSearch(String search) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(search: search, page: 1));
}
void setPage(int page) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(page: page));
}
void setPageSize(int limit) {
final current = state.valueOrNull;
if (current == null) return;
applyQuery(current.query.copyWith(limit: limit, page: 1));
}
Future<bool> deleteAsset(String id) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.deleteAsset(id);
if (result.failure != null) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionError: result.failure!.message));
}
return false;
}
await refresh();
final current = state.valueOrNull;
if (current != null) {
state = AsyncData(current.copyWith(actionSuccess: 'Asset deleted'));
}
return true;
}
}
final assetDetailProvider =
AsyncNotifierProvider.family<AssetDetailNotifier, AssetDetailState, String>(
AssetDetailNotifier.new,
);
class AssetDetailState {
const AssetDetailState({
required this.asset,
this.amcContracts = const [],
this.serviceVisits = const [],
this.insurancePolicies = const [],
});
final AssetModel asset;
final List<AmcContractModel> amcContracts;
final List<ServiceVisitModel> serviceVisits;
final List<InsurancePolicyModel> insurancePolicies;
AssetDetailState copyWith({
AssetModel? asset,
List<AmcContractModel>? amcContracts,
List<ServiceVisitModel>? serviceVisits,
List<InsurancePolicyModel>? insurancePolicies,
}) {
return AssetDetailState(
asset: asset ?? this.asset,
amcContracts: amcContracts ?? this.amcContracts,
serviceVisits: serviceVisits ?? this.serviceVisits,
insurancePolicies: insurancePolicies ?? this.insurancePolicies,
);
}
}
class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String> {
@override
Future<AssetDetailState> build(String arg) async {
return _loadAll(arg);
}
Future<AssetDetailState> _loadAll(String assetId) async {
final repository = ref.read(assetRepositoryProvider);
final assetResult = await repository.getAssetById(assetId);
if (assetResult.failure != null) throw assetResult.failure!;
final amcResult = await repository.getAmcContracts(assetId);
final visitsResult = await repository.getServiceVisits(assetId);
final insuranceResult = await repository.getInsurancePolicies(assetId);
return AssetDetailState(
asset: assetResult.data!,
amcContracts: amcResult.data ?? [],
serviceVisits: visitsResult.data ?? [],
insurancePolicies: insuranceResult.data ?? [],
);
}
Future<void> reload() async {
state = const AsyncLoading();
state = AsyncData(await _loadAll(arg));
}
Future<AssetModel?> updateAsset(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.updateAsset(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(assetsListProvider);
return result.data;
}
Future<bool> deleteAsset() async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.deleteAsset(arg);
if (result.failure != null) return false;
ref.invalidate(assetsListProvider);
return true;
}
Future<AssetModel?> transferAsset(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.transferAsset(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
ref.invalidate(assetsListProvider);
return result.data;
}
Future<AmcContractModel?> createAmc(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.createAmcContract(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
return result.data;
}
Future<ServiceVisitModel?> logVisit(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.logServiceVisit(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
return result.data;
}
Future<InsurancePolicyModel?> createInsurance(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.createInsurancePolicy(arg, data);
if (result.failure != null) throw result.failure!;
await reload();
return result.data;
}
}
final assetFormProvider =
AsyncNotifierProvider.family.autoDispose<AssetFormNotifier, AssetModel?, String?>(
AssetFormNotifier.new,
);
class AssetFormNotifier extends AutoDisposeFamilyAsyncNotifier<AssetModel?, String?> {
@override
Future<AssetModel?> build(String? arg) async {
if (arg == null) return null;
final repository = ref.read(assetRepositoryProvider);
final result = await repository.getAssetById(arg);
if (result.failure != null) throw result.failure!;
return result.data;
}
Future<AssetModel?> submitCreate(Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.createAsset(data);
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
return result.data;
}
Future<AssetModel?> submitUpdate(String id, Map<String, dynamic> data) async {
final repository = ref.read(assetRepositoryProvider);
final result = await repository.updateAsset(id, data);
if (result.failure != null) throw result.failure!;
ref.invalidate(assetsListProvider);
ref.invalidate(assetDetailProvider(id));
final fresh = await repository.getAssetById(id);
final asset = fresh.data ?? result.data;
if (asset != null) {
state = AsyncData(asset);
}
return asset;
}
}
class AssetAlertsState {
const AssetAlertsState({
this.expiryAlerts = const [],
this.serviceAlerts = const [],
this.expiryDays = 30,
this.expiryType,
this.serviceStatus,
this.expiryPage = 1,
this.expiryTotalPages = 1,
this.isRefreshing = false,
});
final List<AssetAlertModel> expiryAlerts;
final List<AssetAlertModel> serviceAlerts;
final int expiryDays;
final String? expiryType;
final String? serviceStatus;
final int expiryPage;
final int expiryTotalPages;
final bool isRefreshing;
AssetAlertsState copyWith({
List<AssetAlertModel>? expiryAlerts,
List<AssetAlertModel>? serviceAlerts,
int? expiryDays,
String? expiryType,
String? serviceStatus,
int? expiryPage,
int? expiryTotalPages,
bool? isRefreshing,
bool clearExpiryType = false,
bool clearServiceStatus = false,
}) {
return AssetAlertsState(
expiryAlerts: expiryAlerts ?? this.expiryAlerts,
serviceAlerts: serviceAlerts ?? this.serviceAlerts,
expiryDays: expiryDays ?? this.expiryDays,
expiryType: clearExpiryType ? null : expiryType ?? this.expiryType,
serviceStatus: clearServiceStatus ? null : serviceStatus ?? this.serviceStatus,
expiryPage: expiryPage ?? this.expiryPage,
expiryTotalPages: expiryTotalPages ?? this.expiryTotalPages,
isRefreshing: isRefreshing ?? this.isRefreshing,
);
}
}
final assetAlertsProvider =
AsyncNotifierProvider<AssetAlertsNotifier, AssetAlertsState>(AssetAlertsNotifier.new);
class AssetAlertsNotifier extends AsyncNotifier<AssetAlertsState> {
@override
Future<AssetAlertsState> build() async {
return _load(const AssetAlertsState());
}
Future<AssetAlertsState> _load(AssetAlertsState filters) async {
final repository = ref.read(assetRepositoryProvider);
final expiryResult = await repository.getExpiryAlerts(
days: filters.expiryDays,
type: filters.expiryType,
page: filters.expiryPage,
);
final serviceResult = await repository.getServiceAlerts(status: filters.serviceStatus);
if (expiryResult.failure != null) throw expiryResult.failure!;
final expiryPage = expiryResult.data!;
return AssetAlertsState(
expiryAlerts: expiryPage.items,
serviceAlerts: serviceResult.data ?? [],
expiryDays: filters.expiryDays,
expiryType: filters.expiryType,
serviceStatus: filters.serviceStatus,
expiryPage: expiryPage.page,
expiryTotalPages: expiryPage.totalPages,
);
}
Future<void> refresh() async {
final current = state.valueOrNull ?? const AssetAlertsState();
state = AsyncData(current.copyWith(isRefreshing: true));
try {
state = AsyncData(await _load(current));
} catch (e, st) {
state = AsyncError(e, st);
}
}
Future<void> setExpiryDays(int days) async {
final current = state.valueOrNull ?? const AssetAlertsState();
state = const AsyncLoading();
state = AsyncData(await _load(current.copyWith(expiryDays: days, expiryPage: 1)));
}
Future<void> setExpiryType(String? type) async {
final current = state.valueOrNull ?? const AssetAlertsState();
state = const AsyncLoading();
state = AsyncData(
await _load(
current.copyWith(
expiryType: type,
expiryPage: 1,
clearExpiryType: type == null,
),
),
);
}
Future<void> setServiceStatus(String? status) async {
final current = state.valueOrNull ?? const AssetAlertsState();
state = const AsyncLoading();
state = AsyncData(
await _load(
current.copyWith(
serviceStatus: status,
clearServiceStatus: status == null,
),
),
);
}
Future<void> setExpiryPage(int page) async {
final current = state.valueOrNull ?? const AssetAlertsState();
state = const AsyncLoading();
state = AsyncData(await _load(current.copyWith(expiryPage: page)));
}
}

View File

@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_pagination.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../providers/assets_provider.dart';
class AssetAlertsScreen extends ConsumerWidget {
const AssetAlertsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final alertsAsync = ref.watch(assetAlertsProvider);
return Padding(
padding: const EdgeInsets.all(24),
child: alertsAsync.when(
loading: () => const AppLoadingView(message: 'Loading alerts...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(assetAlertsProvider),
),
data: (state) => DefaultTabController(
length: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Asset Alerts',
subtitle: 'Expiry and service due notifications',
actions: [
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assets),
icon: const Icon(Icons.inventory_2_outlined),
label: const Text('Asset Master'),
),
],
),
const SizedBox(height: 16),
const TabBar(
tabs: [
Tab(text: 'Expiry Alerts'),
Tab(text: 'Service Alerts'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
children: [
_ExpiryAlertsTab(state: state),
_ServiceAlertsTab(state: state),
],
),
),
],
),
),
),
);
}
}
class _ExpiryAlertsTab extends ConsumerWidget {
const _ExpiryAlertsTab({required this.state});
final AssetAlertsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(assetAlertsProvider.notifier);
final dateFormat = DateFormat('dd MMM yyyy');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 160,
child: AppDropdown<int>(
label: 'Days ahead',
value: state.expiryDays,
options: const [7, 15, 30, 60, 90]
.map((d) => AppDropdownOption(value: d, label: '$d days'))
.toList(),
onChanged: (v) {
if (v != null) notifier.setExpiryDays(v);
},
),
),
SizedBox(
width: 180,
child: AppDropdown<String?>(
label: 'Type',
value: state.expiryType,
options: const [
AppDropdownOption(value: null, label: 'All'),
AppDropdownOption(value: 'AMC', label: 'AMC'),
AppDropdownOption(value: 'INSURANCE', label: 'Insurance'),
AppDropdownOption(value: 'WARRANTY', label: 'Warranty'),
],
onChanged: notifier.setExpiryType,
),
),
IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
const SizedBox(height: 16),
Expanded(
child: state.expiryAlerts.isEmpty
? const AppEmptyState(
title: 'No expiry alerts',
description: 'No AMC, insurance or warranty expiries in this window.',
icon: Icons.event_available_outlined,
)
: ListView.separated(
itemCount: state.expiryAlerts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final alert = state.expiryAlerts[index];
return _AlertCard(
alert: alert,
dateLabel: alert.expiryDate != null
? dateFormat.format(alert.expiryDate!)
: null,
onTap: alert.assetId != null
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
: null,
);
},
),
),
AppPagination(
currentPage: state.expiryPage,
totalPages: state.expiryTotalPages,
totalItems: state.expiryAlerts.length,
pageSize: 20,
onPageChanged: notifier.setExpiryPage,
onPageSizeChanged: (_) {},
),
],
);
}
}
class _ServiceAlertsTab extends ConsumerWidget {
const _ServiceAlertsTab({required this.state});
final AssetAlertsState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = ref.read(assetAlertsProvider.notifier);
final dateFormat = DateFormat('dd MMM yyyy');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 12,
runSpacing: 8,
children: [
SizedBox(
width: 200,
child: AppDropdown<String?>(
label: 'Status',
value: state.serviceStatus,
options: const [
AppDropdownOption(value: null, label: 'All'),
AppDropdownOption(value: 'OVERDUE', label: 'Overdue'),
AppDropdownOption(value: 'DUE_THIS_WEEK', label: 'Due This Week'),
AppDropdownOption(value: 'DUE_THIS_MONTH', label: 'Due This Month'),
AppDropdownOption(value: 'UPCOMING', label: 'Upcoming'),
],
onChanged: notifier.setServiceStatus,
),
),
IconButton(
onPressed: () => notifier.refresh(),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
const SizedBox(height: 16),
Expanded(
child: state.serviceAlerts.isEmpty
? const AppEmptyState(
title: 'No service alerts',
description: 'All services are up to date.',
icon: Icons.build_circle_outlined,
)
: ListView.separated(
itemCount: state.serviceAlerts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final alert = state.serviceAlerts[index];
return _AlertCard(
alert: alert,
dateLabel: alert.dueDate != null
? dateFormat.format(alert.dueDate!)
: null,
onTap: alert.assetId != null
? () => context.push('${RouteConstants.assets}/${alert.assetId}')
: null,
);
},
),
),
],
);
}
}
class _AlertCard extends StatelessWidget {
const _AlertCard({
required this.alert,
this.dateLabel,
this.onTap,
});
final AssetAlertModel alert;
final String? dateLabel;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return AppCard(
child: ListTile(
onTap: onTap,
leading: Icon(
alert.type?.toUpperCase() == 'AMC'
? Icons.handshake_outlined
: alert.type?.toUpperCase() == 'INSURANCE'
? Icons.shield_outlined
: Icons.notifications_outlined,
),
title: Text(alert.assetName ?? alert.title ?? 'Asset ${alert.assetId ?? ''}'),
subtitle: Text(
[
if (alert.assetCode != null) alert.assetCode,
if (alert.type != null) alert.type,
if (alert.plantName != null) alert.plantName,
if (dateLabel != null) 'Due: $dateLabel',
if (alert.daysRemaining != null) '${alert.daysRemaining} days',
].whereType<String>().join(' · '),
),
trailing: alert.status != null
? Chip(label: Text(alert.status!, style: const TextStyle(fontSize: 11)))
: null,
),
);
}
}

View File

@ -36,7 +36,10 @@ class AssetCategoriesScreen extends ConsumerWidget {
child: ListTile(
leading: const Icon(Icons.category_outlined),
title: Text(category.name),
subtitle: Text(category.slug),
subtitle: Text(
'${category.code}'
'${category.defaultDepreciationMethod != null ? ' · ${category.defaultDepreciationMethod}' : ''}',
),
),
);
},

View File

@ -1,77 +1,422 @@
import '../../../../shared/widgets/app_card.dart';
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/constants/route_constants.dart';
import '../../../../core/errors/failure.dart';
import '../../../../shared/models/asset_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_status_chip.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../providers/assets_provider.dart';
import '../widgets/asset_form_panel.dart';
import '../widgets/asset_side_panels.dart';
class AssetDetailScreen extends StatelessWidget {
class AssetDetailScreen extends ConsumerStatefulWidget {
const AssetDetailScreen({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<AssetDetailScreen> createState() => _AssetDetailScreenState();
}
class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Asset Details',
subtitle: 'ID: $assetId',
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DetailRow(label: 'Asset Name', value: ''),
_DetailRow(label: 'Asset Code', value: ''),
_DetailRow(label: 'Category', value: ''),
_DetailRow(label: 'Serial Number', value: ''),
_DetailRow(label: 'Status', value: ''),
_DetailRow(label: 'Branch', value: ''),
],
),
final detailAsync = ref.watch(assetDetailProvider(widget.assetId));
final canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete);
return detailAsync.when(
loading: () => const AppLoadingView(message: 'Loading asset details...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(assetDetailProvider(widget.assetId)),
),
data: (state) => Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: state.asset.assetName,
subtitle: state.asset.assetCode ?? 'Asset ID: ${state.asset.id}',
actions: [
if (canEdit)
OutlinedButton.icon(
onPressed: () =>
openAssetFormPanel(context, ref, assetId: widget.assetId),
icon: const Icon(Icons.edit_outlined),
label: const Text('Edit'),
),
),
if (canEdit) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => _showTransferDialog(state.asset),
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
),
],
if (canDelete) ...[
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: _deleteAsset,
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
],
),
const SizedBox(height: 16),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Overview'),
Tab(text: 'AMC'),
Tab(text: 'Service Visits'),
Tab(text: 'Insurance'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_OverviewTab(asset: state.asset),
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
_ServiceVisitsTab(assetId: widget.assetId, visits: state.serviceVisits),
_InsuranceTab(assetId: widget.assetId, policies: state.insurancePolicies),
],
),
const SizedBox(width: 24),
AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const Text('QR Code'),
const SizedBox(height: 16),
QrImageView(
data: assetId,
version: QrVersions.auto,
size: 160,
),
],
),
),
),
],
),
),
);
}
Future<void> _deleteAsset() async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Asset',
message: 'Soft delete this asset?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed != true || !mounted) return;
final deleted = await ref.read(assetDetailProvider(widget.assetId).notifier).deleteAsset();
if (deleted && mounted) context.go(RouteConstants.assets);
}
Future<void> _showTransferDialog(AssetModel asset) async {
final plantIdController = TextEditingController(text: asset.plantId?.toString() ?? '');
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Transfer Asset'),
content: TextField(
controller: plantIdController,
decoration: const InputDecoration(
labelText: 'Target Plant ID',
helperText: 'Enter the plant ID to transfer this asset to',
),
keyboardType: TextInputType.number,
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
ElevatedButton(onPressed: () => Navigator.pop(context, true), child: const Text('Transfer')),
],
),
);
if (confirmed != true || !mounted) return;
final plantId = int.tryParse(plantIdController.text.trim());
if (plantId == null) return;
try {
await ref.read(assetDetailProvider(widget.assetId).notifier).transferAsset({
'plant_id': plantId,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Asset transferred')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
}
class _OverviewTab extends StatelessWidget {
const _OverviewTab({required this.asset});
final AssetModel asset;
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd MMM yyyy');
return SingleChildScrollView(
child: AppCard(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
_DetailRow(label: 'Asset Name', value: asset.assetName),
_DetailRow(label: 'Asset Code', value: asset.assetCode ?? ''),
_DetailRow(label: 'Category', value: asset.assetCategoryName ?? ''),
_DetailRow(label: 'Plant', value: asset.plantName ?? ''),
_DetailRow(
label: 'Warranty Expiry',
value: asset.warrantyExpiryDate != null
? dateFormat.format(asset.warrantyExpiryDate!)
: '',
),
_DetailRow(
label: 'Purchase Cost',
value: asset.purchaseCost != null ? '${asset.purchaseCost}' : '',
),
_DetailRow(
label: 'Status',
valueWidget: AppStatusChip(status: asset.status ?? 'active'),
),
],
),
],
),
),
);
}
}
class _AmcTab extends ConsumerWidget {
const _AmcTab({required this.assetId, required this.contracts});
final String assetId;
final List<AmcContractModel> contracts;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openAddAmcPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add AMC'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: contracts.isEmpty
? const Center(child: Text('No AMC contracts'))
: ListView.separated(
itemCount: contracts.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final c = contracts[index];
return AppCard(
child: ListTile(
title: Text(c.contractNo ?? 'AMC #${c.id}'),
subtitle: Text(
'${c.vendorName ?? 'Vendor ${c.vendorId ?? ''}'} · '
'${DateFormat('dd MMM yyyy').format(c.startDate)} '
'${DateFormat('dd MMM yyyy').format(c.endDate)}',
),
trailing: c.annualCost != null ? Text('${c.annualCost}') : null,
),
);
},
),
),
],
);
}
Future<void> _openAddAmcPanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
AddAmcPanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AMC contract created')),
);
}
}
}
class _ServiceVisitsTab extends ConsumerWidget {
const _ServiceVisitsTab({required this.assetId, required this.visits});
final String assetId;
final List<ServiceVisitModel> visits;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openLogVisitPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Log Visit'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: visits.isEmpty
? const Center(child: Text('No service visits'))
: ListView.separated(
itemCount: visits.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final v = visits[index];
return AppCard(
child: ListTile(
title: Text(v.visitType),
subtitle: Text(
'${DateFormat('dd MMM yyyy').format(v.visitDate)}'
'${v.workDone != null ? ' · ${v.workDone}' : ''}',
),
trailing: AppStatusChip(status: v.status, compact: true),
),
);
},
),
),
],
);
}
Future<void> _openLogVisitPanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
LogServiceVisitPanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Service visit logged')),
);
}
}
}
class _InsuranceTab extends ConsumerWidget {
const _InsuranceTab({required this.assetId, required this.policies});
final String assetId;
final List<InsurancePolicyModel> policies;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
onPressed: () => _openAddInsurancePanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Policy'),
),
),
),
const SizedBox(height: 12),
Expanded(
child: policies.isEmpty
? const Center(child: Text('No insurance policies'))
: ListView.separated(
itemCount: policies.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final p = policies[index];
return AppCard(
child: ListTile(
title: Text(p.policyNo),
subtitle: Text(
'${p.insurerName} · '
'${DateFormat('dd MMM yyyy').format(p.policyStartDate)} '
'${DateFormat('dd MMM yyyy').format(p.policyEndDate)}',
),
trailing: p.sumInsured != null ? Text('${p.sumInsured}') : null,
),
);
},
),
),
],
);
}
Future<void> _openAddInsurancePanel(BuildContext context, WidgetRef ref) async {
final saved = await showSidePanel<bool>(
context,
AddInsurancePanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Insurance policy created')),
);
}
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({required this.label, required this.value});
const _DetailRow({
required this.label,
this.value,
this.valueWidget,
});
final String label;
final String value;
final String? value;
final Widget? valueWidget;
@override
Widget build(BuildContext context) {
@ -80,7 +425,7 @@ class _DetailRow extends StatelessWidget {
child: Row(
children: [
SizedBox(
width: 140,
width: 160,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
@ -88,7 +433,9 @@ class _DetailRow extends StatelessWidget {
),
),
),
Expanded(child: Text(value, style: Theme.of(context).textTheme.bodyLarge)),
Expanded(
child: valueWidget ?? Text(value ?? '', style: Theme.of(context).textTheme.bodyLarge),
),
],
),
);

View File

@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import '../../../../core/constants/enums.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_text_field.dart';
class AssetFormScreen extends StatefulWidget {
const AssetFormScreen({super.key, this.assetId});
final String? assetId;
@override
State<AssetFormScreen> createState() => _AssetFormScreenState();
}
class _AssetFormScreenState extends State<AssetFormScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _codeController = TextEditingController();
final _brandController = TextEditingController();
final _modelController = TextEditingController();
final _serialController = TextEditingController();
final _vendorController = TextEditingController();
final _costController = TextEditingController();
AssetCategoryType _category = AssetCategoryType.laptop;
AssetStatus _status = AssetStatus.available;
bool get isEditing => widget.assetId != null;
@override
void dispose() {
_nameController.dispose();
_codeController.dispose();
_brandController.dispose();
_modelController.dispose();
_serialController.dispose();
_vendorController.dispose();
_costController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(isEditing ? 'Edit Asset' : 'Add Asset')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _nameController,
label: 'Asset Name',
validator: (v) => Validators.required(v, fieldName: 'Asset name'),
),
const SizedBox(height: 16),
AppTextField(
controller: _codeController,
label: 'Asset Code',
validator: (v) => Validators.required(v, fieldName: 'Asset code'),
),
const SizedBox(height: 16),
DropdownButtonFormField<AssetCategoryType>(
value: _category,
decoration: const InputDecoration(labelText: 'Category'),
items: AssetCategoryType.values
.map((c) => DropdownMenuItem(value: c, child: Text(c.label)))
.toList(),
onChanged: (v) => setState(() => _category = v!),
),
const SizedBox(height: 16),
AppTextField(controller: _brandController, label: 'Brand'),
const SizedBox(height: 16),
AppTextField(controller: _modelController, label: 'Model'),
const SizedBox(height: 16),
AppTextField(controller: _serialController, label: 'Serial Number'),
const SizedBox(height: 16),
AppTextField(
controller: _costController,
label: 'Purchase Cost',
keyboardType: TextInputType.number,
),
const SizedBox(height: 16),
AppTextField(controller: _vendorController, label: 'Vendor'),
const SizedBox(height: 16),
DropdownButtonFormField<AssetStatus>(
value: _status,
decoration: const InputDecoration(labelText: 'Status'),
items: AssetStatus.values
.map((s) => DropdownMenuItem(value: s, child: Text(s.label)))
.toList(),
onChanged: (v) => setState(() => _status = v!),
),
const SizedBox(height: 24),
AppButton(
label: isEditing ? 'Update Asset' : 'Create Asset',
onPressed: () {
if (_formKey.currentState!.validate()) Navigator.of(context).pop();
},
),
],
),
),
),
),
),
);
}
}

View File

@ -1,46 +1,802 @@
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/models/asset_model.dart';
import '../../../../shared/providers/permissions_provider.dart';
import '../../../../shared/widgets/app_card.dart';
import '../../../../shared/widgets/app_confirmation_dialog.dart';
import '../../../../shared/widgets/app_dropdown.dart';
import '../../../../shared/widgets/app_empty_state.dart';
import '../../../../shared/widgets/app_loading_view.dart';
import '../../../../shared/widgets/app_searchable_dropdown.dart';
import '../../../../shared/widgets/can_permission.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../rbac/presentation/widgets/rbac_widgets.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/assets_provider.dart';
import '../widgets/asset_form_panel.dart';
class AssetListScreen extends StatelessWidget {
class AssetListScreen extends ConsumerStatefulWidget {
const AssetListScreen({super.key});
@override
ConsumerState<AssetListScreen> createState() => _AssetListScreenState();
}
class _AssetListScreenState extends ConsumerState<AssetListScreen> {
static const _tableMinWidth = 1040.0;
String? _selectedCategory;
String? _selectedPlant;
String? _selectedStatus;
@override
Widget build(BuildContext context) {
final assetsAsync = ref.watch(assetsListProvider);
final canEdit = ref.can('assets', PermissionAction.update);
final canDelete = ref.can('assets', PermissionAction.delete);
ref.listen(assetsListProvider, (prev, next) {
final error = next.valueOrNull?.actionError;
final success = next.valueOrNull?.actionSuccess;
if (error != null && error != prev?.valueOrNull?.actionError) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error)));
}
if (success != null && success != prev?.valueOrNull?.actionSuccess) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(success)));
}
});
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Asset Master',
subtitle: 'Manage all company assets',
actions: [
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assetQrScan),
icon: const Icon(Icons.qr_code_scanner),
label: const Text('QR Scan'),
child: assetsAsync.when(
loading: () => const AppLoadingView(message: 'Loading assets...'),
error: (error, _) => ErrorView.fromFailure(
error is Failure ? error : Failure.unknown(message: error.toString()),
onRetry: () => ref.invalidate(assetsListProvider),
),
data: (state) {
final allCategories = ref.watch(assetCategoriesProvider).valueOrNull ?? [];
final filteredAssets = _filterAssets(state.assets);
final categories = _categoryOptions(state.assets, allCategories);
final plants = _plantOptions(state.assets);
final statuses = _statusOptions();
final categoryFilter = _selectedCategory ?? 'All categories';
final plantFilter = _selectedPlant ?? 'All plants';
final statusFilter = _selectedStatus ?? 'All statuses';
final page = state.query.page;
final pageSize = state.query.limit;
final total = state.total;
final start = total == 0 ? 0 : ((page - 1) * pageSize) + 1;
final end = (page * pageSize).clamp(0, total);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(
title: 'Asset Master',
subtitle: 'Manage plant assets, AMC, service & insurance',
actions: [
OutlinedButton.icon(
onPressed: () => context.go(RouteConstants.assetAlerts),
icon: const Icon(Icons.notifications_outlined),
label: const Text('Alerts'),
),
const SizedBox(width: 8),
CanPermission(
module: 'assets',
action: PermissionAction.create,
child: ElevatedButton.icon(
onPressed: () => openAssetFormPanel(context, ref),
icon: const Icon(Icons.add),
label: const Text('Add Asset'),
),
),
],
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: () => context.push('${RouteConstants.assets}/add'),
icon: const Icon(Icons.add),
label: const Text('Add Asset'),
const SizedBox(height: 16),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.read(assetsListProvider.notifier).refresh(),
child: context.isMobile
? _AssetMobileList(
assets: filteredAssets,
onView: _viewAsset,
onEdit: canEdit ? _editAsset : null,
onDelete: canDelete ? _deleteAsset : null,
)
: AppCard(
enableHover: false,
clipBehavior: Clip.none,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.12),
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: LayoutBuilder(
builder: (context, constraints) {
return _AssetsFilterBar(
wrapped: constraints.maxWidth < 1000,
categoryFilter: categoryFilter,
plantFilter: plantFilter,
statusFilter: statusFilter,
categories: categories,
plants: plants,
statuses: statuses,
onSearch:
ref.read(assetsListProvider.notifier).setSearch,
onCategoryChanged: (value) {
setState(() {
_selectedCategory =
value == 'All categories' ? null : value;
});
},
onPlantChanged: (value) {
setState(() {
_selectedPlant =
value == 'All plants' ? null : value;
});
},
onStatusChanged: (value) {
setState(() {
_selectedStatus =
value == 'All statuses' ? null : value;
});
},
);
},
),
),
const Divider(height: 1),
if (filteredAssets.isEmpty)
Expanded(
child: Center(
child: Text(
'No assets found',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
)
else
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final tableWidth =
constraints.maxWidth < _tableMinWidth
? _tableMinWidth
: constraints.maxWidth;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding:
const EdgeInsets.symmetric(horizontal: 4),
child: ConstrainedBox(
constraints:
BoxConstraints(minWidth: tableWidth),
child: DataTable(
horizontalMargin: 20,
columnSpacing: 16,
headingRowColor: WidgetStateProperty.all(
Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.4),
),
columns: const [
DataColumn(label: Text('ASSET')),
DataColumn(label: Text('CATEGORY')),
DataColumn(label: Text('PLANT')),
DataColumn(label: Text('WARRANTY')),
DataColumn(label: Text('STATUS')),
DataColumn(
label: _AssetTableActionsHeader(),
),
],
rows: filteredAssets.map((asset) {
final status =
assetStatusDisplay(asset.status);
return DataRow(
cells: [
DataCell(
Row(
children: [
UserAvatarChip(
name: asset.assetName,
initials:
assetInitials(asset),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Text(
asset.assetName,
style: const TextStyle(
fontWeight:
FontWeight.w600,
),
),
Text(
asset.assetCode ?? '',
style: Theme.of(context)
.textTheme
.bodySmall,
),
],
),
],
),
),
DataCell(
RoleBadge(
label: asset.assetCategoryName ??
'',
),
),
DataCell(
Text(asset.plantName ?? ''),
),
DataCell(
Text(
DateFormatter.displayDate(
asset.warrantyExpiryDate,
),
),
),
DataCell(
StatusBadge(
label: status.label,
color: status.color,
),
),
DataCell(
_AssetTableActionsCell(
child: _AssetTableActions(
canView: true,
canEdit: canEdit,
canDelete: canDelete,
onView: () => _viewAsset(asset),
onEdit: () => _editAsset(asset),
onDelete: () =>
_deleteAsset(asset),
),
),
),
],
);
}).toList(),
),
),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Text(
'Showing $start$end of $total assets',
style: Theme.of(context).textTheme.bodySmall,
),
const Spacer(),
TextButton(
onPressed: page > 1
? () => ref
.read(assetsListProvider.notifier)
.setPage(page - 1)
: null,
child: const Text('Previous'),
),
...List.generate(state.totalPages.clamp(0, 4), (i) {
final pageIndex = i + 1;
final selected = page == pageIndex;
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 2),
child: Material(
color: selected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => ref
.read(assetsListProvider.notifier)
.setPage(pageIndex),
child: SizedBox(
width: 36,
height: 36,
child: Center(
child: Text(
'$pageIndex',
style: TextStyle(
color: selected
? Theme.of(context)
.colorScheme
.onPrimary
: null,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}),
TextButton(
onPressed: page < state.totalPages
? () => ref
.read(assetsListProvider.notifier)
.setPage(page + 1)
: null,
child: const Text('Next'),
),
],
),
),
],
),
),
),
),
],
),
const Expanded(
child: EmptyStateView(
title: 'No assets yet',
description: 'Add assets to start tracking laptops, devices, furniture, and more.',
icon: Icons.inventory_2_outlined,
),
),
);
},
),
);
}
List<AssetModel> _filterAssets(List<AssetModel> assets) {
return assets.where((asset) {
if (_selectedCategory != null &&
asset.assetCategoryName != _selectedCategory) {
return false;
}
if (_selectedPlant != null && asset.plantName != _selectedPlant) {
return false;
}
if (_selectedStatus != null) {
final label = assetStatusDisplay(asset.status).label;
if (label != _selectedStatus) return false;
}
return true;
}).toList();
}
List<String> _categoryOptions(
List<AssetModel> assets,
List<AssetCategoryModel> allCategories,
) {
final names = <String>{};
for (final asset in assets) {
final name = asset.assetCategoryName;
if (name != null && name.isNotEmpty) names.add(name);
}
for (final category in allCategories) {
names.add(category.name);
}
final sorted = names.toList()..sort();
return ['All categories', ...sorted];
}
List<String> _plantOptions(List<AssetModel> assets) {
final names = assets
.map((a) => a.plantName)
.whereType<String>()
.where((n) => n.isNotEmpty)
.toSet()
.toList()
..sort();
return ['All plants', ...names];
}
List<String> _statusOptions() {
return [
'All statuses',
'In Use',
'Available',
'Under Maintenance',
'Disposed',
'Inactive',
];
}
void _viewAsset(AssetModel asset) {
context.push('${RouteConstants.assets}/${asset.id}');
}
void _editAsset(AssetModel asset) {
openAssetFormPanel(context, ref, assetId: asset.id);
}
Future<void> _deleteAsset(AssetModel asset) async {
final confirmed = await showAppConfirmationDialog(
context: context,
title: 'Delete Asset',
message: 'Soft delete "${asset.assetName}"?',
confirmLabel: 'Delete',
isDestructive: true,
);
if (confirmed == true && mounted) {
await ref.read(assetsListProvider.notifier).deleteAsset(asset.id);
}
}
}
String assetInitials(AssetModel asset) {
final code = asset.assetCode;
if (code != null && code.isNotEmpty) {
return code.length >= 2 ? code.substring(0, 2).toUpperCase() : code.toUpperCase();
}
if (asset.assetName.isNotEmpty) {
final parts = asset.assetName.trim().split(RegExp(r'\s+'));
if (parts.length >= 2) {
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
}
return asset.assetName[0].toUpperCase();
}
return 'A';
}
({String label, Color color}) assetStatusDisplay(String? status) {
return switch (status?.toUpperCase().replaceAll(' ', '_')) {
'IN_USE' => (label: 'In Use', color: const Color(0xFF16A34A)),
'AVAILABLE' => (label: 'Available', color: const Color(0xFF2563EB)),
'UNDER_MAINTENANCE' => (
label: 'Under Maintenance',
color: const Color(0xFFCA8A04),
),
'DISPOSED' => (label: 'Disposed', color: const Color(0xFF6B7280)),
'INACTIVE' => (label: 'Inactive', color: const Color(0xFFDC2626)),
_ when status != null && status.isNotEmpty => (
label: status.replaceAll('_', ' '),
color: const Color(0xFF16A34A),
),
_ => (label: 'Active', color: const Color(0xFF16A34A)),
};
}
class _AssetsFilterBar extends StatelessWidget {
const _AssetsFilterBar({
required this.wrapped,
required this.categoryFilter,
required this.plantFilter,
required this.statusFilter,
required this.categories,
required this.plants,
required this.statuses,
required this.onSearch,
required this.onCategoryChanged,
required this.onPlantChanged,
required this.onStatusChanged,
});
final bool wrapped;
final String categoryFilter;
final String plantFilter;
final String statusFilter;
final List<String> categories;
final List<String> plants;
final List<String> statuses;
final ValueChanged<String> onSearch;
final ValueChanged<String> onCategoryChanged;
final ValueChanged<String> onPlantChanged;
final ValueChanged<String> onStatusChanged;
@override
Widget build(BuildContext context) {
final searchField = TextField(
decoration: const InputDecoration(
hintText: 'Search by name, code, category...',
prefixIcon: Icon(Icons.search, size: 20),
isDense: true,
),
onChanged: onSearch,
);
final filters = [
_AssetFilterDropdown(
value: categoryFilter,
label: 'Category',
items: categories,
onChanged: onCategoryChanged,
),
_AssetFilterDropdown(
value: plantFilter,
label: 'Plant',
items: plants,
onChanged: onPlantChanged,
),
_AssetFilterDropdown(
value: statusFilter,
label: 'Status',
items: statuses,
onChanged: onStatusChanged,
),
];
if (wrapped) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
searchField,
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: filters),
],
);
}
return Row(
children: [
Expanded(flex: 3, child: searchField),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[0]),
const SizedBox(width: 12),
Expanded(flex: 2, child: filters[1]),
const SizedBox(width: 12),
Expanded(child: filters[2]),
],
);
}
}
class _AssetFilterDropdown extends StatelessWidget {
const _AssetFilterDropdown({
required this.value,
required this.label,
required this.items,
required this.onChanged,
});
final String value;
final String label;
final List<String> items;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return AppSearchableDropdown<String>(
label: label,
value: value,
isDense: true,
searchHint: 'Search $label...',
options: items
.map((item) => AppDropdownOption(value: item, label: item))
.toList(),
onChanged: (v) {
if (v != null) onChanged(v);
},
);
}
}
class _AssetTableActions extends StatelessWidget {
const _AssetTableActions({
required this.onView,
required this.onEdit,
required this.onDelete,
this.canView = true,
this.canEdit = true,
this.canDelete = true,
});
static const columnWidth = 108.0;
final VoidCallback onView;
final VoidCallback onEdit;
final VoidCallback onDelete;
final bool canView;
final bool canEdit;
final bool canDelete;
@override
Widget build(BuildContext context) {
final muted = Theme.of(context).colorScheme.onSurfaceVariant;
return Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (canView)
_AssetActionIcon(
tooltip: 'View asset',
icon: Icons.visibility_outlined,
color: muted,
onPressed: onView,
),
if (canEdit)
_AssetActionIcon(
tooltip: 'Edit asset',
icon: Icons.edit_outlined,
color: muted,
onPressed: onEdit,
),
if (canDelete)
_AssetActionIcon(
tooltip: 'Delete asset',
icon: Icons.delete_outline,
color: muted,
onPressed: onDelete,
),
],
);
}
}
class _AssetTableActionsHeader extends StatelessWidget {
const _AssetTableActionsHeader();
@override
Widget build(BuildContext context) {
return const SizedBox(
width: _AssetTableActions.columnWidth,
child: Align(
alignment: Alignment.centerRight,
child: Text('ACTIONS'),
),
);
}
}
class _AssetTableActionsCell extends StatelessWidget {
const _AssetTableActionsCell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _AssetTableActions.columnWidth,
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.only(right: 4),
child: child,
),
),
);
}
}
class _AssetActionIcon extends StatelessWidget {
const _AssetActionIcon({
required this.tooltip,
required this.icon,
required this.color,
required this.onPressed,
});
final String tooltip;
final IconData icon;
final Color color;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(icon, size: 18, color: color),
),
),
);
}
}
class _AssetMobileList extends StatelessWidget {
const _AssetMobileList({
required this.assets,
required this.onView,
this.onEdit,
this.onDelete,
});
final List<AssetModel> assets;
final void Function(AssetModel) onView;
final void Function(AssetModel)? onEdit;
final void Function(AssetModel)? onDelete;
@override
Widget build(BuildContext context) {
if (assets.isEmpty) {
return ListView(
children: const [
AppEmptyState(
title: 'No assets found',
description: 'Add assets or adjust your search.',
icon: Icons.inventory_2_outlined,
),
],
);
}
return ListView.separated(
itemCount: assets.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final asset = assets[index];
final status = assetStatusDisplay(asset.status);
return AppCard(
child: ListTile(
onTap: () => onView(asset),
leading: UserAvatarChip(
name: asset.assetName,
initials: assetInitials(asset),
),
title: Text(
asset.assetName,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(asset.assetCode ?? ''),
const SizedBox(height: 4),
Row(
children: [
Flexible(
child: RoleBadge(
label: asset.assetCategoryName ?? '',
),
),
const SizedBox(width: 8),
StatusBadge(label: status.label, color: status.color),
],
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onEdit != null)
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => onEdit!(asset),
),
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => onDelete!(asset),
),
],
),
),
);
},
);
}
}

View File

@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../../../core/errors/failure.dart';
import '../../../../core/utils/formatters.dart';
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_loading_view.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../../../../shared/widgets/error_view.dart';
import '../../../masters/data/datasources/master_remote_data_source.dart';
import '../providers/asset_categories_provider.dart';
import '../providers/assets_provider.dart';
Future<void> openAssetFormPanel(
BuildContext context,
WidgetRef ref, {
String? assetId,
}) async {
ref.invalidate(assetFormProvider(assetId));
final saved = await showSidePanel<bool>(
context,
AssetFormPanel(assetId: assetId),
width: 520,
);
if (saved == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
assetId == null
? 'Asset created successfully'
: 'Asset updated successfully',
),
),
);
}
}
class AssetFormPanel extends ConsumerStatefulWidget {
const AssetFormPanel({super.key, this.assetId});
final String? assetId;
bool get isEditing => assetId != null;
@override
ConsumerState<AssetFormPanel> createState() => _AssetFormPanelState();
}
class _AssetFormPanelState extends ConsumerState<AssetFormPanel> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _costController = TextEditingController();
int? _categoryId;
int? _plantId;
DateTime? _warrantyExpiry;
bool _isSubmitting = false;
String? _populatedSignature;
@override
void dispose() {
_nameController.dispose();
_costController.dispose();
super.dispose();
}
String _assetSignature(AssetModel asset) =>
'${asset.id}:${asset.assetCategoryId}:${asset.plantId}:'
'${asset.warrantyExpiryDate?.toIso8601String()}:'
'${asset.purchaseCost}:${asset.assetName}';
int? _dropdownValue(int? selected, Iterable<int> validIds) {
if (selected == null) return null;
return validIds.contains(selected) ? selected : null;
}
void _populateFromAsset(AssetModel asset) {
setState(() {
_nameController.text = asset.assetName;
_categoryId = asset.assetCategoryId;
_plantId = asset.plantId;
_warrantyExpiry = asset.warrantyExpiryDate;
if (asset.purchaseCost != null) {
_costController.text = asset.purchaseCost!.toStringAsFixed(
asset.purchaseCost! % 1 == 0 ? 0 : 2,
);
} else {
_costController.clear();
}
});
}
Map<String, dynamic> _buildPayload() {
return {
'asset_name': _nameController.text.trim(),
'asset_category_id': _categoryId,
'plant_id': _plantId,
if (_warrantyExpiry != null)
'warranty_expiry_date': DateFormat('yyyy-MM-dd').format(_warrantyExpiry!),
if (_costController.text.trim().isNotEmpty)
'purchase_cost': double.tryParse(_costController.text.trim()),
};
}
Future<void> _pickWarrantyDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _warrantyExpiry ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => _warrantyExpiry = picked);
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_categoryId == null || _plantId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select category and plant')),
);
return;
}
setState(() => _isSubmitting = true);
try {
final notifier = ref.read(assetFormProvider(widget.assetId).notifier);
final payload = _buildPayload();
if (widget.isEditing) {
await notifier.submitUpdate(widget.assetId!, payload);
} else {
await notifier.submitCreate(payload);
}
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())),
);
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
final categoriesAsync = ref.watch(assetCategoriesProvider);
final plantsAsync = ref.watch(assetPlantsProvider);
if (widget.isEditing) {
ref.listen(assetFormProvider(widget.assetId), (prev, next) {
next.whenData((asset) {
if (asset == null || !mounted) return;
final signature = _assetSignature(asset);
if (_populatedSignature != signature) {
_populatedSignature = signature;
_populateFromAsset(asset);
}
});
});
}
final formAsync =
widget.isEditing ? ref.watch(assetFormProvider(widget.assetId)) : null;
return SidePanelScaffold(
title: widget.isEditing ? 'Edit asset' : 'Add asset',
footer: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: widget.isEditing ? 'Update asset' : 'Save asset',
expand: false,
icon: Icons.check,
isLoading: _isSubmitting,
onPressed: _isSubmitting ? null : _submit,
),
],
),
child: widget.isEditing && formAsync != null
? formAsync.when(
loading: () => const AppLoadingView(message: 'Loading asset...'),
error: (e, _) => ErrorView.fromFailure(
e is Failure ? e : Failure.unknown(message: e.toString()),
onRetry: () => ref.invalidate(assetFormProvider(widget.assetId)),
),
data: (_) => _buildForm(categoriesAsync, plantsAsync),
)
: _buildForm(categoriesAsync, plantsAsync),
);
}
Widget _buildForm(
AsyncValue<List<AssetCategoryModel>> categoriesAsync,
AsyncValue<List<FilterOptionModel>> plantsAsync,
) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AppTextField(
controller: _nameController,
label: 'Asset Name *',
validator: (v) => Validators.required(v, fieldName: 'Asset name'),
),
const SizedBox(height: 12),
SidePanelFormRow(
left: categoriesAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load categories'),
data: (categories) => _categoryDropdown(categories),
),
right: plantsAsync.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('Failed to load plants'),
data: (plants) => _plantDropdown(plants),
),
),
SidePanelFormRow(
left: _AssetFormDateField(
label: 'Warranty Expiry Date',
value: _warrantyExpiry,
onPick: _pickWarrantyDate,
),
right: AppTextField(
controller: _costController,
label: 'Purchase Cost',
keyboardType: TextInputType.number,
),
),
],
),
);
}
Widget _categoryDropdown(List<AssetCategoryModel> categories) {
final categoryIds = categories
.map((c) => int.tryParse(c.id))
.whereType<int>()
.toList();
return DropdownButtonFormField<int>(
value: _dropdownValue(_categoryId, categoryIds),
isExpanded: true,
decoration: const InputDecoration(labelText: 'Category *'),
items: categories
.map(
(c) => DropdownMenuItem(
value: int.tryParse(c.id),
child: Text('${c.code}${c.name}'),
),
)
.where((item) => item.value != null)
.toList(),
onChanged: (v) => setState(() => _categoryId = v),
validator: (v) => v == null ? 'Category is required' : null,
);
}
Widget _plantDropdown(List<FilterOptionModel> plants) {
final plantIds = plants
.map((p) => int.tryParse(p.id))
.whereType<int>()
.toList();
return DropdownButtonFormField<int>(
value: _dropdownValue(_plantId, plantIds),
isExpanded: true,
decoration: const InputDecoration(labelText: 'Plant *'),
items: plants
.map(
(p) => DropdownMenuItem(
value: int.tryParse(p.id),
child: Text(p.name),
),
)
.where((item) => item.value != null)
.toList(),
onChanged: (v) => setState(() => _plantId = v),
validator: (v) => v == null ? 'Plant is required' : null,
);
}
}
class _AssetFormDateField extends StatelessWidget {
const _AssetFormDateField({
required this.label,
required this.value,
required this.onPick,
});
final String label;
final DateTime? value;
final VoidCallback onPick;
@override
Widget build(BuildContext context) {
final displayText = value != null ? DateFormatter.displayDate(value) : '';
return TextFormField(
readOnly: true,
onTap: onPick,
decoration: InputDecoration(
labelText: label,
hintText: 'Select date',
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
),
controller: TextEditingController(text: displayText),
);
}
}
final assetPlantsProvider = FutureProvider<List<FilterOptionModel>>((ref) async {
final dataSource = ref.watch(masterRemoteDataSourceProvider);
return dataSource.listPlants();
});

View File

@ -0,0 +1,500 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/utils/validators.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/app_side_panel.dart';
import '../../../../shared/widgets/app_text_field.dart';
import '../providers/assets_provider.dart';
class AddAmcPanel extends ConsumerStatefulWidget {
const AddAmcPanel({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<AddAmcPanel> createState() => _AddAmcPanelState();
}
class _AddAmcPanelState extends ConsumerState<AddAmcPanel> {
final _formKey = GlobalKey<FormState>();
final _vendorIdController = TextEditingController();
final _contractNoController = TextEditingController();
final _annualCostController = TextEditingController();
DateTime? _startDate;
DateTime? _endDate;
String _contractType = 'COMPREHENSIVE';
bool _isSubmitting = false;
@override
void dispose() {
_vendorIdController.dispose();
_contractNoController.dispose();
_annualCostController.dispose();
super.dispose();
}
Future<void> _pickDate({required bool isStart}) async {
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _startDate : _endDate) ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
if (isStart) {
_startDate = picked;
} else {
_endDate = picked;
}
});
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select start and end dates')),
);
return;
}
final vendorId = int.tryParse(_vendorIdController.text.trim());
if (vendorId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a valid vendor ID')),
);
return;
}
setState(() => _isSubmitting = true);
try {
await ref.read(assetDetailProvider(widget.assetId).notifier).createAmc({
'vendor_id': vendorId,
if (_contractNoController.text.isNotEmpty)
'contract_no': _contractNoController.text.trim(),
'contract_type': _contractType,
'start_date': DateFormatter.toApiDate(_startDate!),
'end_date': DateFormatter.toApiDate(_endDate!),
if (_annualCostController.text.isNotEmpty)
'annual_cost': double.tryParse(_annualCostController.text),
});
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: 'Add AMC Contract',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save contract',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: AppTextField(
controller: _vendorIdController,
label: 'Vendor ID *',
keyboardType: TextInputType.number,
validator: (v) => Validators.required(v, fieldName: 'Vendor ID'),
),
right: AppTextField(
controller: _contractNoController,
label: 'Contract No',
),
),
DropdownButtonFormField<String>(
value: _contractType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Contract Type'),
items: const [
'COMPREHENSIVE',
'LABOUR_ONLY',
'PARTS_ONLY',
'PREVENTIVE_ONLY',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _contractType = v);
},
),
const SizedBox(height: 12),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Start Date',
isRequired: true,
value: _startDate,
onPick: () => _pickDate(isStart: true),
),
right: _SidePanelDateField(
label: 'End Date',
isRequired: true,
value: _endDate,
onPick: () => _pickDate(isStart: false),
),
),
AppTextField(
controller: _annualCostController,
label: 'Annual Cost',
keyboardType: TextInputType.number,
),
],
),
),
);
}
}
class LogServiceVisitPanel extends ConsumerStatefulWidget {
const LogServiceVisitPanel({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<LogServiceVisitPanel> createState() => _LogServiceVisitPanelState();
}
class _LogServiceVisitPanelState extends ConsumerState<LogServiceVisitPanel> {
final _formKey = GlobalKey<FormState>();
final _workDoneController = TextEditingController();
DateTime? _visitDate = DateTime.now();
String _visitType = 'PREVENTIVE';
String _status = 'COMPLETED';
bool _isSubmitting = false;
@override
void dispose() {
_workDoneController.dispose();
super.dispose();
}
Future<void> _pickVisitDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _visitDate ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) setState(() => _visitDate = picked);
}
Future<void> _save() async {
if (_visitDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select visit date')),
);
return;
}
setState(() => _isSubmitting = true);
try {
await ref.read(assetDetailProvider(widget.assetId).notifier).logVisit({
'visit_type': _visitType,
'visit_date': DateFormatter.toApiDate(_visitDate!),
if (_workDoneController.text.isNotEmpty)
'work_done': _workDoneController.text.trim(),
'status': _status,
});
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: 'Log Service Visit',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save visit',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: DropdownButtonFormField<String>(
value: _visitType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Visit Type *'),
items: const [
'PREVENTIVE',
'BREAKDOWN',
'INSPECTION',
'INSTALLATION',
'CALIBRATION',
'OTHER',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _visitType = v);
},
),
right: DropdownButtonFormField<String>(
value: _status,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Status'),
items: const [
'SCHEDULED',
'IN_PROGRESS',
'COMPLETED',
'CANCELLED',
'PENDING_PARTS',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _status = v);
},
),
),
_SidePanelDateField(
label: 'Visit Date',
isRequired: true,
value: _visitDate,
onPick: _pickVisitDate,
),
const SizedBox(height: 12),
AppTextField(
controller: _workDoneController,
label: 'Work Done',
maxLines: 4,
),
],
),
),
);
}
}
class AddInsurancePanel extends ConsumerStatefulWidget {
const AddInsurancePanel({super.key, required this.assetId});
final String assetId;
@override
ConsumerState<AddInsurancePanel> createState() => _AddInsurancePanelState();
}
class _AddInsurancePanelState extends ConsumerState<AddInsurancePanel> {
final _formKey = GlobalKey<FormState>();
final _policyNoController = TextEditingController();
final _insurerNameController = TextEditingController();
final _sumInsuredController = TextEditingController();
DateTime? _startDate;
DateTime? _endDate;
String _policyType = 'COMPREHENSIVE';
bool _isSubmitting = false;
@override
void dispose() {
_policyNoController.dispose();
_insurerNameController.dispose();
_sumInsuredController.dispose();
super.dispose();
}
Future<void> _pickDate({required bool isStart}) async {
final picked = await showDatePicker(
context: context,
initialDate: (isStart ? _startDate : _endDate) ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
if (isStart) {
_startDate = picked;
} else {
_endDate = picked;
}
});
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
if (_startDate == null || _endDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select start and end dates')),
);
return;
}
setState(() => _isSubmitting = true);
try {
await ref.read(assetDetailProvider(widget.assetId).notifier).createInsurance({
'policy_no': _policyNoController.text.trim(),
'insurer_name': _insurerNameController.text.trim(),
'policy_type': _policyType,
if (_sumInsuredController.text.isNotEmpty)
'sum_insured': double.tryParse(_sumInsuredController.text),
'policy_start_date': DateFormatter.toApiDate(_startDate!),
'policy_end_date': DateFormatter.toApiDate(_endDate!),
});
if (mounted) Navigator.of(context, rootNavigator: true).pop(true);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
}
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return SidePanelScaffold(
title: 'Add Insurance Policy',
footer: _panelFooter(
context,
isSubmitting: _isSubmitting,
saveLabel: 'Save policy',
onSave: _save,
),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SidePanelFormRow(
left: AppTextField(
controller: _policyNoController,
label: 'Policy No *',
validator: (v) => Validators.required(v, fieldName: 'Policy no'),
),
right: AppTextField(
controller: _insurerNameController,
label: 'Insurer Name *',
validator: (v) => Validators.required(v, fieldName: 'Insurer name'),
),
),
SidePanelFormRow(
left: DropdownButtonFormField<String>(
value: _policyType,
isExpanded: true,
decoration: const InputDecoration(labelText: 'Policy Type'),
items: const [
'FIRE_AND_ALLIED',
'MACHINERY_BREAKDOWN',
'COMPREHENSIVE',
'THIRD_PARTY',
'VEHICLE',
'OTHER',
]
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) {
if (v != null) setState(() => _policyType = v);
},
),
right: AppTextField(
controller: _sumInsuredController,
label: 'Sum Insured',
keyboardType: TextInputType.number,
),
),
SidePanelFormRow(
left: _SidePanelDateField(
label: 'Start Date',
isRequired: true,
value: _startDate,
onPick: () => _pickDate(isStart: true),
),
right: _SidePanelDateField(
label: 'End Date',
isRequired: true,
value: _endDate,
onPick: () => _pickDate(isStart: false),
),
),
],
),
),
);
}
}
Widget _panelFooter(
BuildContext context, {
required bool isSubmitting,
required String saveLabel,
required VoidCallback onSave,
}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: isSubmitting
? null
: () => Navigator.of(context, rootNavigator: true).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 12),
AppButton(
label: saveLabel,
expand: false,
icon: Icons.check,
isLoading: isSubmitting,
onPressed: isSubmitting ? null : onSave,
),
],
);
}
class _SidePanelDateField extends StatelessWidget {
const _SidePanelDateField({
required this.label,
required this.value,
required this.onPick,
this.isRequired = false,
});
final String label;
final DateTime? value;
final VoidCallback onPick;
final bool isRequired;
@override
Widget build(BuildContext context) {
final labelText = isRequired ? '$label *' : label;
final displayText = value != null ? DateFormatter.displayDate(value) : '';
return TextFormField(
readOnly: true,
onTap: onPick,
decoration: InputDecoration(
labelText: labelText,
hintText: 'Select date',
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 20),
),
controller: TextEditingController(text: displayText),
);
}
}

View File

@ -77,15 +77,10 @@ final _entries = [
),
// Assets
_GalleryEntry(title: 'Asset List', route: RouteConstants.assets, group: 'Assets'),
_GalleryEntry(title: 'Add Asset', route: RouteConstants.assetAdd, group: 'Assets'),
_GalleryEntry(title: 'Edit Asset', route: '/assets/demo-asset/edit', group: 'Assets'),
_GalleryEntry(title: 'Asset Detail', route: '/assets/demo-asset', group: 'Assets'),
_GalleryEntry(title: 'Asset Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Allocations', route: RouteConstants.assetAllocations, group: 'Assets'),
_GalleryEntry(title: 'Maintenance', route: RouteConstants.assetMaintenance, group: 'Assets'),
_GalleryEntry(title: 'Disposal', route: RouteConstants.assetDisposal, group: 'Assets'),
_GalleryEntry(title: 'QR Scan', route: RouteConstants.assetQrScan, group: 'Assets'),
_GalleryEntry(title: 'QR Generate', route: RouteConstants.assetQrGenerate, group: 'Assets'),
_GalleryEntry(title: 'Categories', route: RouteConstants.assetCategories, group: 'Assets'),
_GalleryEntry(title: 'Alerts', route: RouteConstants.assetAlerts, group: 'Assets'),
// Master data
_GalleryEntry(title: 'Master Data Hub', route: RouteConstants.masterData, group: 'Master Data'),
...masterDefinitions.map(

View File

@ -3,15 +3,80 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'asset_model.freezed.dart';
part 'asset_model.g.dart';
String _idFromJson(Object? value) => value?.toString() ?? '';
int? _intFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
double? _doubleFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is double) return value;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
DateTime _dateFromJson(Object? value) {
if (value is DateTime) return value;
return DateTime.parse(value.toString());
}
DateTime? _dateFromJsonNullable(Object? value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
Object? _readNestedName(Map<dynamic, dynamic> json, String nestedKey) {
final nested = json[nestedKey];
if (nested is Map) return nested['name'];
return null;
}
Object? _readAssetCategoryName(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'asset_category');
}
Object? _readPlantName(Map<dynamic, dynamic> json, String key) {
final flat = json['plant_name'];
if (flat is String && flat.isNotEmpty) return flat;
return _readNestedName(json, 'plant');
}
Object? _readAssetCategoryId(Map<dynamic, dynamic> json, String key) {
final flat = json['asset_category_id'];
if (flat != null) return flat;
final nested = json['asset_category'];
if (nested is Map) return nested['id'];
return null;
}
Object? _readPlantId(Map<dynamic, dynamic> json, String key) {
final flat = json['plant_id'];
if (flat != null) return flat;
final nested = json['plant'];
if (nested is Map) return nested['id'];
return null;
}
@freezed
class AssetCategoryModel with _$AssetCategoryModel {
const factory AssetCategoryModel({
required String id,
@JsonKey(fromJson: _idFromJson) required String id,
required String code,
required String name,
required String slug,
String? description,
@JsonKey(name: 'company_id') String? companyId,
@JsonKey(name: 'code_prefix') String? codePrefix,
@JsonKey(name: 'default_useful_life_years', fromJson: _intFromJsonNullable)
int? defaultUsefulLifeYears,
@JsonKey(name: 'default_depreciation_method') String? defaultDepreciationMethod,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
DateTime? createdAt,
DateTime? updatedAt,
}) = _AssetCategoryModel;
factory AssetCategoryModel.fromJson(Map<String, dynamic> json) =>
@ -21,85 +86,108 @@ class AssetCategoryModel with _$AssetCategoryModel {
@freezed
class AssetModel with _$AssetModel {
const factory AssetModel({
required String id,
required String name,
@JsonKey(name: 'asset_code') required String assetCode,
@JsonKey(name: 'category_id') required String categoryId,
@JsonKey(name: 'category_name') String? categoryName,
String? brand,
String? model,
@JsonKey(name: 'serial_number') String? serialNumber,
@JsonKey(name: 'purchase_date') DateTime? purchaseDate,
@JsonKey(name: 'purchase_cost') double? purchaseCost,
String? vendor,
@JsonKey(name: 'warranty_start') DateTime? warrantyStart,
@JsonKey(name: 'warranty_end') DateTime? warrantyEnd,
@Default('available') String status,
@JsonKey(name: 'branch_id') String? branchId,
@JsonKey(name: 'branch_name') String? branchName,
@JsonKey(name: 'company_id') String? companyId,
@JsonKey(name: 'qr_code_url') String? qrCodeUrl,
DateTime? createdAt,
DateTime? updatedAt,
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_name') required String assetName,
@JsonKey(name: 'asset_code') String? assetCode,
@JsonKey(
name: 'asset_category_id',
readValue: _readAssetCategoryId,
fromJson: _intFromJsonNullable,
)
int? assetCategoryId,
@JsonKey(name: 'asset_category_name', readValue: _readAssetCategoryName)
String? assetCategoryName,
@JsonKey(name: 'plant_id', readValue: _readPlantId, fromJson: _intFromJsonNullable)
int? plantId,
@JsonKey(name: 'plant_name', readValue: _readPlantName) String? plantName,
@JsonKey(name: 'warranty_expiry_date', fromJson: _dateFromJsonNullable)
DateTime? warrantyExpiryDate,
@JsonKey(name: 'purchase_cost', fromJson: _doubleFromJsonNullable) double? purchaseCost,
String? status,
@JsonKey(name: 'is_active') @Default(true) bool isActive,
@JsonKey(name: 'created_at', fromJson: _dateFromJsonNullable) DateTime? createdAt,
@JsonKey(name: 'updated_at', fromJson: _dateFromJsonNullable) DateTime? updatedAt,
}) = _AssetModel;
factory AssetModel.fromJson(Map<String, dynamic> json) => _$AssetModelFromJson(json);
}
@freezed
class AssetAllocationModel with _$AssetAllocationModel {
const factory AssetAllocationModel({
required String id,
@JsonKey(name: 'asset_id') required String assetId,
@JsonKey(name: 'asset_name') String? assetName,
@JsonKey(name: 'employee_id') required String employeeId,
@JsonKey(name: 'employee_name') String? employeeName,
@JsonKey(name: 'assigned_date') required DateTime assignedDate,
@JsonKey(name: 'returned_date') DateTime? returnedDate,
String? remarks,
class AmcContractModel with _$AmcContractModel {
const factory AmcContractModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'vendor_id', fromJson: _intFromJsonNullable) int? vendorId,
@JsonKey(name: 'vendor_name') String? vendorName,
@JsonKey(name: 'contract_no') String? contractNo,
@JsonKey(name: 'contract_type') String? contractType,
@JsonKey(name: 'start_date', fromJson: _dateFromJson) required DateTime startDate,
@JsonKey(name: 'end_date', fromJson: _dateFromJson) required DateTime endDate,
@JsonKey(name: 'annual_cost', fromJson: _doubleFromJsonNullable) double? annualCost,
@Default('active') String status,
DateTime? createdAt,
}) = _AssetAllocationModel;
}) = _AmcContractModel;
factory AssetAllocationModel.fromJson(Map<String, dynamic> json) =>
_$AssetAllocationModelFromJson(json);
factory AmcContractModel.fromJson(Map<String, dynamic> json) =>
_$AmcContractModelFromJson(json);
}
@freezed
class AssetMaintenanceModel with _$AssetMaintenanceModel {
const factory AssetMaintenanceModel({
required String id,
@JsonKey(name: 'asset_id') required String assetId,
@JsonKey(name: 'asset_name') String? assetName,
required String description,
@JsonKey(name: 'service_vendor') String? serviceVendor,
double? cost,
@Default('open') String status,
@JsonKey(name: 'requested_by') String? requestedBy,
@JsonKey(name: 'completed_date') DateTime? completedDate,
class ServiceVisitModel with _$ServiceVisitModel {
const factory ServiceVisitModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'visit_type') required String visitType,
@JsonKey(name: 'visit_date', fromJson: _dateFromJson) required DateTime visitDate,
@JsonKey(name: 'complaint_no') String? complaintNo,
@JsonKey(name: 'work_done') String? workDone,
@JsonKey(name: 'next_service_date', fromJson: _dateFromJsonNullable)
DateTime? nextServiceDate,
@Default('COMPLETED') String status,
DateTime? createdAt,
DateTime? updatedAt,
}) = _AssetMaintenanceModel;
}) = _ServiceVisitModel;
factory AssetMaintenanceModel.fromJson(Map<String, dynamic> json) =>
_$AssetMaintenanceModelFromJson(json);
factory ServiceVisitModel.fromJson(Map<String, dynamic> json) =>
_$ServiceVisitModelFromJson(json);
}
@freezed
class AssetDisposalModel with _$AssetDisposalModel {
const factory AssetDisposalModel({
required String id,
@JsonKey(name: 'asset_id') required String assetId,
@JsonKey(name: 'asset_name') String? assetName,
@JsonKey(name: 'disposal_reason') required String disposalReason,
@JsonKey(name: 'disposal_date') DateTime? disposalDate,
@Default('pending') String status,
@JsonKey(name: 'requested_by') String? requestedBy,
@JsonKey(name: 'approved_by') String? approvedBy,
class InsurancePolicyModel with _$InsurancePolicyModel {
const factory InsurancePolicyModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'policy_no') required String policyNo,
@JsonKey(name: 'insurer_name') required String insurerName,
@JsonKey(name: 'policy_type') String? policyType,
@JsonKey(name: 'sum_insured', fromJson: _doubleFromJsonNullable) double? sumInsured,
@JsonKey(name: 'policy_start_date', fromJson: _dateFromJson)
required DateTime policyStartDate,
@JsonKey(name: 'policy_end_date', fromJson: _dateFromJson) required DateTime policyEndDate,
@Default('active') String status,
DateTime? createdAt,
DateTime? updatedAt,
}) = _AssetDisposalModel;
}) = _InsurancePolicyModel;
factory AssetDisposalModel.fromJson(Map<String, dynamic> json) =>
_$AssetDisposalModelFromJson(json);
factory InsurancePolicyModel.fromJson(Map<String, dynamic> json) =>
_$InsurancePolicyModelFromJson(json);
}
@freezed
class AssetAlertModel with _$AssetAlertModel {
const factory AssetAlertModel({
@JsonKey(fromJson: _idFromJson) required String id,
@JsonKey(name: 'asset_id', fromJson: _idFromJson) String? assetId,
@JsonKey(name: 'asset_name') String? assetName,
@JsonKey(name: 'asset_code') String? assetCode,
String? type,
String? title,
String? message,
@JsonKey(name: 'expiry_date', fromJson: _dateFromJsonNullable) DateTime? expiryDate,
@JsonKey(name: 'due_date', fromJson: _dateFromJsonNullable) DateTime? dueDate,
@JsonKey(name: 'days_remaining', fromJson: _intFromJsonNullable) int? daysRemaining,
String? status,
@JsonKey(name: 'plant_name') String? plantName,
}) = _AssetAlertModel;
factory AssetAlertModel.fromJson(Map<String, dynamic> json) =>
_$AssetAlertModelFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@ -9,189 +9,200 @@ part of 'asset_model.dart';
_$AssetCategoryModelImpl _$$AssetCategoryModelImplFromJson(
Map<String, dynamic> json,
) => _$AssetCategoryModelImpl(
id: json['id'] as String,
id: _idFromJson(json['id']),
code: json['code'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
description: json['description'] as String?,
companyId: json['company_id'] as String?,
codePrefix: json['code_prefix'] as String?,
defaultUsefulLifeYears: _intFromJsonNullable(
json['default_useful_life_years'],
),
defaultDepreciationMethod: json['default_depreciation_method'] as String?,
isActive: json['is_active'] as bool? ?? true,
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _$$AssetCategoryModelImplToJson(
_$AssetCategoryModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'code': instance.code,
'name': instance.name,
'slug': instance.slug,
'description': instance.description,
'company_id': instance.companyId,
'code_prefix': instance.codePrefix,
'default_useful_life_years': instance.defaultUsefulLifeYears,
'default_depreciation_method': instance.defaultDepreciationMethod,
'is_active': instance.isActive,
'createdAt': instance.createdAt?.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
};
_$AssetModelImpl _$$AssetModelImplFromJson(Map<String, dynamic> json) =>
_$AssetModelImpl(
id: json['id'] as String,
name: json['name'] as String,
assetCode: json['asset_code'] as String,
categoryId: json['category_id'] as String,
categoryName: json['category_name'] as String?,
brand: json['brand'] as String?,
model: json['model'] as String?,
serialNumber: json['serial_number'] as String?,
purchaseDate: json['purchase_date'] == null
? null
: DateTime.parse(json['purchase_date'] as String),
purchaseCost: (json['purchase_cost'] as num?)?.toDouble(),
vendor: json['vendor'] as String?,
warrantyStart: json['warranty_start'] == null
? null
: DateTime.parse(json['warranty_start'] as String),
warrantyEnd: json['warranty_end'] == null
? null
: DateTime.parse(json['warranty_end'] as String),
status: json['status'] as String? ?? 'available',
branchId: json['branch_id'] as String?,
branchName: json['branch_name'] as String?,
companyId: json['company_id'] as String?,
qrCodeUrl: json['qr_code_url'] as String?,
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
id: _idFromJson(json['id']),
assetName: json['asset_name'] as String,
assetCode: json['asset_code'] as String?,
assetCategoryId: _intFromJsonNullable(
_readAssetCategoryId(json, 'asset_category_id'),
),
assetCategoryName:
_readAssetCategoryName(json, 'asset_category_name') as String?,
plantId: _intFromJsonNullable(_readPlantId(json, 'plant_id')),
plantName: _readPlantName(json, 'plant_name') as String?,
warrantyExpiryDate: _dateFromJsonNullable(json['warranty_expiry_date']),
purchaseCost: _doubleFromJsonNullable(json['purchase_cost']),
status: json['status'] as String?,
isActive: json['is_active'] as bool? ?? true,
createdAt: _dateFromJsonNullable(json['created_at']),
updatedAt: _dateFromJsonNullable(json['updated_at']),
);
Map<String, dynamic> _$$AssetModelImplToJson(_$AssetModelImpl instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'asset_name': instance.assetName,
'asset_code': instance.assetCode,
'category_id': instance.categoryId,
'category_name': instance.categoryName,
'brand': instance.brand,
'model': instance.model,
'serial_number': instance.serialNumber,
'purchase_date': instance.purchaseDate?.toIso8601String(),
'asset_category_id': instance.assetCategoryId,
'asset_category_name': instance.assetCategoryName,
'plant_id': instance.plantId,
'plant_name': instance.plantName,
'warranty_expiry_date': instance.warrantyExpiryDate?.toIso8601String(),
'purchase_cost': instance.purchaseCost,
'vendor': instance.vendor,
'warranty_start': instance.warrantyStart?.toIso8601String(),
'warranty_end': instance.warrantyEnd?.toIso8601String(),
'status': instance.status,
'branch_id': instance.branchId,
'branch_name': instance.branchName,
'company_id': instance.companyId,
'qr_code_url': instance.qrCodeUrl,
'createdAt': instance.createdAt?.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
'is_active': instance.isActive,
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
};
_$AssetAllocationModelImpl _$$AssetAllocationModelImplFromJson(
_$AmcContractModelImpl _$$AmcContractModelImplFromJson(
Map<String, dynamic> json,
) => _$AssetAllocationModelImpl(
id: json['id'] as String,
assetId: json['asset_id'] as String,
assetName: json['asset_name'] as String?,
employeeId: json['employee_id'] as String,
employeeName: json['employee_name'] as String?,
assignedDate: DateTime.parse(json['assigned_date'] as String),
returnedDate: json['returned_date'] == null
? null
: DateTime.parse(json['returned_date'] as String),
remarks: json['remarks'] as String?,
) => _$AmcContractModelImpl(
id: _idFromJson(json['id']),
assetId: _idFromJson(json['asset_id']),
vendorId: _intFromJsonNullable(json['vendor_id']),
vendorName: json['vendor_name'] as String?,
contractNo: json['contract_no'] as String?,
contractType: json['contract_type'] as String?,
startDate: _dateFromJson(json['start_date']),
endDate: _dateFromJson(json['end_date']),
annualCost: _doubleFromJsonNullable(json['annual_cost']),
status: json['status'] as String? ?? 'active',
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$$AssetAllocationModelImplToJson(
_$AssetAllocationModelImpl instance,
Map<String, dynamic> _$$AmcContractModelImplToJson(
_$AmcContractModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'asset_id': instance.assetId,
'asset_name': instance.assetName,
'employee_id': instance.employeeId,
'employee_name': instance.employeeName,
'assigned_date': instance.assignedDate.toIso8601String(),
'returned_date': instance.returnedDate?.toIso8601String(),
'remarks': instance.remarks,
'vendor_id': instance.vendorId,
'vendor_name': instance.vendorName,
'contract_no': instance.contractNo,
'contract_type': instance.contractType,
'start_date': instance.startDate.toIso8601String(),
'end_date': instance.endDate.toIso8601String(),
'annual_cost': instance.annualCost,
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
};
_$AssetMaintenanceModelImpl _$$AssetMaintenanceModelImplFromJson(
_$ServiceVisitModelImpl _$$ServiceVisitModelImplFromJson(
Map<String, dynamic> json,
) => _$AssetMaintenanceModelImpl(
id: json['id'] as String,
assetId: json['asset_id'] as String,
assetName: json['asset_name'] as String?,
description: json['description'] as String,
serviceVendor: json['service_vendor'] as String?,
cost: (json['cost'] as num?)?.toDouble(),
status: json['status'] as String? ?? 'open',
requestedBy: json['requested_by'] as String?,
completedDate: json['completed_date'] == null
? null
: DateTime.parse(json['completed_date'] as String),
) => _$ServiceVisitModelImpl(
id: _idFromJson(json['id']),
assetId: _idFromJson(json['asset_id']),
visitType: json['visit_type'] as String,
visitDate: _dateFromJson(json['visit_date']),
complaintNo: json['complaint_no'] as String?,
workDone: json['work_done'] as String?,
nextServiceDate: _dateFromJsonNullable(json['next_service_date']),
status: json['status'] as String? ?? 'COMPLETED',
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _$$AssetMaintenanceModelImplToJson(
_$AssetMaintenanceModelImpl instance,
Map<String, dynamic> _$$ServiceVisitModelImplToJson(
_$ServiceVisitModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'asset_id': instance.assetId,
'asset_name': instance.assetName,
'description': instance.description,
'service_vendor': instance.serviceVendor,
'cost': instance.cost,
'visit_type': instance.visitType,
'visit_date': instance.visitDate.toIso8601String(),
'complaint_no': instance.complaintNo,
'work_done': instance.workDone,
'next_service_date': instance.nextServiceDate?.toIso8601String(),
'status': instance.status,
'requested_by': instance.requestedBy,
'completed_date': instance.completedDate?.toIso8601String(),
'createdAt': instance.createdAt?.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
};
_$AssetDisposalModelImpl _$$AssetDisposalModelImplFromJson(
_$InsurancePolicyModelImpl _$$InsurancePolicyModelImplFromJson(
Map<String, dynamic> json,
) => _$AssetDisposalModelImpl(
id: json['id'] as String,
assetId: json['asset_id'] as String,
assetName: json['asset_name'] as String?,
disposalReason: json['disposal_reason'] as String,
disposalDate: json['disposal_date'] == null
? null
: DateTime.parse(json['disposal_date'] as String),
status: json['status'] as String? ?? 'pending',
requestedBy: json['requested_by'] as String?,
approvedBy: json['approved_by'] as String?,
) => _$InsurancePolicyModelImpl(
id: _idFromJson(json['id']),
assetId: _idFromJson(json['asset_id']),
policyNo: json['policy_no'] as String,
insurerName: json['insurer_name'] as String,
policyType: json['policy_type'] as String?,
sumInsured: _doubleFromJsonNullable(json['sum_insured']),
policyStartDate: _dateFromJson(json['policy_start_date']),
policyEndDate: _dateFromJson(json['policy_end_date']),
status: json['status'] as String? ?? 'active',
createdAt: json['createdAt'] == null
? null
: DateTime.parse(json['createdAt'] as String),
updatedAt: json['updatedAt'] == null
? null
: DateTime.parse(json['updatedAt'] as String),
);
Map<String, dynamic> _$$AssetDisposalModelImplToJson(
_$AssetDisposalModelImpl instance,
Map<String, dynamic> _$$InsurancePolicyModelImplToJson(
_$InsurancePolicyModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'asset_id': instance.assetId,
'policy_no': instance.policyNo,
'insurer_name': instance.insurerName,
'policy_type': instance.policyType,
'sum_insured': instance.sumInsured,
'policy_start_date': instance.policyStartDate.toIso8601String(),
'policy_end_date': instance.policyEndDate.toIso8601String(),
'status': instance.status,
'createdAt': instance.createdAt?.toIso8601String(),
};
_$AssetAlertModelImpl _$$AssetAlertModelImplFromJson(
Map<String, dynamic> json,
) => _$AssetAlertModelImpl(
id: _idFromJson(json['id']),
assetId: _idFromJson(json['asset_id']),
assetName: json['asset_name'] as String?,
assetCode: json['asset_code'] as String?,
type: json['type'] as String?,
title: json['title'] as String?,
message: json['message'] as String?,
expiryDate: _dateFromJsonNullable(json['expiry_date']),
dueDate: _dateFromJsonNullable(json['due_date']),
daysRemaining: _intFromJsonNullable(json['days_remaining']),
status: json['status'] as String?,
plantName: json['plant_name'] as String?,
);
Map<String, dynamic> _$$AssetAlertModelImplToJson(
_$AssetAlertModelImpl instance,
) => <String, dynamic>{
'id': instance.id,
'asset_id': instance.assetId,
'asset_name': instance.assetName,
'disposal_reason': instance.disposalReason,
'disposal_date': instance.disposalDate?.toIso8601String(),
'asset_code': instance.assetCode,
'type': instance.type,
'title': instance.title,
'message': instance.message,
'expiry_date': instance.expiryDate?.toIso8601String(),
'due_date': instance.dueDate?.toIso8601String(),
'days_remaining': instance.daysRemaining,
'status': instance.status,
'requested_by': instance.requestedBy,
'approved_by': instance.approvedBy,
'createdAt': instance.createdAt?.toIso8601String(),
'updatedAt': instance.updatedAt?.toIso8601String(),
'plant_name': instance.plantName,
};

View File

@ -5,15 +5,10 @@ import 'package:go_router/go_router.dart';
import '../../core/config/dev_config.dart';
import '../../core/constants/route_constants.dart';
import '../../modules/dashboard/presentation/screens/dashboard_screen.dart';
import '../../modules/assets/presentation/screens/asset_allocations_screen.dart';
import '../../modules/assets/presentation/screens/asset_alerts_screen.dart';
import '../../modules/assets/presentation/screens/asset_categories_screen.dart';
import '../../modules/assets/presentation/screens/asset_detail_screen.dart';
import '../../modules/assets/presentation/screens/asset_disposal_screen.dart';
import '../../modules/assets/presentation/screens/asset_form_screen.dart';
import '../../modules/assets/presentation/screens/asset_list_screen.dart';
import '../../modules/assets/presentation/screens/asset_maintenance_screen.dart';
import '../../modules/assets/presentation/screens/asset_qr_generate_screen.dart';
import '../../modules/assets/presentation/screens/asset_qr_scan_screen.dart';
import '../../modules/auth/presentation/screens/change_password_screen.dart';
import '../../modules/auth/presentation/screens/forgot_password_screen.dart';
import '../../modules/auth/presentation/screens/login_screen.dart';
@ -210,44 +205,19 @@ final routerProvider = Provider<GoRouter>((ref) {
pageBuilder: (context, state) =>
shellPage(state, const AssetListScreen()),
routes: [
GoRoute(
path: 'add',
builder: (context, state) => const AssetFormScreen(),
),
GoRoute(
path: 'categories',
builder: (context, state) => const AssetCategoriesScreen(),
),
GoRoute(
path: 'allocations',
builder: (context, state) => const AssetAllocationsScreen(),
),
GoRoute(
path: 'maintenance',
builder: (context, state) => const AssetMaintenanceScreen(),
),
GoRoute(
path: 'disposal',
builder: (context, state) => const AssetDisposalScreen(),
),
GoRoute(
path: 'qr-scan',
builder: (context, state) => const AssetQrScanScreen(),
),
GoRoute(
path: 'qr-generate',
builder: (context, state) => const AssetQrGenerateScreen(),
path: 'alerts',
builder: (context, state) => const AssetAlertsScreen(),
),
GoRoute(
path: ':id',
builder: (context, state) =>
AssetDetailScreen(assetId: state.pathParameters['id']!),
),
GoRoute(
path: ':id/edit',
builder: (context, state) =>
AssetFormScreen(assetId: state.pathParameters['id']),
),
],
),
GoRoute(

View File

@ -67,33 +67,9 @@ const List<MenuItem> appMenuItems = [
module: 'asset_categories',
),
MenuItem(
label: 'Allocations',
icon: Icons.assignment_ind_outlined,
route: RouteConstants.assetAllocations,
module: 'asset_allocations',
),
MenuItem(
label: 'Maintenance',
icon: Icons.build_outlined,
route: RouteConstants.assetMaintenance,
module: 'asset_maintenance',
),
MenuItem(
label: 'Disposal',
icon: Icons.delete_outline,
route: RouteConstants.assetDisposal,
module: 'asset_disposal',
),
MenuItem(
label: 'QR Scan',
icon: Icons.qr_code_scanner_outlined,
route: RouteConstants.assetQrScan,
module: 'assets',
),
MenuItem(
label: 'QR Generate',
icon: Icons.qr_code_2_outlined,
route: RouteConstants.assetQrGenerate,
label: 'Alerts',
icon: Icons.notifications_outlined,
route: RouteConstants.assetAlerts,
module: 'assets',
),
],

View File

@ -49,26 +49,46 @@ class AppSidebar extends ConsumerStatefulWidget {
class _AppSidebarState extends ConsumerState<AppSidebar> {
final Set<String> _expandedMenus = <String>{};
final Set<String> _manuallyCollapsedMenus = <String>{};
List<menu.MenuItem> get _mainMenuItems =>
widget.menuItems.where((item) => item.route != RouteConstants.settings).toList();
bool _isSelected(String route) {
if (route == '/') return widget.currentRoute == route;
return widget.currentRoute.startsWith(route);
bool _routeMatches(String route) {
final current = widget.currentRoute;
if (route == '/') return current == route;
if (current == route) return true;
return current.startsWith('$route/');
}
bool _isGroupActive(menu.MenuItem item) =>
item.children.any((child) => _isSelected(child.route));
bool _isSelected(String route) => _routeMatches(route);
bool _isGroupExpanded(menu.MenuItem item) =>
_expandedMenus.contains(item.route) || _isGroupActive(item);
bool _isSelectedAmongSiblings(String route, List<menu.MenuItem> siblings) {
if (!_routeMatches(route)) return false;
for (final sibling in siblings) {
if (sibling.route == route) continue;
if (sibling.route.length > route.length && _routeMatches(sibling.route)) {
return false;
}
}
return true;
}
bool _isGroupActive(menu.MenuItem item) => item.children
.any((child) => _isSelectedAmongSiblings(child.route, item.children));
bool _isGroupExpanded(menu.MenuItem item) {
if (_manuallyCollapsedMenus.contains(item.route)) return false;
return _expandedMenus.contains(item.route) || _isGroupActive(item);
}
void _toggleGroup(menu.MenuItem item) {
setState(() {
if (_expandedMenus.contains(item.route)) {
if (_isGroupExpanded(item)) {
_expandedMenus.remove(item.route);
_manuallyCollapsedMenus.add(item.route);
} else {
_manuallyCollapsedMenus.remove(item.route);
_expandedMenus.add(item.route);
}
});
@ -81,6 +101,7 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
for (final item in _mainMenuItems) {
if (item.children.isNotEmpty && _isGroupActive(item)) {
_expandedMenus.add(item.route);
_manuallyCollapsedMenus.remove(item.route);
}
}
}
@ -148,7 +169,8 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
return _CollapsedFlyoutNavItem(
item: item,
selected: _isGroupActive(item),
isChildSelected: _isSelected,
isChildSelected: (route) =>
_isSelectedAmongSiblings(route, item.children),
onChildTap: widget.onItemTap,
);
}
@ -173,7 +195,10 @@ class _AppSidebarState extends ConsumerState<AppSidebar> {
(child) => _SidebarNavItem(
icon: child.icon,
label: child.label,
selected: _isSelected(child.route),
selected: _isSelectedAmongSiblings(
child.route,
item.children,
),
collapsed: false,
indent: _sidebarChildIndent,
onTap: () => widget.onItemTap(child.route),