export feature progress and success dialog added

export_module
WaseemAbbasi22 2 months ago
parent 77f5d41757
commit 1e6a3d2f4d

@ -170,6 +170,33 @@ class ApiManager {
return response; 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<http.StreamedResponse> postStreaming(String url, {Map<String, String>? headers, required Map<String, dynamic> 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<http.Response> delete(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async { Future<http.Response> delete(String url, {Map<String, String>? headers, required Map<String, dynamic> body}) async {
headers ??= {}; headers ??= {};
@ -366,4 +393,4 @@ class ApiManager {
void dispose() { void dispose() {
_httpClient.close(); _httpClient.close();
} }
} }

@ -24,11 +24,7 @@ class AssetColumnMetadata extends Base {
} }
@override @override
bool operator ==(Object other) => bool operator ==(Object other) => identical(this, other) || other is AssetColumnMetadata && runtimeType == other.runtimeType && propertyName == other.propertyName;
identical(this, other) ||
other is AssetColumnMetadata &&
runtimeType == other.runtimeType &&
propertyName == other.propertyName;
@override @override
int get hashCode => propertyName.hashCode; int get hashCode => propertyName.hashCode;

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; 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:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/string_extensions.dart'; import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/extensions/text_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/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/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_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/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/multiple_item_drop_down_menu.dart';
import 'package:test_sa/new_views/common_widgets/single_item_drop_down_menu.dart'; import 'package:test_sa/new_views/common_widgets/single_item_drop_down_menu.dart';
import 'dart:developer';
class ExportFormView<T> extends StatefulWidget { class ExportFormView<T> extends StatefulWidget {
final T searchModel; final T searchModel;
@ -81,18 +84,6 @@ class _ExportFormViewState<T> extends State<ExportFormView<T>> {
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
children: [ children: [
MultipleItemDropDownMenu<AssetColumnMetadata, AssetColumnsProvider>(
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<Lookup, ExportFormatProvider>( SingleItemDropDownMenu<Lookup, ExportFormatProvider>(
context: context, context: context,
title: 'Format', title: 'Format',
@ -104,6 +95,18 @@ class _ExportFormViewState<T> extends State<ExportFormView<T>> {
requestById: 1, requestById: 1,
onSelect: _updateSelectedFormat, onSelect: _updateSelectedFormat,
), ),
18.height,
MultipleItemDropDownMenu<AssetColumnMetadata, AssetColumnsProvider>(
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), ).toShadowContainer(context).paddingOnly(top: 20, start: 16, end: 16),
), ),
@ -115,14 +118,15 @@ class _ExportFormViewState<T> extends State<ExportFormView<T>> {
label: 'Export', label: 'Export',
buttonColor: AppColor.primary10, buttonColor: AppColor.primary10,
onPressed: () async { onPressed: () async {
if (selectedColumns.isEmpty) {
'Please select at least one column to export.'.showToast;
return;
}
if (selectedFormat == null) { if (selectedFormat == null) {
'Please select an export format.'.showToast; 'Please select an export format.'.showToast;
return; return;
} }
if (selectedColumns.isEmpty) {
'Please select at least one column to export.'.showToast;
return;
}
final exportRequest = ExportRequest<T>( final exportRequest = ExportRequest<T>(
selectedColumns: selectedColumns.map((col) => col.propertyName ?? '').toList(), selectedColumns: selectedColumns.map((col) => col.propertyName ?? '').toList(),
format: selectedFormat?.name ?? '', format: selectedFormat?.name ?? '',
@ -130,21 +134,34 @@ class _ExportFormViewState<T> extends State<ExportFormView<T>> {
pageNo: widget.pageNo, pageNo: widget.pageNo,
pageSize: widget.pageSize, pageSize: widget.pageSize,
); );
final exportProvider = Provider.of<ExportApiProvider>(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<ExportApiProvider>(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<T> extends State<ExportFormView<T>> {
), ),
); );
} }
Future<void> _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 //check if we need we can show this as well @waseem
String _getAppSuggestion(String format) { String _getAppSuggestion(String format) {

@ -1,16 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; 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: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/api_manager.dart';
import 'package:test_sa/controllers/api_routes/urls.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_form_model.dart';
import 'package:test_sa/modules/export_module/models/export_module_type.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 { class ExportApiProvider extends ChangeNotifier {
bool _isExporting = false; bool _isExporting = false;
@ -25,43 +24,139 @@ class ExportApiProvider extends ChangeNotifier {
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
Future<bool> exportData<T>(ExportRequest<T> exportRequest, ExportModuleType moduleType) async { Future<String?> exportData<T>(ExportRequest<T> exportRequest, ExportModuleType moduleType, BuildContext context) async {
_isExporting = true; _isExporting = true;
_errorMessage = null; _errorMessage = null;
notifyListeners(); 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 { try {
final String apiUrl = _getApiUrlForModule(moduleType); final String apiUrl = _getApiUrlForModule(moduleType);
final Map<String, dynamic> body = _buildRequestBody(exportRequest, moduleType); final Map<String, dynamic> 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); final startTime = DateTime.now();
_stateCode = response.statusCode;
if (response.statusCode >= 200 && response.statusCode < 300) { final streamedResponse = await ApiManager.instance.postStreaming(apiUrl, body: body);
String ext = _getFileExtension(exportRequest.format); _stateCode = streamedResponse.statusCode;
String encodedStr = base64.encode(response.bodyBytes);
String filePath = await _createFileFromString(encodedStr, ext); final responseTime = DateTime.now().difference(startTime).inMilliseconds;
await OpenFile.open(filePath); 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 { } else {
if (context.mounted) {
pd.close();
}
try { try {
final responseData = json.decode(response.body); final responseBody = await streamedResponse.stream.bytesToString();
final responseData = json.decode(responseBody);
_errorMessage = responseData['message'] ?? 'Export failed'; _errorMessage = responseData['message'] ?? 'Export failed';
} catch (e) { } 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) { } 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; _stateCode = -1;
_errorMessage = 'An error occurred during export: $error'; _errorMessage = 'An error occurred during export: $error';
return false; return null;
} finally { } finally {
_isExporting = false; _isExporting = false;
notifyListeners(); 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) { if (moduleType == ExportModuleType.callRequest) {
_addCallRequestFields(body); _addCallRequestFields(body);
} }
@ -125,7 +219,6 @@ class ExportApiProvider extends ChangeNotifier {
} }
void _addCallRequestFields(Map<String, dynamic> body) { void _addCallRequestFields(Map<String, dynamic> 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) { 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]; 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<String> _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() { void resetError() {
_errorMessage = null; _errorMessage = null;
notifyListeners(); notifyListeners();

@ -1571,6 +1571,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: source_span:
dependency: transitive dependency: transitive
description: description:

@ -108,6 +108,7 @@ dependencies:
ellipsized_text: ^2.0.0 ellipsized_text: ^2.0.0
local_auth_darwin: ^1.4.0 local_auth_darwin: ^1.4.0
haptic_feedback: ^0.6.4+3 haptic_feedback: ^0.6.4+3
sn_progress_dialog: ^1.2.0
dev_dependencies: dev_dependencies:

Loading…
Cancel
Save