diff --git a/lib/controllers/api_routes/api_manager.dart b/lib/controllers/api_routes/api_manager.dart index 4d85989a..abd554d4 100644 --- a/lib/controllers/api_routes/api_manager.dart +++ b/lib/controllers/api_routes/api_manager.dart @@ -170,6 +170,33 @@ class ApiManager { return response; } + /// POST request that returns StreamedResponse for progress tracking (e.g., file downloads/uploads) + /// This method doesn't consume the response stream, allowing the caller to track progress + Future postStreaming(String url, {Map? headers, required Map body}) async { + headers ??= {}; + headers.addAll(_headers); + + Uri url0 = Uri.parse(url); + if (!kReleaseMode) { + print("URL:$url0"); + print("Headers:$headers"); + print("Body:$body"); + } + + var request = http.Request('POST', url0); + request.body = json.encode(body); + request.headers.addAll(headers); + + http.StreamedResponse streamedResponse = await _httpClient.send(request); + + // Handle 401 immediately + if (streamedResponse.statusCode == 401) { + showLoginDialog(); + } + + return streamedResponse; + } + Future delete(String url, {Map? headers, required Map body}) async { headers ??= {}; @@ -366,4 +393,4 @@ class ApiManager { void dispose() { _httpClient.close(); } -} +} \ No newline at end of file diff --git a/lib/modules/export_module/models/asset_column_metadata.dart b/lib/modules/export_module/models/asset_column_metadata.dart index e1d5c6ba..08a5becc 100644 --- a/lib/modules/export_module/models/asset_column_metadata.dart +++ b/lib/modules/export_module/models/asset_column_metadata.dart @@ -24,11 +24,7 @@ class AssetColumnMetadata extends Base { } @override - bool operator ==(Object other) => - identical(this, other) || - other is AssetColumnMetadata && - runtimeType == other.runtimeType && - propertyName == other.propertyName; + bool operator ==(Object other) => identical(this, other) || other is AssetColumnMetadata && runtimeType == other.runtimeType && propertyName == other.propertyName; @override int get hashCode => propertyName.hashCode; diff --git a/lib/modules/export_module/pages/export_form_view.dart b/lib/modules/export_module/pages/export_form_view.dart index bf92d351..cffc0be0 100644 --- a/lib/modules/export_module/pages/export_form_view.dart +++ b/lib/modules/export_module/pages/export_form_view.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:open_file/open_file.dart'; import 'package:provider/provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; @@ -14,10 +17,10 @@ import 'package:test_sa/modules/export_module/provider/export_columns_provider.d import 'package:test_sa/modules/export_module/provider/export_format_provider.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/app_lazy_loading.dart'; import 'package:test_sa/new_views/common_widgets/default_app_bar.dart'; import 'package:test_sa/new_views/common_widgets/multiple_item_drop_down_menu.dart'; import 'package:test_sa/new_views/common_widgets/single_item_drop_down_menu.dart'; +import 'dart:developer'; class ExportFormView extends StatefulWidget { final T searchModel; @@ -81,18 +84,6 @@ class _ExportFormViewState extends State> { child: SingleChildScrollView( child: Column( children: [ - MultipleItemDropDownMenu( - context: context, - title: 'Columns', - showAsBottomSheet: true, - backgroundColor: AppColor.fieldBgColor(context), - showShadow: false, - height: 56.toScreenHeight, - showCancel: true, - requestById: widget.moduleType.id, - onSelect: _updateSelectedColumns, - ), - 18.height, SingleItemDropDownMenu( context: context, title: 'Format', @@ -104,6 +95,18 @@ class _ExportFormViewState extends State> { requestById: 1, onSelect: _updateSelectedFormat, ), + 18.height, + MultipleItemDropDownMenu( + context: context, + title: 'Columns', + showAsBottomSheet: true, + backgroundColor: AppColor.fieldBgColor(context), + showShadow: false, + height: 56.toScreenHeight, + showCancel: true, + requestById: widget.moduleType.id, + onSelect: _updateSelectedColumns, + ), ], ).toShadowContainer(context).paddingOnly(top: 20, start: 16, end: 16), ), @@ -115,14 +118,15 @@ class _ExportFormViewState extends State> { label: 'Export', buttonColor: AppColor.primary10, onPressed: () async { - if (selectedColumns.isEmpty) { - 'Please select at least one column to export.'.showToast; - return; - } if (selectedFormat == null) { 'Please select an export format.'.showToast; return; } + if (selectedColumns.isEmpty) { + 'Please select at least one column to export.'.showToast; + return; + } + final exportRequest = ExportRequest( selectedColumns: selectedColumns.map((col) => col.propertyName ?? '').toList(), format: selectedFormat?.name ?? '', @@ -130,21 +134,34 @@ class _ExportFormViewState extends State> { pageNo: widget.pageNo, pageSize: widget.pageSize, ); - final exportProvider = Provider.of(context, listen: false); - showDialog(context: context, barrierDismissible: false, builder: (context) => const AppLazyLoading()); - final success = await exportProvider.exportData(exportRequest, widget.moduleType); - Navigator.pop(context); - // this is for this This should be handled as per requirement in dialog with share and other options - if (success) { - 'Export completed successfully!'.showToast; - } else { - String errorMessage = exportProvider.errorMessage ?? 'Export failed. Please try again.'; - if (errorMessage.contains('No app installed')) { - String fileType = selectedFormat?.name ?? 'this file type'; - errorMessage = 'No app found to open $fileType files. Please install a compatible app'; - } - errorMessage.showToast; + log('📦 ExportRequest created'); + + try { + final exportProvider = Provider.of(context, listen: false); + if (!mounted) return; + + final filePath = await exportProvider.exportData(exportRequest, widget.moduleType, context); + if (!mounted) return; + + if (filePath != null && filePath.isNotEmpty) { + await _showExportSuccessDialog(context, filePath); + } else if (exportProvider.errorMessage != null) { + String errorMessage = exportProvider.errorMessage!; + if (errorMessage.contains('No app installed') || errorMessage.contains('No app found')) { + String fileType = selectedFormat?.name ?? 'this file type'; + errorMessage = 'No app found to open $fileType files. Please install a compatible app'; + } + errorMessage.showToast; + } else { + 'Export failed. Please try again.'.showToast; + } + } catch (e, stackTrace) { + log('❌ Error in export button: $e'); + log('Stack trace: $stackTrace'); + if (mounted) { + 'Error occurred during export: $e'.showToast; + } } }), ), @@ -152,6 +169,103 @@ class _ExportFormViewState extends State> { ), ); } + + Future _showExportSuccessDialog(BuildContext context, String filePath) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext dialogContext) { + return Dialog( + backgroundColor: AppColor.background(context), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + 'assets/images/done.svg', + width: 80, + height: 80, + ), + const SizedBox(height: 24), + Text( + 'Export Successful', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColor.headingTextColor(context), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Your file has been exported successfully', + style: TextStyle( + fontSize: 14, + color: AppColor.lightTextColor(context), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Row( + children: [ + Expanded( + child: AppFilledButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + log('📂 Opening exported file...'); + final result = await OpenFile.open(filePath); + if (result.type != ResultType.done) { + log('⚠️ Could not open file: ${result.message}'); + 'No app found to open this file type. File saved to: $filePath'.showToast; + } + }, + label: 'Open', + buttonColor: AppColor.primary10, + showIcon: true, + icon: const Icon(Icons.folder_open, color: Colors.white), + height: 48, + ), + ), + const SizedBox(width: 12), + Expanded( + child: AppFilledButton( + onPressed: () async { + Navigator.of(dialogContext).pop(); + log('📤 Sharing exported file...'); + try { + final xFile = XFile(filePath); + await Share.shareXFiles( + [xFile], + text: 'Exported file', + ); + log('✅ File shared successfully'); + } catch (e) { + log('❌ Error sharing file: $e'); + 'Failed to share file: $e'.showToast; + } + }, + label: 'Share', + buttonColor: AppColor.green40, + textColor: AppColor.green60, + showIcon: true, + icon: Icon(Icons.share, color: AppColor.green60), + height: 48, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + //check if we need we can show this as well @waseem String _getAppSuggestion(String format) { diff --git a/lib/modules/export_module/provider/export_provider.dart b/lib/modules/export_module/provider/export_provider.dart index 88fb3c6a..fde94ecf 100644 --- a/lib/modules/export_module/provider/export_provider.dart +++ b/lib/modules/export_module/provider/export_provider.dart @@ -1,16 +1,15 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; -import 'package:http/http.dart'; -import 'package:open_file/open_file.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:sn_progress_dialog/sn_progress_dialog.dart'; import 'package:test_sa/controllers/api_routes/api_manager.dart'; import 'package:test_sa/controllers/api_routes/urls.dart'; import 'package:test_sa/modules/export_module/models/export_form_model.dart'; import 'package:test_sa/modules/export_module/models/export_module_type.dart'; +import 'package:test_sa/new_views/app_style/app_color.dart'; class ExportApiProvider extends ChangeNotifier { bool _isExporting = false; @@ -25,43 +24,139 @@ class ExportApiProvider extends ChangeNotifier { String? get errorMessage => _errorMessage; - Future exportData(ExportRequest exportRequest, ExportModuleType moduleType) async { + Future exportData(ExportRequest exportRequest, ExportModuleType moduleType, BuildContext context) async { _isExporting = true; _errorMessage = null; notifyListeners(); + ProgressDialog pd = ProgressDialog(context: context); + pd.show( + max: 100, + msg: 'Preparing export...', + progressBgColor: Colors.transparent, + progressValueColor: AppColor.primary10, + msgFontWeight: FontWeight.normal, + + // backgroundColor: AppColor.background(context), + // msgColor: AppColor.headingTextColor(context), + valueColor: AppColor.primary10, + ); + + IOSink? sink; + File? outputFile; try { final String apiUrl = _getApiUrlForModule(moduleType); final Map body = _buildRequestBody(exportRequest, moduleType); + if (!context.mounted) return null; + pd.update(msg: 'Sending request to server...'); + log('📡 Sending export request...'); - Response response = await ApiManager.instance.post(apiUrl, body: body); - _stateCode = response.statusCode; + final startTime = DateTime.now(); - if (response.statusCode >= 200 && response.statusCode < 300) { - String ext = _getFileExtension(exportRequest.format); - String encodedStr = base64.encode(response.bodyBytes); + final streamedResponse = await ApiManager.instance.postStreaming(apiUrl, body: body); + _stateCode = streamedResponse.statusCode; - String filePath = await _createFileFromString(encodedStr, ext); - await OpenFile.open(filePath); + final responseTime = DateTime.now().difference(startTime).inMilliseconds; + log('✅ Response received in ${responseTime}ms - Status: ${streamedResponse.statusCode}'); - return true; + if (streamedResponse.statusCode >= 200 && streamedResponse.statusCode < 300) { + final contentLength = streamedResponse.contentLength ?? 0; + log('📊 Content length: ${contentLength > 0 ? "${(contentLength / 1024 / 1024).toStringAsFixed(2)} MB" : "Unknown"}'); + + if (!context.mounted) { + await streamedResponse.stream.drain(); + return null; + } + final String ext = _getFileExtension(exportRequest.format); + final String dir = (await getApplicationDocumentsDirectory()).path; + final String filePath = "$dir/${DateTime.now().millisecondsSinceEpoch}.$ext"; + outputFile = File(filePath); + sink = outputFile.openWrite(); + int received = 0; + int lastProgress = 15; + final downloadStartTime = DateTime.now(); + pd.update(value: 15, msg: 'Exporting...'); + await for (var chunk in streamedResponse.stream) { + sink.add(chunk); + received += chunk.length; + if (!context.mounted) { + await sink.close(); + await outputFile.delete(); + return null; + } + + if (contentLength > 0) { + final progress = (15 + ((received / contentLength) * 70)).clamp(15, 85).toInt(); + if (progress > lastProgress) { + lastProgress = progress; + final downloadedMB = (received / 1024 / 1024).toStringAsFixed(2); + final totalMB = (contentLength / 1024 / 1024).toStringAsFixed(2); + pd.update( + value: progress, + msg: 'Exporting: $downloadedMB MB / $totalMB MB', + ); + } + } else { + final downloadedMB = (received / 1024 / 1024).toStringAsFixed(2); + pd.update( + msg: 'Exporting: $downloadedMB MB', + ); + } + } + await sink.close(); + sink = null; + final downloadTime = DateTime.now().difference(downloadStartTime).inMilliseconds; + final fileSizeMB = (received / 1024 / 1024).toStringAsFixed(2); + final downloadSpeed = downloadTime > 0 ? ((received / 1024 / 1024) / (downloadTime / 1000)).toStringAsFixed(2) : 'N/A'; + if (!context.mounted) { + await outputFile.delete(); + return null; + } + pd.close(); + final totalTime = DateTime.now().difference(startTime).inMilliseconds; + return filePath; } else { + if (context.mounted) { + pd.close(); + } try { - final responseData = json.decode(response.body); + final responseBody = await streamedResponse.stream.bytesToString(); + final responseData = json.decode(responseBody); _errorMessage = responseData['message'] ?? 'Export failed'; } catch (e) { - _errorMessage = 'Export failed with status: ${response.statusCode}'; + log('❌ Error parsing error response: $e'); + _errorMessage = 'Export failed with status: ${streamedResponse.statusCode}'; } - return false; + return null; } } catch (error, stackTrace) { - log('Error exporting data: $error'); + if (sink != null) { + try { + await sink.close(); + } catch (e) { + log('⚠️ Error closing sink: $e'); + } + } + if (outputFile != null && await outputFile.exists()) { + try { + await outputFile.delete(); + log('🗑️ Deleted incomplete file'); + } catch (e) { + log('⚠️ Error deleting incomplete file: $e'); + } + } + + if (context.mounted) { + pd.close(); + } + log('❌ Error exporting data: $error'); _stateCode = -1; _errorMessage = 'An error occurred during export: $error'; - return false; + return null; } finally { _isExporting = false; notifyListeners(); + log('🔚 Export process finished'); } } @@ -108,7 +203,6 @@ class ExportApiProvider extends ChangeNotifier { } } - // Add CallRequest-specific fields only for callRequest type if (moduleType == ExportModuleType.callRequest) { _addCallRequestFields(body); } @@ -125,7 +219,6 @@ class ExportApiProvider extends ChangeNotifier { } void _addCallRequestFields(Map body) { - //This is if no filter is selected we will send all the options to the backend to filter accordingly as per we are sending on allworkorder api if (!body.containsKey('typeTransaction') || (body['typeTransaction'] as List?)?.isEmpty == true) { body['typeTransaction'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; } @@ -143,14 +236,6 @@ class ExportApiProvider extends ChangeNotifier { } } - Future _createFileFromString(String encodedStr, String ext) async { - Uint8List bytes = base64.decode(encodedStr); - String dir = (await getApplicationDocumentsDirectory()).path; - File file = File("$dir/${DateTime.now().millisecondsSinceEpoch}.$ext"); - await file.writeAsBytes(bytes); - return file.path; - } - void resetError() { _errorMessage = null; notifyListeners(); diff --git a/pubspec.lock b/pubspec.lock index 4f2f810a..8626a470 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1571,6 +1571,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sn_progress_dialog: + dependency: "direct main" + description: + name: sn_progress_dialog + sha256: bbb6bbe71c12afe937474932eefcb5c7d607238b1325f83206f12d3f7a850acf + url: "https://pub.dev" + source: hosted + version: "1.2.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 35df467d..44bc1e86 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -108,6 +108,7 @@ dependencies: ellipsized_text: ^2.0.0 local_auth_darwin: ^1.4.0 haptic_feedback: ^0.6.4+3 + sn_progress_dialog: ^1.2.0 dev_dependencies: