diff --git a/lib/controllers/api_routes/api_manager.dart b/lib/controllers/api_routes/api_manager.dart index 4d85989a..89e4b3e0 100644 --- a/lib/controllers/api_routes/api_manager.dart +++ b/lib/controllers/api_routes/api_manager.dart @@ -18,9 +18,9 @@ import 'package:test_sa/new_views/pages/login_page.dart'; class _CachedResponse { final http.Response response; final DateTime timestamp; - + _CachedResponse(this.response, this.timestamp); - + bool isValid(Duration cacheDuration) { return DateTime.now().difference(timestamp) < cacheDuration; } @@ -40,26 +40,26 @@ class ApiManager { User? user; AssetGroup? assetGroup; - + // OPTIMIZATION: HTTP Connection Pooling - Reuse connections instead of creating new ones // This improves performance by 100-200ms on subsequent API calls static final http.Client _httpClient = http.Client(); - + // OPTIMIZATION: Response Caching - Cache lookup/static data to reduce network calls // This reduces API calls by 60% for frequently accessed but rarely changing data static final Map _cache = {}; static const Duration _defaultCacheDuration = Duration(hours: 1); - + /// Clear all cached responses (call on logout or when needed) void clearCache() { _cache.clear(); } - + /// Clear specific cache entry void clearCacheEntry(String url) { _cache.remove(_generateCacheKey(url)); } - + /// Generate cache key from URL and user context String _generateCacheKey(String url) { // Include user and asset group in cache key to avoid cross-user pollution @@ -67,7 +67,7 @@ class ApiManager { } /// GET request with optional caching support - /// + /// /// [url] - API endpoint URL /// [headers] - Optional additional headers /// [enableToastMessage] - Show toast message from API response @@ -85,12 +85,12 @@ class ApiManager { headers ??= {}; headers.addAll(_headers); Uri url0 = Uri.parse(url); - + // OPTIMIZATION: Check cache first if enabled if (useCache && !forceRefresh) { final cacheKey = _generateCacheKey(url); final cachedResponse = _cache[cacheKey]; - + if (cachedResponse != null && cachedResponse.isValid(cacheDuration ?? _defaultCacheDuration)) { if (kDebugMode) { print('📦 Cache HIT: $url'); @@ -98,10 +98,10 @@ class ApiManager { return cachedResponse.response; } } - + // OPTIMIZATION: Use persistent HTTP client for connection pooling http.Response response = await _httpClient.get(url0, headers: headers); - + // OPTIMIZATION: Cache the response if caching is enabled if (useCache && response.statusCode >= 200 && response.statusCode < 300) { final cacheKey = _generateCacheKey(url); @@ -248,7 +248,7 @@ class ApiManager { Uri url0 = Uri.parse(url); // print(headers); // log(json.encode(body)); - + // OPTIMIZATION: Use persistent HTTP client for connection pooling var request = http.Request('PUT', url0); request.body = json.encode(body); @@ -275,10 +275,9 @@ class ApiManager { } Future multiPart(String url, {Map? headers, required Map body, required List> files}) async { - Map headers0 = const { - 'Content-Type': 'multipart/form-data', - }; + Map headers0 = _headers; + headers0["Content-Type"] = 'multipart/form-data'; headers ??= {}; headers.addAll(headers0); @@ -360,7 +359,7 @@ class ApiManager { Navigator.pop(context); Navigator.of(context).pushNamedAndRemoveUntil(LoginPage.routeName, (routes) => true); } - + /// Dispose HTTP client (call when app is closing if needed) /// Note: In most cases, keep client alive for app lifetime for best performance void dispose() { diff --git a/lib/controllers/api_routes/urls.dart b/lib/controllers/api_routes/urls.dart index 43998ee8..e0820b57 100644 --- a/lib/controllers/api_routes/urls.dart +++ b/lib/controllers/api_routes/urls.dart @@ -3,13 +3,13 @@ class URLs { static const String appReleaseBuildNumber = "44"; - static const host1 = "https://atomsm.hmg.com"; // production url + // static const host1 = "https://atomsm.hmg.com"; // production url // static const host1 = "https://atomsmdev.hmg.com"; // local DEV url - // static const host1 = "https://atomsmuat.hmg.com"; // local UAT url + static const host1 = "https://atomsmuat.hmg.com"; // local UAT url - // static final String _baseUrl = "$_host/mobile"; // host local UAT + static final String _baseUrl = "$_host/mobile"; // host local UAT // static final String _baseUrl = "$_host/v2/mobile"; // new V2 apis - static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM + // static final String _baseUrl = "$_host/v3/mobile"; // v3 for production CM,PM,TM // static final String _baseUrl = "$_host/v4/mobile"; // v4 for Demo module // static final String _baseUrl = "$_host/v5/mobile"; // v5 for data segregation // static final String _baseUrl = "$_host/v6/mobile"; // for asset delivery module @@ -65,6 +65,7 @@ class URLs { static get getAssets => "$_baseUrl/Asset/GetAssets"; // get static get getFaultDescription => "$_baseUrl/Asset/GetDefectForModelDefinition"; // get static get getAssetById => "$_baseUrl/Asset/GetAssetById?assetId="; // get + static get saveAssetImages => "$_baseUrl/Asset/SaveAssetImages"; // get static get getModels => "$_baseUrl/ModelDefinition/GetModelDefinitionAsset"; // get ?client=2 static get getAllRequestsAndCount => "$_baseUrl/CallRequest/GetAllRequestsAndCount"; // get static get updateUserContactInformation => "$_baseUrl/Account/UpdateContactInfo"; // get diff --git a/lib/controllers/providers/api/devices_provider.dart b/lib/controllers/providers/api/devices_provider.dart index 218ebf80..f933a5a9 100644 --- a/lib/controllers/providers/api/devices_provider.dart +++ b/lib/controllers/providers/api/devices_provider.dart @@ -9,6 +9,7 @@ import 'package:test_sa/controllers/api_routes/http_status_manger.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import 'package:test_sa/models/device/asset_by_id_model.dart'; import 'package:test_sa/models/device/asset_search.dart'; +import 'package:test_sa/models/generic_attachment_model.dart'; import 'package:test_sa/models/lookup.dart'; import 'package:test_sa/models/user.dart'; import 'package:test_sa/modules/asset_delivery_module/models/technical_inpection_asset_model.dart'; @@ -230,4 +231,22 @@ class AssetProvider extends ChangeNotifier { return []; } } + + Future uploadAssetImages(int assetId, List attachments) async { + late Response response; + try { + response = + await ApiManager.instance.multiPart(URLs.saveAssetImages + "?AssetId=$assetId", body: {}, files: attachments.map((attachment) => MultipartFile.fromPath("Files", attachment.name!)).toList()); + if (response.statusCode >= 200 && response.statusCode < 300) { + // client's request was successfully received + // Map assetData = json.decode(response.body)["data"]; + // _assetById = AssetByIdModel.fromJson(assetData); + // return _assetById!; + return true; + } + return false; + } catch (error) { + return false; + } + } } diff --git a/lib/views/widgets/equipment/asset_detail_page.dart b/lib/views/widgets/equipment/asset_detail_page.dart index f0b095fb..5beea1c5 100644 --- a/lib/views/widgets/equipment/asset_detail_page.dart +++ b/lib/views/widgets/equipment/asset_detail_page.dart @@ -8,7 +8,13 @@ import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/models/device/asset_by_id_model.dart'; +import 'package:test_sa/models/generic_attachment_model.dart'; +import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart'; +import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; +import 'package:test_sa/views/widgets/equipment/asset_image_upload_page.dart'; +import 'package:test_sa/views/widgets/images/files_list.dart'; +import 'package:test_sa/views/widgets/images/multi_image_picker.dart'; import 'package:test_sa/views/widgets/loaders/app_loading.dart'; import 'package:test_sa/views/widgets/loaders/failed_loading.dart'; @@ -39,6 +45,9 @@ class _AssetDetailPageState extends State { int? assetId; AssetProvider? _assetProvider; + AssetByIdModel? assetModel; + + List attachments = []; @override Widget build(BuildContext context) { @@ -48,7 +57,7 @@ class _AssetDetailPageState extends State { return Scaffold( appBar: DefaultAppBar(title: context.translation.assetDetails), body: FutureBuilder( - future: _assetProvider!.getAssetById(assetId!, context.translation), + future: assetModel == null ? _assetProvider!.getAssetById(assetId!, context.translation) : null, builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasError) { return FailedLoading( @@ -62,87 +71,108 @@ class _AssetDetailPageState extends State { return const Center(child: ALoading()); } else if (snapshot.hasData) {} if (snapshot.hasData) { - AssetByIdModel assetModel = snapshot.data!; - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 159 / 94, - child: Container( - width: 95, - height: 95, - decoration: ShapeDecoration( - color: AppColor.neutral30, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - image: DecorationImage( - fit: BoxFit.cover, - image: NetworkImage(assetModel.assetPhoto != null ? URLs.getFileUrl(assetModel.assetPhoto!)! : "https://www.lasteelcraft.com/images/no-image-available.png"), - )), - ), - ), - 6.height, + assetModel = snapshot.data!; + attachments = assetModel?.assetAttachments + ?.map((attachment) => GenericAttachmentModel( + id: attachment.id, + name: attachment.attachmentName, + originalName: attachment.originalName, + )) + .toList() ?? + []; + return Column( + children: [ + ListView(padding: const EdgeInsets.all(16), children: [ Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (assetModel.commissioningStatus != null) - StatusLabel( - label: assetModel.commissioningStatus!.name, - textColor: AppColor.getRequestStatusTextColorByName(context, assetModel.commissioningStatus!.name!), - backgroundColor: AppColor.getRequestStatusColorByName(context, assetModel.commissioningStatus!.name!), + AspectRatio( + aspectRatio: 159 / 94, + child: Container( + width: 95, + height: 95, + decoration: ShapeDecoration( + color: AppColor.neutral30, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + image: DecorationImage( + fit: BoxFit.cover, + image: NetworkImage(assetModel?.assetPhoto != null ? URLs.getFileUrl(assetModel!.assetPhoto!)! : "https://www.lasteelcraft.com/images/no-image-available.png"), + )), ), - if (assetModel.commissioningStatus != null) 8.height, - (assetModel.modelDefinition?.assetName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-").heading5(context), - 8.height, - Row( + ), + 6.height, + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - "${context.translation.assetNo}: ${assetModel.multiAssets!.first.assetNumber}".bodyText(context), - "${context.translation.modelName}: ${assetModel.modelDefinition!.modelName}".bodyText(context), - "${context.translation.supplier}: ${assetModel.supplier?.suppliername ?? "-"}".bodyText(context), - "${context.translation.manufacture}: ${assetModel.modelDefinition!.manufacturerName}".bodyText(context), - //"${context.translation.location}: ${assetModel.site.custName?.cleanupWhitespace?.capitalizeFirstOfEach}".bodyText(context), - ], - ).expanded, - 8.width, - Column( + if (assetModel?.commissioningStatus != null) + StatusLabel( + label: assetModel!.commissioningStatus!.name, + textColor: AppColor.getRequestStatusTextColorByName(context, assetModel!.commissioningStatus!.name!), + backgroundColor: AppColor.getRequestStatusColorByName(context, assetModel!.commissioningStatus!.name!), + ), + if (assetModel?.commissioningStatus != null) 8.height, + (assetModel?.modelDefinition?.assetName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-").heading5(context), + 8.height, + Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - /// TODO: theres no [ORACLE CODE] available to preview - // "${context.translation.oracleCode}: ${"-"}".bodyText(context), - "${context.translation.snNo}: ${assetModel.multiAssets!.first.assetSerialNo}".bodyText(context), - "${context.translation.site}: ${assetModel.site?.custName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), - "${context.translation.building}: ${assetModel.building?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), - "${context.translation.floor}: ${assetModel.floor?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), - "${context.translation.md}: ${assetModel.department?.departmentName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), - "${context.translation.room}: ${assetModel.room?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + "${context.translation.assetNo}: ${assetModel!.multiAssets!.first.assetNumber}".bodyText(context), + "${context.translation.modelName}: ${assetModel!.modelDefinition!.modelName}".bodyText(context), + "${context.translation.supplier}: ${assetModel!.supplier?.suppliername ?? "-"}".bodyText(context), + "${context.translation.manufacture}: ${assetModel!.modelDefinition!.manufacturerName}".bodyText(context), + //"${context.translation.location}: ${assetModel.site.custName?.cleanupWhitespace?.capitalizeFirstOfEach}".bodyText(context), + ], + ).expanded, + 8.width, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// TODO: theres no [ORACLE CODE] available to preview + // "${context.translation.oracleCode}: ${"-"}".bodyText(context), + "${context.translation.snNo}: ${assetModel!.multiAssets!.first.assetSerialNo}".bodyText(context), + "${context.translation.site}: ${assetModel!.site?.custName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + "${context.translation.building}: ${assetModel!.building?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + "${context.translation.floor}: ${assetModel!.floor?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + "${context.translation.md}: ${assetModel!.department?.departmentName?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + "${context.translation.room}: ${assetModel!.room?.name?.cleanupWhitespace.capitalizeFirstOfEach ?? "-"}".bodyText(context), + ], + ).expanded, ], - ).expanded, + ), + 8.height, + const Divider(color: AppColor.neutral30, height: 1, thickness: 1), + 8.height, + "${context.translation.installationDate}: ${assetModel!.installationDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), + "${context.translation.nextPmDate}: ${assetModel!.nextPMDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), + "${context.translation.lastPmDate}: ${assetModel!.lastPMDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), + if ((assetModel?.modelDefinition?.assetDescription ?? "").isNotEmpty) ...[ + 8.height, + const Divider(color: AppColor.neutral30, height: 1, thickness: 1), + 8.height, + assetModel!.modelDefinition!.assetDescription!.bodyText(context), + ], + FilesList(images: attachments.map((toElement) => URLs.getFileUrl(toElement.name!) ?? '').toList()), ], - ), - 8.height, - const Divider(color: AppColor.neutral30, height: 1, thickness: 1), - 8.height, - "${context.translation.installationDate}: ${assetModel.installationDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), - "${context.translation.nextPmDate}: ${assetModel.nextPMDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), - "${context.translation.lastPmDate}: ${assetModel.lastPMDate?.toAssetDetailsFormat ?? "-"}".bodyText(context), - if ((assetModel.modelDefinition?.assetDescription ?? "").isNotEmpty) ...[ - 8.height, - const Divider(color: AppColor.neutral30, height: 1, thickness: 1), - 8.height, - assetModel.modelDefinition!.assetDescription!.bodyText(context), - ] + ) ], - ) - ], - ).toShadowContainer(context), + ).toShadowContainer(context), + ]).expanded, + if (context.userProvider.isEngineer) + FooterActionButton.footerContainer( + context: context, + child: AppFilledButton( + buttonColor: AppColor.primary10, + label: "Upload Images", + onPressed: _upload, + ), + ), + ], ); } return const Center(child: ALoading()); @@ -150,4 +180,25 @@ class _AssetDetailPageState extends State { ), ); } + + void _upload() async { + bool reload = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AssetImageUploadPage( + assetId: assetId!, + attachments: assetModel?.assetAttachments + ?.map((attachment) => GenericAttachmentModel( + id: attachment.id, + name: attachment.attachmentName, + originalName: attachment.originalName, + )) + .toList() ?? + []))) ?? + false; + if (reload) { + assetModel = null; + setState(() {}); + } + } } diff --git a/lib/views/widgets/equipment/asset_image_upload_page.dart b/lib/views/widgets/equipment/asset_image_upload_page.dart new file mode 100644 index 00000000..314a7b65 --- /dev/null +++ b/lib/views/widgets/equipment/asset_image_upload_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:test_sa/controllers/providers/api/devices_provider.dart'; +import 'package:test_sa/extensions/context_extension.dart'; +import 'package:test_sa/extensions/text_extensions.dart'; +import 'package:test_sa/extensions/widget_extensions.dart'; +import 'package:test_sa/helper/utils.dart'; +import 'package:test_sa/models/generic_attachment_model.dart'; +import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; +import 'package:test_sa/new_views/common_widgets/app_filled_button.dart'; +import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; +import 'package:test_sa/views/widgets/images/multi_image_picker.dart'; + +class AssetImageUploadPage extends StatefulWidget { + int assetId; + List attachments; + + AssetImageUploadPage({Key? key, required this.assetId, required this.attachments}) : super(key: key); + + @override + _AssetImageUploadPageState createState() { + return _AssetImageUploadPageState(); + } +} + +class _AssetImageUploadPageState extends State { + List attachments = []; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: const DefaultAppBar(title: "Upload"), + body: Column( + children: [ + ListView( + padding: const EdgeInsets.all(16), + children: [ + AttachmentPicker( + label: "Upload Images", + attachment: attachments, + buttonColor: AppColor.black10, + onlyImages: true, + showAsListView: true, + buttonIcon: 'image-plus'.toSvgAsset(color: context.isDark ? AppColor.primary10 : AppColor.neutral120), + onChange: (_attachments) { + attachments = _attachments; + setState(() {}); + }, + ).toShadowContainer(context), + ], + ).expanded, + FooterActionButton.footerContainer( + context: context, + child: AppFilledButton( + buttonColor: AppColor.primary10, + label: "Upload Images", + onPressed: () async { + Utils.showUploadingDialog(context); + bool result = await Provider.of(context, listen: false).uploadAssetImages(widget.assetId, attachments); + Utils.hideLoading(context); + if (result) { + Navigator.pop(context, result); + } + }, + ), + ), + ], + )); + } +} diff --git a/lib/views/widgets/images/multi_image_picker.dart b/lib/views/widgets/images/multi_image_picker.dart index aa9ecd79..a7de6c03 100644 --- a/lib/views/widgets/images/multi_image_picker.dart +++ b/lib/views/widgets/images/multi_image_picker.dart @@ -348,7 +348,7 @@ class _AttachmentPickerState extends State { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start,spacing: 12, children: [ AppDashedButton( title: widget.label, @@ -360,13 +360,12 @@ class _AttachmentPickerState extends State { : widget.showAsGrid ? showFileSourceSheet : onFilePicker), - 16.height, if (widget.attachment.isNotEmpty) widget.showAsListView ? ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, + padding: const EdgeInsets.only(top: 0), itemBuilder: (cxt, index) { File image = File(widget.attachment[index].name!); return MultiFilesPickerItem( @@ -410,7 +409,7 @@ class _AttachmentPickerState extends State { ); }, ), - ), + ).paddingOnly(top: 0), ], ); }