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 purchaseOrderCancel(String id) => '/purchase-orders/$id/cancel';
|
||||
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
|
||||
static const String grn = '/grn';
|
||||
@ -114,6 +123,12 @@ class ApiEndpoints {
|
||||
static String assetById(String id) => '/assets/$id';
|
||||
static String assetTransfer(String id) => '/assets/$id/transfer';
|
||||
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 assetContractTypes = '/assets/contract-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 '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
|
||||
class AssetRemoteDataSource {
|
||||
AssetRemoteDataSource({required this.dio});
|
||||
@ -40,6 +41,50 @@ class AssetRemoteDataSource {
|
||||
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 {
|
||||
await dio.post(ApiEndpoints.assetTransfer(id), data: data);
|
||||
}
|
||||
@ -324,7 +369,7 @@ class AssetRemoteDataSource {
|
||||
dynamic body,
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
if (body is! Map<String, dynamic>) {
|
||||
if (body is! Map) {
|
||||
return const PaginatedResponse(
|
||||
items: [],
|
||||
page: 1,
|
||||
@ -334,22 +379,66 @@ class AssetRemoteDataSource {
|
||||
);
|
||||
}
|
||||
|
||||
final raw = body['data'];
|
||||
final meta = body['meta'] as Map<String, dynamic>? ?? {};
|
||||
final map = Map<String, dynamic>.from(body);
|
||||
final raw = map['data'];
|
||||
final meta = map['meta'] is Map
|
||||
? Map<String, dynamic>.from(map['meta'] as Map)
|
||||
: <String, dynamic>{};
|
||||
|
||||
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(
|
||||
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,
|
||||
limit: limit,
|
||||
total: total,
|
||||
totalPages: explicitTotalPages ??
|
||||
(limit > 0
|
||||
? ((total + limit - 1) ~/ limit).clamp(1, 999999)
|
||||
: 1),
|
||||
);
|
||||
}
|
||||
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return PaginatedResponse.fromJson(raw, (json) => fromJson(json! as Map<String, dynamic>));
|
||||
if (raw is Map) {
|
||||
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(
|
||||
|
||||
@ -4,6 +4,7 @@ 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 '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../domain/repositories/asset_repository.dart';
|
||||
import '../datasources/asset_remote_data_source.dart';
|
||||
|
||||
@ -252,4 +253,43 @@ class AssetRepositoryImpl implements AssetRepository {
|
||||
) {
|
||||
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 '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
|
||||
abstract class AssetRepository {
|
||||
Future<Result<PaginatedResponse<AssetModel>>> getAssets(PaginationParams params);
|
||||
@ -84,4 +85,16 @@ abstract class AssetRepository {
|
||||
String policyId,
|
||||
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/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../data/repositories/asset_repository_impl.dart';
|
||||
|
||||
class AssetsListState {
|
||||
@ -141,24 +142,28 @@ class AssetDetailState {
|
||||
this.amcContracts = const [],
|
||||
this.serviceVisits = const [],
|
||||
this.insurancePolicies = const [],
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
final AssetModel asset;
|
||||
final List<AmcContractModel> amcContracts;
|
||||
final List<ServiceVisitModel> serviceVisits;
|
||||
final List<InsurancePolicyModel> insurancePolicies;
|
||||
final List<EntityAttachmentModel> attachments;
|
||||
|
||||
AssetDetailState copyWith({
|
||||
AssetModel? asset,
|
||||
List<AmcContractModel>? amcContracts,
|
||||
List<ServiceVisitModel>? serviceVisits,
|
||||
List<InsurancePolicyModel>? insurancePolicies,
|
||||
List<EntityAttachmentModel>? attachments,
|
||||
}) {
|
||||
return AssetDetailState(
|
||||
asset: asset ?? this.asset,
|
||||
amcContracts: amcContracts ?? this.amcContracts,
|
||||
serviceVisits: serviceVisits ?? this.serviceVisits,
|
||||
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 visitsResult = await repository.getServiceVisits(assetId);
|
||||
final insuranceResult = await repository.getInsurancePolicies(assetId);
|
||||
final attachmentsResult = await repository.listAttachments(assetId);
|
||||
|
||||
return AssetDetailState(
|
||||
asset: assetResult.data!,
|
||||
amcContracts: amcResult.data ?? [],
|
||||
serviceVisits: visitsResult.data ?? [],
|
||||
insurancePolicies: insuranceResult.data ?? [],
|
||||
attachments: attachmentsResult.data ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
@ -191,6 +198,57 @@ class AssetDetailNotifier extends FamilyAsyncNotifier<AssetDetailState, String>
|
||||
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 {
|
||||
final repository = ref.read(assetRepositoryProvider);
|
||||
final result = await repository.updateAsset(arg, data);
|
||||
|
||||
@ -7,6 +7,7 @@ import '../../../../core/constants/enums.dart';
|
||||
import '../../../../core/constants/route_constants.dart';
|
||||
import '../../../../core/errors/failure.dart';
|
||||
import '../../../../shared/models/asset_model.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/user_management_models.dart';
|
||||
import '../../../../shared/providers/permissions_provider.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_status_chip.dart';
|
||||
import '../../../../shared/widgets/can_permission.dart';
|
||||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/app_side_panel.dart';
|
||||
@ -115,7 +117,11 @@ class _AssetDetailScreenState extends ConsumerState<AssetDetailScreen>
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_OverviewTab(
|
||||
assetId: widget.assetId,
|
||||
asset: state.asset,
|
||||
attachments: state.attachments,
|
||||
canUpload: canEdit,
|
||||
canDelete: canDelete,
|
||||
onOpenTransferHistory: _openTransferHistoryPanel,
|
||||
),
|
||||
_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({
|
||||
required this.assetId,
|
||||
required this.asset,
|
||||
required this.attachments,
|
||||
required this.canUpload,
|
||||
required this.canDelete,
|
||||
required this.onOpenTransferHistory,
|
||||
});
|
||||
|
||||
final String assetId;
|
||||
final AssetModel asset;
|
||||
final List<EntityAttachmentModel> attachments;
|
||||
final bool canUpload;
|
||||
final bool canDelete;
|
||||
final VoidCallback onOpenTransferHistory;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final dateFormat = DateFormat('dd MMM yyyy');
|
||||
|
||||
@ -188,89 +202,124 @@ class _OverviewTab extends StatelessWidget {
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 1200),
|
||||
child: AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AppCard(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Asset Details',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Asset Details',
|
||||
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),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: onOpenTransferHistory,
|
||||
icon: const Icon(Icons.history, size: 18),
|
||||
label: const Text('Transfer History'),
|
||||
),
|
||||
_AssetInfoGrid(
|
||||
items: [_AssetInfo('Remarks', asset.remarks!)],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
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 '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
|
||||
class PurchaseOrderRemoteDataSource {
|
||||
@ -127,6 +128,51 @@ class PurchaseOrderRemoteDataSource {
|
||||
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) {
|
||||
return {
|
||||
'page': query.page,
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../../core/network/api_handler.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../domain/repositories/purchase_order_repository.dart';
|
||||
import '../datasources/purchase_order_remote_data_source.dart';
|
||||
@ -99,4 +100,41 @@ class PurchaseOrderRepositoryImpl implements PurchaseOrderRepository {
|
||||
Future<Result<List<int>>> downloadPurchaseOrderPdf(String 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 '../../../../shared/models/api_response.dart';
|
||||
import '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
|
||||
abstract class PurchaseOrderRepository {
|
||||
@ -25,4 +26,15 @@ abstract class PurchaseOrderRepository {
|
||||
});
|
||||
Future<Result<PurchaseOrderModel>> cancelPurchaseOrder(String id, {String? remarks});
|
||||
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 '../../../../shared/models/entity_attachment_model.dart';
|
||||
import '../../../../shared/models/purchase_order_model.dart';
|
||||
import '../../../grn/presentation/providers/grn_lookups_provider.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<
|
||||
PurchaseOrderFormNotifier, PurchaseOrderModel?, String?>(
|
||||
PurchaseOrderFormNotifier.new,
|
||||
|
||||
@ -14,6 +14,7 @@ import '../../../../shared/utils/file_download_helper.dart';
|
||||
import '../../../../shared/widgets/app_confirmation_dialog.dart';
|
||||
import '../../../../shared/widgets/app_loading_view.dart';
|
||||
import '../../../../shared/widgets/app_text_field.dart';
|
||||
import '../../../../shared/widgets/entity_attachments_card.dart';
|
||||
import '../../../../shared/widgets/error_view.dart';
|
||||
import '../providers/purchase_order_lookups_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),
|
||||
_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 {
|
||||
const _LineItemsCard({
|
||||
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