attachment and bug fix
This commit is contained in:
parent
5a572be3c6
commit
a4634a9ba2
@ -97,6 +97,15 @@ class ApiEndpoints {
|
|||||||
static String purchaseOrderAmend(String id) => '/purchase-orders/$id/amend';
|
static String purchaseOrderAmend(String id) => '/purchase-orders/$id/amend';
|
||||||
static String purchaseOrderCancel(String id) => '/purchase-orders/$id/cancel';
|
static String purchaseOrderCancel(String id) => '/purchase-orders/$id/cancel';
|
||||||
static String purchaseOrderPdf(String id) => '/purchase-orders/$id/pdf';
|
static String purchaseOrderPdf(String id) => '/purchase-orders/$id/pdf';
|
||||||
|
static String purchaseOrderAttachments(String poId) =>
|
||||||
|
'/purchase-orders/$poId/attachments';
|
||||||
|
static String purchaseOrderAttachmentById(String poId, String attachmentId) =>
|
||||||
|
'/purchase-orders/$poId/attachments/$attachmentId';
|
||||||
|
static String purchaseOrderAttachmentDownload(
|
||||||
|
String poId,
|
||||||
|
String attachmentId,
|
||||||
|
) =>
|
||||||
|
'/purchase-orders/$poId/attachments/$attachmentId/download';
|
||||||
|
|
||||||
// GRN
|
// GRN
|
||||||
static const String grn = '/grn';
|
static const String grn = '/grn';
|
||||||
@ -114,6 +123,12 @@ class ApiEndpoints {
|
|||||||
static String assetById(String id) => '/assets/$id';
|
static String assetById(String id) => '/assets/$id';
|
||||||
static String assetTransfer(String id) => '/assets/$id/transfer';
|
static String assetTransfer(String id) => '/assets/$id/transfer';
|
||||||
static String assetTransferHistory(String id) => '/assets/$id/transfer-history';
|
static String assetTransferHistory(String id) => '/assets/$id/transfer-history';
|
||||||
|
static String assetAttachments(String assetId) =>
|
||||||
|
'/assets/$assetId/attachments';
|
||||||
|
static String assetAttachmentById(String assetId, String attachmentId) =>
|
||||||
|
'/assets/$assetId/attachments/$attachmentId';
|
||||||
|
static String assetAttachmentDownload(String assetId, String attachmentId) =>
|
||||||
|
'/assets/$assetId/attachments/$attachmentId/download';
|
||||||
static const String assetOptions = '/assets/options';
|
static const String assetOptions = '/assets/options';
|
||||||
static const String assetContractTypes = '/assets/contract-types';
|
static const String assetContractTypes = '/assets/contract-types';
|
||||||
static const String assetVisitTypes = '/assets/visit-types';
|
static const String assetVisitTypes = '/assets/visit-types';
|
||||||
|
|||||||
63
lib/core/utils/table_search.dart
Normal file
63
lib/core/utils/table_search.dart
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// Shared helpers for case-insensitive, trimmed, multi-field table search.
|
||||||
|
class TableSearch {
|
||||||
|
TableSearch._();
|
||||||
|
|
||||||
|
/// Trims leading/trailing whitespace. Does not alter casing.
|
||||||
|
static String normalize(String? query) => query?.trim() ?? '';
|
||||||
|
|
||||||
|
/// Trimmed + lowercased query for matching.
|
||||||
|
static String normalizedQuery(String? query) => normalize(query).toLowerCase();
|
||||||
|
|
||||||
|
/// Returns true when [query] is empty or any [values] contain the query.
|
||||||
|
static bool matches(String? query, Iterable<Object?> values) {
|
||||||
|
final q = normalizedQuery(query);
|
||||||
|
if (q.isEmpty) return true;
|
||||||
|
|
||||||
|
for (final value in values) {
|
||||||
|
if (value == null) continue;
|
||||||
|
final text = value.toString().trim().toLowerCase();
|
||||||
|
if (text.isEmpty) continue;
|
||||||
|
if (text.contains(q)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filters [items] to those where any extracted field matches [query].
|
||||||
|
static List<T> filter<T>(
|
||||||
|
Iterable<T> items,
|
||||||
|
String? query,
|
||||||
|
Iterable<Object?> Function(T item) valuesOf,
|
||||||
|
) {
|
||||||
|
final q = normalizedQuery(query);
|
||||||
|
if (q.isEmpty) {
|
||||||
|
return items is List<T> ? items : items.toList();
|
||||||
|
}
|
||||||
|
return items.where((item) => matches(q, valuesOf(item))).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Debounces search input so API-backed lists are not hit on every keystroke.
|
||||||
|
class SearchDebouncer {
|
||||||
|
SearchDebouncer({this.duration = const Duration(milliseconds: 300)});
|
||||||
|
|
||||||
|
final Duration duration;
|
||||||
|
Timer? _timer;
|
||||||
|
|
||||||
|
/// Runs [action] after [duration]. Empty queries run immediately.
|
||||||
|
void run(String query, void Function(String normalized) action) {
|
||||||
|
final normalized = TableSearch.normalize(query);
|
||||||
|
_timer?.cancel();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
action(normalized);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_timer = Timer(duration, () => action(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
|
||||||
class AssetRemoteDataSource {
|
class AssetRemoteDataSource {
|
||||||
AssetRemoteDataSource({required this.dio});
|
AssetRemoteDataSource({required this.dio});
|
||||||
@ -40,6 +41,50 @@ class AssetRemoteDataSource {
|
|||||||
await dio.delete(ApiEndpoints.assetById(id));
|
await dio.delete(ApiEndpoints.assetById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<EntityAttachmentModel>> listAttachments(String assetId) async {
|
||||||
|
final response = await dio.get(ApiEndpoints.assetAttachments(assetId));
|
||||||
|
final data = response.data['data'];
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return data
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => EntityAttachmentModel.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<EntityAttachmentModel> uploadAttachment(
|
||||||
|
String assetId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
String attachmentType = 'DOCUMENT',
|
||||||
|
}) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
|
'attachment_type': attachmentType,
|
||||||
|
});
|
||||||
|
final response = await dio.post(
|
||||||
|
ApiEndpoints.assetAttachments(assetId),
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
return EntityAttachmentModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(response.data['data'] as Map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> downloadAttachment(
|
||||||
|
String assetId,
|
||||||
|
String attachmentId,
|
||||||
|
) async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
ApiEndpoints.assetAttachmentDownload(assetId, attachmentId),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
return response.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAttachment(String assetId, String attachmentId) async {
|
||||||
|
await dio.delete(ApiEndpoints.assetAttachmentById(assetId, attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> transferAsset(String id, Map<String, dynamic> data) async {
|
Future<void> transferAsset(String id, Map<String, dynamic> data) async {
|
||||||
await dio.post(ApiEndpoints.assetTransfer(id), data: data);
|
await dio.post(ApiEndpoints.assetTransfer(id), data: data);
|
||||||
}
|
}
|
||||||
@ -324,7 +369,7 @@ class AssetRemoteDataSource {
|
|||||||
dynamic body,
|
dynamic body,
|
||||||
T Function(Map<String, dynamic>) fromJson,
|
T Function(Map<String, dynamic>) fromJson,
|
||||||
) {
|
) {
|
||||||
if (body is! Map<String, dynamic>) {
|
if (body is! Map) {
|
||||||
return const PaginatedResponse(
|
return const PaginatedResponse(
|
||||||
items: [],
|
items: [],
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -334,22 +379,66 @@ class AssetRemoteDataSource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final raw = body['data'];
|
final map = Map<String, dynamic>.from(body);
|
||||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
final raw = map['data'];
|
||||||
|
final meta = map['meta'] is Map
|
||||||
|
? Map<String, dynamic>.from(map['meta'] as Map)
|
||||||
|
: <String, dynamic>{};
|
||||||
|
|
||||||
if (raw is List) {
|
if (raw is List) {
|
||||||
final items = raw.whereType<Map<String, dynamic>>().map(fromJson).toList();
|
final items = raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
final limit = (meta['limit'] as num?)?.toInt() ?? items.length;
|
||||||
|
final total = (meta['total'] as num?)?.toInt() ?? items.length;
|
||||||
|
final explicitTotalPages =
|
||||||
|
(meta['totalPages'] as num?)?.toInt() ??
|
||||||
|
(meta['total_pages'] as num?)?.toInt();
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items: items,
|
items: items,
|
||||||
page: (meta['page'] as num?)?.toInt() ?? 1,
|
page: (meta['page'] as num?)?.toInt() ?? 1,
|
||||||
limit: (meta['limit'] as num?)?.toInt() ?? items.length,
|
limit: limit,
|
||||||
total: (meta['total'] as num?)?.toInt() ?? items.length,
|
total: total,
|
||||||
totalPages: (meta['totalPages'] as num?)?.toInt() ?? 1,
|
totalPages: explicitTotalPages ??
|
||||||
|
(limit > 0
|
||||||
|
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||||
|
: 1),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw is Map<String, dynamic>) {
|
if (raw is Map) {
|
||||||
return PaginatedResponse.fromJson(raw, (json) => fromJson(json! as Map<String, dynamic>));
|
final nested = Map<String, dynamic>.from(raw);
|
||||||
|
final list = nested['items'];
|
||||||
|
if (list is List) {
|
||||||
|
final items = list
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
final limit = (meta['limit'] as num?)?.toInt() ??
|
||||||
|
(nested['limit'] as num?)?.toInt() ??
|
||||||
|
20;
|
||||||
|
final total = (meta['total'] as num?)?.toInt() ??
|
||||||
|
(nested['total'] as num?)?.toInt() ??
|
||||||
|
items.length;
|
||||||
|
final explicitTotalPages =
|
||||||
|
(meta['totalPages'] as num?)?.toInt() ??
|
||||||
|
(meta['total_pages'] as num?)?.toInt() ??
|
||||||
|
(nested['totalPages'] as num?)?.toInt() ??
|
||||||
|
(nested['total_pages'] as num?)?.toInt();
|
||||||
|
return PaginatedResponse(
|
||||||
|
items: items,
|
||||||
|
page: (meta['page'] as num?)?.toInt() ??
|
||||||
|
(nested['page'] as num?)?.toInt() ??
|
||||||
|
1,
|
||||||
|
limit: limit,
|
||||||
|
total: total,
|
||||||
|
totalPages: explicitTotalPages ??
|
||||||
|
(limit > 0
|
||||||
|
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||||
|
: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return const PaginatedResponse(
|
return const PaginatedResponse(
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import '../../../../core/network/api_handler.dart';
|
|||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../domain/repositories/asset_repository.dart';
|
import '../../domain/repositories/asset_repository.dart';
|
||||||
import '../datasources/asset_remote_data_source.dart';
|
import '../datasources/asset_remote_data_source.dart';
|
||||||
|
|
||||||
@ -252,4 +253,43 @@ class AssetRepositoryImpl implements AssetRepository {
|
|||||||
) {
|
) {
|
||||||
return safeApiCall(() => dataSource.renewInsurancePolicy(assetId, policyId, data));
|
return safeApiCall(() => dataSource.renewInsurancePolicy(assetId, policyId, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<List<EntityAttachmentModel>>> listAttachments(String assetId) {
|
||||||
|
return safeApiCall(() => dataSource.listAttachments(assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||||
|
String assetId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
String attachmentType = 'DOCUMENT',
|
||||||
|
}) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.uploadAttachment(
|
||||||
|
assetId,
|
||||||
|
bytes: bytes,
|
||||||
|
filename: filename,
|
||||||
|
attachmentType: attachmentType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<List<int>>> downloadAttachment(
|
||||||
|
String assetId,
|
||||||
|
String attachmentId,
|
||||||
|
) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.downloadAttachment(assetId, attachmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void>> deleteAttachment(String assetId, String attachmentId) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.deleteAttachment(assetId, attachmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
|
|
||||||
abstract class AssetRepository {
|
abstract class AssetRepository {
|
||||||
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params);
|
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params);
|
||||||
@ -84,4 +85,16 @@ abstract class AssetRepository {
|
|||||||
String policyId,
|
String policyId,
|
||||||
Map<String, dynamic> data,
|
Map<String, dynamic> data,
|
||||||
);
|
);
|
||||||
|
Future<Result<List<EntityAttachmentModel>>> listAttachments(String assetId);
|
||||||
|
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||||
|
String assetId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
String attachmentType = 'DOCUMENT',
|
||||||
|
});
|
||||||
|
Future<Result<List<int>>> downloadAttachment(
|
||||||
|
String assetId,
|
||||||
|
String attachmentId,
|
||||||
|
);
|
||||||
|
Future<Result<void>> deleteAttachment(String assetId, String attachmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import '../../../../core/utils/table_search.dart';
|
|||||||
|
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../data/repositories/asset_repository_impl.dart';
|
import '../../data/repositories/asset_repository_impl.dart';
|
||||||
|
|
||||||
class AssetsListState {
|
class AssetsListState {
|
||||||
@ -141,24 +142,28 @@ class AssetDetailState {
|
|||||||
this.amcContracts = const [],
|
this.amcContracts = const [],
|
||||||
this.serviceVisits = const [],
|
this.serviceVisits = const [],
|
||||||
this.insurancePolicies = const [],
|
this.insurancePolicies = const [],
|
||||||
|
this.attachments = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
final AssetModel asset;
|
final AssetModel asset;
|
||||||
final List<AmcContractModel> amcContracts;
|
final List<AmcContractModel> amcContracts;
|
||||||
final List<ServiceVisitModel> serviceVisits;
|
final List<ServiceVisitModel> serviceVisits;
|
||||||
final List<InsurancePolicyModel> insurancePolicies;
|
final List<InsurancePolicyModel> insurancePolicies;
|
||||||
|
final List<EntityAttachmentModel> attachments;
|
||||||
|
|
||||||
AssetDetailState copyWith({
|
AssetDetailState copyWith({
|
||||||
AssetModel? asset,
|
AssetModel? asset,
|
||||||
List<AmcContractModel>? amcContracts,
|
List<AmcContractModel>? amcContracts,
|
||||||
List<ServiceVisitModel>? serviceVisits,
|
List<ServiceVisitModel>? serviceVisits,
|
||||||
List<InsurancePolicyModel>? insurancePolicies,
|
List<InsurancePolicyModel>? insurancePolicies,
|
||||||
|
List<EntityAttachmentModel>? attachments,
|
||||||
}) {
|
}) {
|
||||||
return AssetDetailState(
|
return AssetDetailState(
|
||||||
asset: asset ?? this.asset,
|
asset: asset ?? this.asset,
|
||||||
amcContracts: amcContracts ?? this.amcContracts,
|
amcContracts: amcContracts ?? this.amcContracts,
|
||||||
serviceVisits: serviceVisits ?? this.serviceVisits,
|
serviceVisits: serviceVisits ?? this.serviceVisits,
|
||||||
insurancePolicies: insurancePolicies ?? this.insurancePolicies,
|
insurancePolicies: insurancePolicies ?? this.insurancePolicies,
|
||||||
|
attachments: attachments ?? this.attachments,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,12 +182,14 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
|||||||
final amcResult = await repository.getAmcContracts(assetId);
|
final amcResult = await repository.getAmcContracts(assetId);
|
||||||
final visitsResult = await repository.getServiceVisits(assetId);
|
final visitsResult = await repository.getServiceVisits(assetId);
|
||||||
final insuranceResult = await repository.getInsurancePolicies(assetId);
|
final insuranceResult = await repository.getInsurancePolicies(assetId);
|
||||||
|
final attachmentsResult = await repository.listAttachments(assetId);
|
||||||
|
|
||||||
return AssetDetailState(
|
return AssetDetailState(
|
||||||
asset: assetResult.data!,
|
asset: assetResult.data!,
|
||||||
amcContracts: amcResult.data ?? [],
|
amcContracts: amcResult.data ?? [],
|
||||||
serviceVisits: visitsResult.data ?? [],
|
serviceVisits: visitsResult.data ?? [],
|
||||||
insurancePolicies: insuranceResult.data ?? [],
|
insurancePolicies: insuranceResult.data ?? [],
|
||||||
|
attachments: attachmentsResult.data ?? [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,6 +198,57 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
|||||||
state = AsyncData(await _loadAll(arg));
|
state = AsyncData(await _loadAll(arg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<EntityAttachmentModel> uploadAttachment({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
String attachmentType = 'DOCUMENT',
|
||||||
|
}) async {
|
||||||
|
final repository = ref.read(assetRepositoryProvider);
|
||||||
|
final result = await repository.uploadAttachment(
|
||||||
|
arg,
|
||||||
|
bytes: bytes,
|
||||||
|
filename: filename,
|
||||||
|
attachmentType: attachmentType,
|
||||||
|
);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null) {
|
||||||
|
await reload();
|
||||||
|
} else {
|
||||||
|
state = AsyncData(
|
||||||
|
current.copyWith(
|
||||||
|
attachments: [...current.attachments, result.data!],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> downloadAttachment(String attachmentId) async {
|
||||||
|
final repository = ref.read(assetRepositoryProvider);
|
||||||
|
final result = await repository.downloadAttachment(arg, attachmentId);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
return result.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAttachment(String attachmentId) async {
|
||||||
|
final repository = ref.read(assetRepositoryProvider);
|
||||||
|
final result = await repository.deleteAttachment(arg, attachmentId);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
final current = state.valueOrNull;
|
||||||
|
if (current == null) {
|
||||||
|
await reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state = AsyncData(
|
||||||
|
current.copyWith(
|
||||||
|
attachments: current.attachments
|
||||||
|
.where((a) => a.id != attachmentId)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<AssetModel?> updateAsset(Map<String, dynamic> data) async {
|
Future<AssetModel?> updateAsset(Map<String, dynamic> data) async {
|
||||||
final repository = ref.read(assetRepositoryProvider);
|
final repository = ref.read(assetRepositoryProvider);
|
||||||
final result = await repository.updateAsset(arg, data);
|
final result = await repository.updateAsset(arg, data);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import '../../../../core/constants/enums.dart';
|
|||||||
import '../../../../core/constants/route_constants.dart';
|
import '../../../../core/constants/route_constants.dart';
|
||||||
import '../../../../core/errors/failure.dart';
|
import '../../../../core/errors/failure.dart';
|
||||||
import '../../../../shared/models/asset_model.dart';
|
import '../../../../shared/models/asset_model.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../../../shared/models/user_management_models.dart';
|
import '../../../../shared/models/user_management_models.dart';
|
||||||
import '../../../../shared/providers/permissions_provider.dart';
|
import '../../../../shared/providers/permissions_provider.dart';
|
||||||
import '../../../../shared/widgets/app_card.dart';
|
import '../../../../shared/widgets/app_card.dart';
|
||||||
@ -15,6 +16,7 @@ import '../../../../shared/widgets/app_empty_state.dart';
|
|||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_status_chip.dart';
|
import '../../../../shared/widgets/app_status_chip.dart';
|
||||||
import '../../../../shared/widgets/can_permission.dart';
|
import '../../../../shared/widgets/can_permission.dart';
|
||||||
|
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../../../../shared/widgets/page_header.dart';
|
import '../../../../shared/widgets/page_header.dart';
|
||||||
import '../../../../shared/widgets/app_side_panel.dart';
|
import '../../../../shared/widgets/app_side_panel.dart';
|
||||||
@ -115,7 +117,11 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
|||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
_OverviewTab(
|
_OverviewTab(
|
||||||
|
assetId: widget.assetId,
|
||||||
asset: state.asset,
|
asset: state.asset,
|
||||||
|
attachments: state.attachments,
|
||||||
|
canUpload: canEdit,
|
||||||
|
canDelete: canDelete,
|
||||||
onOpenTransferHistory: _openTransferHistoryPanel,
|
onOpenTransferHistory: _openTransferHistoryPanel,
|
||||||
),
|
),
|
||||||
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
|
_AmcTab(assetId: widget.assetId, contracts: state.amcContracts),
|
||||||
@ -170,17 +176,25 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OverviewTab extends StatelessWidget {
|
class _OverviewTab extends ConsumerWidget {
|
||||||
const _OverviewTab({
|
const _OverviewTab({
|
||||||
|
required this.assetId,
|
||||||
required this.asset,
|
required this.asset,
|
||||||
|
required this.attachments,
|
||||||
|
required this.canUpload,
|
||||||
|
required this.canDelete,
|
||||||
required this.onOpenTransferHistory,
|
required this.onOpenTransferHistory,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final String assetId;
|
||||||
final AssetModel asset;
|
final AssetModel asset;
|
||||||
|
final List<EntityAttachmentModel> attachments;
|
||||||
|
final bool canUpload;
|
||||||
|
final bool canDelete;
|
||||||
final VoidCallback onOpenTransferHistory;
|
final VoidCallback onOpenTransferHistory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final dateFormat = DateFormat('dd MMM yyyy');
|
final dateFormat = DateFormat('dd MMM yyyy');
|
||||||
|
|
||||||
@ -188,89 +202,124 @@ class _OverviewTab extends StatelessWidget {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: AppCard(
|
child: Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.all(24),
|
children: [
|
||||||
child: Column(
|
AppCard(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: Padding(
|
||||||
children: [
|
padding: const EdgeInsets.all(24),
|
||||||
Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
'Asset Details',
|
children: [
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
Text(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
'Asset Details',
|
||||||
fontWeight: FontWeight.w600,
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onOpenTransferHistory,
|
||||||
|
icon: const Icon(Icons.history, size: 18),
|
||||||
|
label: const Text('Transfer History'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_AssetInfoGrid(
|
||||||
|
items: [
|
||||||
|
_AssetInfo('Asset Name', asset.assetName),
|
||||||
|
_AssetInfo('Asset Code', asset.assetCode ?? '—'),
|
||||||
|
_AssetInfo('Category', asset.assetCategoryName ?? '—'),
|
||||||
|
_AssetInfo(
|
||||||
|
'Subcategory',
|
||||||
|
asset.assetSubcategoryName ?? '—',
|
||||||
|
),
|
||||||
|
_AssetInfo('Plant', asset.plantName ?? '—'),
|
||||||
|
_AssetInfo('Serial Number', asset.serialNumber ?? '—'),
|
||||||
|
_AssetInfo('Brand / Model', asset.brandModel ?? '—'),
|
||||||
|
_AssetInfo('Manufacturer', asset.manufacturer ?? '—'),
|
||||||
|
_AssetInfo(
|
||||||
|
'Purchase Date',
|
||||||
|
asset.purchaseDate != null
|
||||||
|
? dateFormat.format(asset.purchaseDate!)
|
||||||
|
: '—',
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Warranty Expiry',
|
||||||
|
asset.warrantyExpiryDate != null
|
||||||
|
? dateFormat.format(asset.warrantyExpiryDate!)
|
||||||
|
: '—',
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Purchase Cost',
|
||||||
|
asset.purchaseCost != null
|
||||||
|
? '₹${asset.purchaseCost}'
|
||||||
|
: '—',
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Useful Life',
|
||||||
|
asset.usefulLifeYears != null
|
||||||
|
? '${asset.usefulLifeYears} years'
|
||||||
|
: '—',
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Depreciation',
|
||||||
|
asset.depreciationMethod != null
|
||||||
|
? '${asset.depreciationMethod}'
|
||||||
|
'${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}'
|
||||||
|
: '—',
|
||||||
|
),
|
||||||
|
_AssetInfo(
|
||||||
|
'Condition',
|
||||||
|
assetConditionLabel(asset.condition),
|
||||||
|
),
|
||||||
|
_AssetInfo.widget(
|
||||||
|
'Status',
|
||||||
|
AppStatusChip(status: asset.status ?? 'IN_USE'),
|
||||||
|
),
|
||||||
|
_AssetInfo('Active', asset.isActive ? 'Yes' : 'No'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (asset.remarks?.trim().isNotEmpty == true) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Divider(height: 1),
|
||||||
),
|
),
|
||||||
),
|
_AssetInfoGrid(
|
||||||
const Spacer(),
|
items: [_AssetInfo('Remarks', asset.remarks!)],
|
||||||
TextButton.icon(
|
),
|
||||||
onPressed: onOpenTransferHistory,
|
],
|
||||||
icon: const Icon(Icons.history, size: 18),
|
|
||||||
label: const Text('Transfer History'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
_AssetInfoGrid(
|
|
||||||
items: [
|
|
||||||
_AssetInfo('Asset Name', asset.assetName),
|
|
||||||
_AssetInfo('Asset Code', asset.assetCode ?? '—'),
|
|
||||||
_AssetInfo('Category', asset.assetCategoryName ?? '—'),
|
|
||||||
_AssetInfo('Subcategory', asset.assetSubcategoryName ?? '—'),
|
|
||||||
_AssetInfo('Plant', asset.plantName ?? '—'),
|
|
||||||
_AssetInfo('Serial Number', asset.serialNumber ?? '—'),
|
|
||||||
_AssetInfo('Brand / Model', asset.brandModel ?? '—'),
|
|
||||||
_AssetInfo('Manufacturer', asset.manufacturer ?? '—'),
|
|
||||||
_AssetInfo(
|
|
||||||
'Purchase Date',
|
|
||||||
asset.purchaseDate != null
|
|
||||||
? dateFormat.format(asset.purchaseDate!)
|
|
||||||
: '—',
|
|
||||||
),
|
|
||||||
_AssetInfo(
|
|
||||||
'Warranty Expiry',
|
|
||||||
asset.warrantyExpiryDate != null
|
|
||||||
? dateFormat.format(asset.warrantyExpiryDate!)
|
|
||||||
: '—',
|
|
||||||
),
|
|
||||||
_AssetInfo(
|
|
||||||
'Purchase Cost',
|
|
||||||
asset.purchaseCost != null ? '₹${asset.purchaseCost}' : '—',
|
|
||||||
),
|
|
||||||
_AssetInfo(
|
|
||||||
'Useful Life',
|
|
||||||
asset.usefulLifeYears != null
|
|
||||||
? '${asset.usefulLifeYears} years'
|
|
||||||
: '—',
|
|
||||||
),
|
|
||||||
_AssetInfo(
|
|
||||||
'Depreciation',
|
|
||||||
asset.depreciationMethod != null
|
|
||||||
? '${asset.depreciationMethod}'
|
|
||||||
'${asset.depreciationRate != null ? ' (${asset.depreciationRate}%)' : ''}'
|
|
||||||
: '—',
|
|
||||||
),
|
|
||||||
_AssetInfo('Condition', assetConditionLabel(asset.condition)),
|
|
||||||
_AssetInfo.widget(
|
|
||||||
'Status',
|
|
||||||
AppStatusChip(status: asset.status ?? 'IN_USE'),
|
|
||||||
),
|
|
||||||
_AssetInfo('Active', asset.isActive ? 'Yes' : 'No'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (asset.remarks?.trim().isNotEmpty == true) ...[
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 20),
|
|
||||||
child: Divider(height: 1),
|
|
||||||
),
|
|
||||||
_AssetInfoGrid(
|
|
||||||
items: [_AssetInfo('Remarks', asset.remarks!)],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
EntityAttachmentsCard(
|
||||||
|
attachments: attachments,
|
||||||
|
canUpload: canUpload,
|
||||||
|
canDelete: canDelete,
|
||||||
|
subtitleWhenEditable:
|
||||||
|
'PDF, JPEG, PNG, or WebP · invoices, warranty cards, photos',
|
||||||
|
subtitleWhenReadonly: 'Supporting documents for this asset',
|
||||||
|
emptyUploadHint:
|
||||||
|
'No attachments yet. Upload invoices, warranty cards, or photos.',
|
||||||
|
onUpload: ({required bytes, required filename}) async {
|
||||||
|
await ref
|
||||||
|
.read(assetDetailProvider(assetId).notifier)
|
||||||
|
.uploadAttachment(bytes: bytes, filename: filename);
|
||||||
|
},
|
||||||
|
onDownload: (id) => ref
|
||||||
|
.read(assetDetailProvider(assetId).notifier)
|
||||||
|
.downloadAttachment(id),
|
||||||
|
onDelete: (id) => ref
|
||||||
|
.read(assetDetailProvider(assetId).notifier)
|
||||||
|
.deleteAttachment(id),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
|
|
||||||
import '../../../../core/constants/api_endpoints.dart';
|
import '../../../../core/constants/api_endpoints.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
|
|
||||||
class PurchaseOrderRemoteDataSource {
|
class PurchaseOrderRemoteDataSource {
|
||||||
@ -127,6 +128,51 @@ class PurchaseOrderRemoteDataSource {
|
|||||||
return response.data ?? [];
|
return response.data ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<EntityAttachmentModel>> listAttachments(String poId) async {
|
||||||
|
final response =
|
||||||
|
await dio.get(ApiEndpoints.purchaseOrderAttachments(poId));
|
||||||
|
final data = response.data['data'];
|
||||||
|
if (data is! List) return const [];
|
||||||
|
return data
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => EntityAttachmentModel.fromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<EntityAttachmentModel> uploadAttachment(
|
||||||
|
String poId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'file': MultipartFile.fromBytes(bytes, filename: filename),
|
||||||
|
});
|
||||||
|
final response = await dio.post(
|
||||||
|
ApiEndpoints.purchaseOrderAttachments(poId),
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
|
return EntityAttachmentModel.fromJson(
|
||||||
|
Map<String, dynamic>.from(response.data['data'] as Map),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> downloadAttachment(
|
||||||
|
String poId,
|
||||||
|
String attachmentId,
|
||||||
|
) async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
ApiEndpoints.purchaseOrderAttachmentDownload(poId, attachmentId),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
return response.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteAttachment(String poId, String attachmentId) async {
|
||||||
|
await dio.delete(
|
||||||
|
ApiEndpoints.purchaseOrderAttachmentById(poId, attachmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _queryToMap(PurchaseOrderListQuery query) {
|
Map<String, dynamic> _queryToMap(PurchaseOrderListQuery query) {
|
||||||
return {
|
return {
|
||||||
'page': query.page,
|
'page': query.page,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../core/network/dio_client.dart';
|
import '../../../../core/network/dio_client.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../domain/repositories/purchase_order_repository.dart';
|
import '../../domain/repositories/purchase_order_repository.dart';
|
||||||
import '../datasources/purchase_order_remote_data_source.dart';
|
import '../datasources/purchase_order_remote_data_source.dart';
|
||||||
@ -99,4 +100,41 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
|||||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
|
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id) {
|
||||||
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));
|
return safeApiCall(() => dataSource.downloadPurchaseOrderPdf(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId) {
|
||||||
|
return safeApiCall(() => dataSource.listAttachments(poId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||||
|
String poId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.uploadAttachment(
|
||||||
|
poId,
|
||||||
|
bytes: bytes,
|
||||||
|
filename: filename,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<List<int>>> downloadAttachment(
|
||||||
|
String poId,
|
||||||
|
String attachmentId,
|
||||||
|
) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.downloadAttachment(poId, attachmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Result<void>> deleteAttachment(String poId, String attachmentId) {
|
||||||
|
return safeApiCall(
|
||||||
|
() => dataSource.deleteAttachment(poId, attachmentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import '../../../../core/network/api_handler.dart';
|
import '../../../../core/network/api_handler.dart';
|
||||||
import '../../../../shared/models/api_response.dart';
|
import '../../../../shared/models/api_response.dart';
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
|
|
||||||
abstract class PurchaseOrderRepository {
|
abstract class PurchaseOrderRepository {
|
||||||
@ -25,4 +26,15 @@ abstract class PurchaseOrderRepository {
|
|||||||
});
|
});
|
||||||
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
|
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
|
||||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
|
Future<Result<List<int>>> downloadPurchaseOrderPdf(String id);
|
||||||
|
Future<Result<List<EntityAttachmentModel>>> listAttachments(String poId);
|
||||||
|
Future<Result<EntityAttachmentModel>> uploadAttachment(
|
||||||
|
String poId, {
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
});
|
||||||
|
Future<Result<List<int>>> downloadAttachment(
|
||||||
|
String poId,
|
||||||
|
String attachmentId,
|
||||||
|
);
|
||||||
|
Future<Result<void>> deleteAttachment(String poId, String attachmentId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../../../core/utils/table_search.dart';
|
import '../../../../core/utils/table_search.dart';
|
||||||
|
|
||||||
|
import '../../../../shared/models/entity_attachment_model.dart';
|
||||||
import '../../../../shared/models/purchase_order_model.dart';
|
import '../../../../shared/models/purchase_order_model.dart';
|
||||||
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
import '../../../grn/presentation/providers/grn_lookups_provider.dart';
|
||||||
import '../../data/repositories/purchase_order_repository_impl.dart';
|
import '../../data/repositories/purchase_order_repository_impl.dart';
|
||||||
@ -221,6 +222,55 @@ class PurchaseOrderDetailNotifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final purchaseOrderAttachmentsProvider = AsyncNotifierProvider.family<
|
||||||
|
PurchaseOrderAttachmentsNotifier, List<EntityAttachmentModel>, String>(
|
||||||
|
PurchaseOrderAttachmentsNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
|
class PurchaseOrderAttachmentsNotifier
|
||||||
|
extends FamilyAsyncNotifier<List<EntityAttachmentModel>, String> {
|
||||||
|
@override
|
||||||
|
Future<List<EntityAttachmentModel>> build(String arg) async {
|
||||||
|
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||||
|
final result = await repository.listAttachments(arg);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
return result.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<EntityAttachmentModel> upload({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) async {
|
||||||
|
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||||
|
final result = await repository.uploadAttachment(
|
||||||
|
arg,
|
||||||
|
bytes: bytes,
|
||||||
|
filename: filename,
|
||||||
|
);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
state = AsyncData([...current, result.data!]);
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<int>> download(String attachmentId) async {
|
||||||
|
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||||
|
final result = await repository.downloadAttachment(arg, attachmentId);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
return result.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete(String attachmentId) async {
|
||||||
|
final repository = ref.read(purchaseOrderRepositoryProvider);
|
||||||
|
final result = await repository.deleteAttachment(arg, attachmentId);
|
||||||
|
if (result.failure != null) throw result.failure!;
|
||||||
|
final current = state.valueOrNull ?? [];
|
||||||
|
state = AsyncData(
|
||||||
|
current.where((a) => a.id != attachmentId).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final purchaseOrderFormProvider = AsyncNotifierProvider.family<
|
final purchaseOrderFormProvider = AsyncNotifierProvider.family<
|
||||||
PurchaseOrderFormNotifier, PurchaseOrderModel?, String?>(
|
PurchaseOrderFormNotifier, PurchaseOrderModel?, String?>(
|
||||||
PurchaseOrderFormNotifier.new,
|
PurchaseOrderFormNotifier.new,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
|||||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||||
import '../../../../shared/widgets/app_loading_view.dart';
|
import '../../../../shared/widgets/app_loading_view.dart';
|
||||||
import '../../../../shared/widgets/app_text_field.dart';
|
import '../../../../shared/widgets/app_text_field.dart';
|
||||||
|
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||||
import '../../../../shared/widgets/error_view.dart';
|
import '../../../../shared/widgets/error_view.dart';
|
||||||
import '../providers/purchase_order_lookups_provider.dart';
|
import '../providers/purchase_order_lookups_provider.dart';
|
||||||
import '../providers/purchase_orders_provider.dart';
|
import '../providers/purchase_orders_provider.dart';
|
||||||
@ -118,6 +119,12 @@ class _PurchaseOrderDetailScreenState
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_PoAttachmentsSection(
|
||||||
|
poId: order.id,
|
||||||
|
canUpload: canEdit,
|
||||||
|
canDelete: canDelete,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_DetailFooter(order: order),
|
_DetailFooter(order: order),
|
||||||
],
|
],
|
||||||
@ -793,6 +800,71 @@ class _DetailField extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PoAttachmentsSection extends ConsumerWidget {
|
||||||
|
const _PoAttachmentsSection({
|
||||||
|
required this.poId,
|
||||||
|
required this.canUpload,
|
||||||
|
required this.canDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String poId;
|
||||||
|
final bool canUpload;
|
||||||
|
final bool canDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final attachmentsAsync = ref.watch(purchaseOrderAttachmentsProvider(poId));
|
||||||
|
|
||||||
|
return attachmentsAsync.when(
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
height: 88,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
error: (e, _) => EntityAttachmentsCard(
|
||||||
|
attachments: const [],
|
||||||
|
canUpload: canUpload,
|
||||||
|
canDelete: canDelete,
|
||||||
|
subtitleWhenEditable: 'PDF, JPEG, PNG, or WebP',
|
||||||
|
subtitleWhenReadonly: 'Supporting documents for this purchase order',
|
||||||
|
emptyReadonlyHint: e is Failure
|
||||||
|
? e.message
|
||||||
|
: 'Failed to load attachments',
|
||||||
|
onUpload: ({required bytes, required filename}) async {
|
||||||
|
await ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.upload(bytes: bytes, filename: filename);
|
||||||
|
},
|
||||||
|
onDownload: (id) => ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.download(id),
|
||||||
|
onDelete: (id) => ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.delete(id),
|
||||||
|
),
|
||||||
|
data: (attachments) => EntityAttachmentsCard(
|
||||||
|
attachments: attachments,
|
||||||
|
canUpload: canUpload,
|
||||||
|
canDelete: canDelete,
|
||||||
|
subtitleWhenEditable: 'PDF, JPEG, PNG, or WebP',
|
||||||
|
subtitleWhenReadonly: 'Supporting documents for this purchase order',
|
||||||
|
emptyUploadHint:
|
||||||
|
'No attachments yet. Upload quotes, approvals, or related files.',
|
||||||
|
onUpload: ({required bytes, required filename}) async {
|
||||||
|
await ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.upload(bytes: bytes, filename: filename);
|
||||||
|
},
|
||||||
|
onDownload: (id) => ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.download(id),
|
||||||
|
onDelete: (id) => ref
|
||||||
|
.read(purchaseOrderAttachmentsProvider(poId).notifier)
|
||||||
|
.delete(id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _LineItemsCard extends StatelessWidget {
|
class _LineItemsCard extends StatelessWidget {
|
||||||
const _LineItemsCard({
|
const _LineItemsCard({
|
||||||
required this.order,
|
required this.order,
|
||||||
|
|||||||
87
lib/shared/models/entity_attachment_model.dart
Normal file
87
lib/shared/models/entity_attachment_model.dart
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
/// Shared attachment metadata for GRN / Asset / Purchase Order files.
|
||||||
|
class EntityAttachmentModel {
|
||||||
|
const EntityAttachmentModel({
|
||||||
|
required this.id,
|
||||||
|
this.parentId,
|
||||||
|
this.fileName,
|
||||||
|
this.fileType,
|
||||||
|
this.fileSize,
|
||||||
|
this.uploadedByName,
|
||||||
|
this.attachmentType,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String? parentId;
|
||||||
|
final String? fileName;
|
||||||
|
final String? fileType;
|
||||||
|
final int? fileSize;
|
||||||
|
final String? uploadedByName;
|
||||||
|
final String? attachmentType;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
factory EntityAttachmentModel.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EntityAttachmentModel(
|
||||||
|
id: _idFromJson(json['id']),
|
||||||
|
parentId: _idFromJsonNullable(
|
||||||
|
json['asset_id'] ?? json['po_id'] ?? json['grn_id'],
|
||||||
|
),
|
||||||
|
fileName: json['file_name'] as String?,
|
||||||
|
fileType: json['file_type'] as String?,
|
||||||
|
fileSize: _intFromJsonNullable(json['file_size']),
|
||||||
|
uploadedByName: _readUploadedByName(json),
|
||||||
|
attachmentType: json['attachment_type'] as String?,
|
||||||
|
createdAt: _dateFromJsonNullable(json['created_at']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isPdf =>
|
||||||
|
(fileType ?? '').toLowerCase().contains('pdf') ||
|
||||||
|
(fileName ?? '').toLowerCase().endsWith('.pdf');
|
||||||
|
|
||||||
|
bool get isImage {
|
||||||
|
final type = (fileType ?? '').toLowerCase();
|
||||||
|
final name = (fileName ?? '').toLowerCase();
|
||||||
|
return type.startsWith('image/') ||
|
||||||
|
name.endsWith('.jpg') ||
|
||||||
|
name.endsWith('.jpeg') ||
|
||||||
|
name.endsWith('.png') ||
|
||||||
|
name.endsWith('.webp');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _idFromJson(Object? value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _idFromJsonNullable(Object? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
final s = value.toString().trim();
|
||||||
|
return s.isEmpty ? null : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _intFromJsonNullable(Object? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is int) return value;
|
||||||
|
return int.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _dateFromJsonNullable(Object? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
return DateTime.tryParse(value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readUploadedByName(Map<String, dynamic> json) {
|
||||||
|
final flat = json['uploaded_by_name'];
|
||||||
|
if (flat is String && flat.trim().isNotEmpty) return flat;
|
||||||
|
final nested = json['uploaded_by_user'] ?? json['uploaded_by'];
|
||||||
|
if (nested is Map) {
|
||||||
|
final name = nested['full_name'] ?? nested['name'];
|
||||||
|
if (name != null && name.toString().trim().isNotEmpty) {
|
||||||
|
return name.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
377
lib/shared/widgets/entity_attachments_card.dart
Normal file
377
lib/shared/widgets/entity_attachments_card.dart
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/errors/failure.dart';
|
||||||
|
import '../../core/network/api_handler.dart';
|
||||||
|
import '../../core/utils/formatters.dart';
|
||||||
|
import '../models/entity_attachment_model.dart';
|
||||||
|
import '../utils/file_download_helper.dart';
|
||||||
|
import 'app_confirmation_dialog.dart';
|
||||||
|
|
||||||
|
const kEntityAttachmentExtensions = ['pdf', 'jpg', 'jpeg', 'png', 'webp'];
|
||||||
|
|
||||||
|
String _formatFileSize(int? bytes) {
|
||||||
|
if (bytes == null || bytes <= 0) return '—';
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _fileIcon(EntityAttachmentModel attachment) {
|
||||||
|
if (attachment.isPdf) return Icons.picture_as_pdf_outlined;
|
||||||
|
if (attachment.isImage) return Icons.image_outlined;
|
||||||
|
return Icons.insert_drive_file_outlined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared attachments card — list / upload / download / delete.
|
||||||
|
class EntityAttachmentsCard extends StatefulWidget {
|
||||||
|
const EntityAttachmentsCard({
|
||||||
|
super.key,
|
||||||
|
required this.attachments,
|
||||||
|
required this.canUpload,
|
||||||
|
required this.canDelete,
|
||||||
|
required this.onUpload,
|
||||||
|
required this.onDownload,
|
||||||
|
required this.onDelete,
|
||||||
|
this.subtitleWhenEditable =
|
||||||
|
'PDF, JPEG, PNG, or WebP',
|
||||||
|
this.subtitleWhenReadonly = 'Supporting documents',
|
||||||
|
this.emptyUploadHint = 'No attachments yet. Upload a file to get started.',
|
||||||
|
this.emptyReadonlyHint = 'No attachments',
|
||||||
|
this.readonlyFooter,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<EntityAttachmentModel> attachments;
|
||||||
|
final bool canUpload;
|
||||||
|
final bool canDelete;
|
||||||
|
final Future<void> Function({
|
||||||
|
required List<int> bytes,
|
||||||
|
required String filename,
|
||||||
|
}) onUpload;
|
||||||
|
final Future<List<int>> Function(String attachmentId) onDownload;
|
||||||
|
final Future<void> Function(String attachmentId) onDelete;
|
||||||
|
final String subtitleWhenEditable;
|
||||||
|
final String subtitleWhenReadonly;
|
||||||
|
final String emptyUploadHint;
|
||||||
|
final String emptyReadonlyHint;
|
||||||
|
final String? readonlyFooter;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EntityAttachmentsCard> createState() => _EntityAttachmentsCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntityAttachmentsCardState extends State<EntityAttachmentsCard> {
|
||||||
|
bool _isUploading = false;
|
||||||
|
String? _busyAttachmentId;
|
||||||
|
|
||||||
|
Future<void> _upload() async {
|
||||||
|
if (!widget.canUpload) return;
|
||||||
|
|
||||||
|
final result = await FilePicker.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: kEntityAttachmentExtensions,
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
|
||||||
|
final file = result.files.single;
|
||||||
|
final bytes = file.bytes;
|
||||||
|
if (bytes == null || bytes.isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Could not read the selected file')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ext = (file.extension ?? '').toLowerCase();
|
||||||
|
if (!kEntityAttachmentExtensions.contains(ext)) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Only PDF, JPEG, PNG, and WebP files are allowed'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isUploading = true);
|
||||||
|
try {
|
||||||
|
await widget.onUpload(bytes: bytes, filename: file.name);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${file.name} uploaded')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final message =
|
||||||
|
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isUploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _download(EntityAttachmentModel attachment) async {
|
||||||
|
setState(() => _busyAttachmentId = attachment.id);
|
||||||
|
try {
|
||||||
|
final bytes = await widget.onDownload(attachment.id);
|
||||||
|
if (bytes.isEmpty) throw Exception('Empty file response');
|
||||||
|
await downloadFile(
|
||||||
|
bytes: bytes,
|
||||||
|
fileName: attachment.fileName ?? 'attachment-${attachment.id}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busyAttachmentId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(EntityAttachmentModel attachment) async {
|
||||||
|
if (!widget.canDelete) return;
|
||||||
|
|
||||||
|
final confirmed = await showAppConfirmationDialog(
|
||||||
|
context: context,
|
||||||
|
title: 'Delete Attachment',
|
||||||
|
message:
|
||||||
|
'Delete ${attachment.fileName ?? 'this file'}? This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
isDestructive: true,
|
||||||
|
);
|
||||||
|
if (confirmed != true || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _busyAttachmentId = attachment.id);
|
||||||
|
try {
|
||||||
|
await widget.onDelete(attachment.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Attachment deleted')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final message =
|
||||||
|
e is Failure ? validationErrorMessage(e) : e.toString();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busyAttachmentId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final attachments = widget.attachments;
|
||||||
|
final showUpload = widget.canUpload;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'ATTACHMENTS · ${attachments.length}',
|
||||||
|
style: theme.textTheme.labelMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (showUpload)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isUploading ? null : _upload,
|
||||||
|
icon: _isUploading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.upload_file_outlined, size: 18),
|
||||||
|
label: Text(_isUploading ? 'Uploading…' : 'Upload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
showUpload
|
||||||
|
? widget.subtitleWhenEditable
|
||||||
|
: widget.subtitleWhenReadonly,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (attachments.isEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outline.withValues(alpha: 0.15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
showUpload
|
||||||
|
? widget.emptyUploadHint
|
||||||
|
: widget.emptyReadonlyHint,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...attachments.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final attachment = entry.value;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index == attachments.length - 1 ? 0 : 8,
|
||||||
|
),
|
||||||
|
child: _AttachmentRow(
|
||||||
|
attachment: attachment,
|
||||||
|
isBusy: _busyAttachmentId == attachment.id,
|
||||||
|
canDelete: widget.canDelete,
|
||||||
|
onDownload: () => _download(attachment),
|
||||||
|
onDelete: () => _delete(attachment),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
if (widget.readonlyFooter != null &&
|
||||||
|
!showUpload &&
|
||||||
|
attachments.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.readonlyFooter!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AttachmentRow extends StatelessWidget {
|
||||||
|
const _AttachmentRow({
|
||||||
|
required this.attachment,
|
||||||
|
required this.isBusy,
|
||||||
|
required this.canDelete,
|
||||||
|
required this.onDownload,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EntityAttachmentModel attachment;
|
||||||
|
final bool isBusy;
|
||||||
|
final bool canDelete;
|
||||||
|
final VoidCallback onDownload;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final borderColor = theme.colorScheme.outline.withValues(alpha: 0.15);
|
||||||
|
final metaParts = <String>[
|
||||||
|
_formatFileSize(attachment.fileSize),
|
||||||
|
if (attachment.uploadedByName?.trim().isNotEmpty == true)
|
||||||
|
attachment.uploadedByName!.trim(),
|
||||||
|
if (attachment.createdAt != null)
|
||||||
|
DateFormatter.displayDateTime(attachment.createdAt),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_fileIcon(attachment),
|
||||||
|
size: 22,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
attachment.fileName ?? 'Attachment #${attachment.id}',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (metaParts.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
metaParts.join(' · '),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isBusy)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Download',
|
||||||
|
onPressed: onDownload,
|
||||||
|
icon: const Icon(Icons.download_outlined, size: 20),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
if (canDelete)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: onDelete,
|
||||||
|
icon: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
size: 20,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user