attachment and bug fix

This commit is contained in:
Surendiran 2026-07-14 17:48:42 +05:30
parent 5a572be3c6
commit a4634a9ba2
14 changed files with 1098 additions and 89 deletions

View File

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

View 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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}

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