export feature added for asset and my request
parent
023a47f69f
commit
77f5d41757
@ -0,0 +1,35 @@
|
||||
import 'package:test_sa/models/base.dart';
|
||||
|
||||
class AssetColumnMetadata extends Base {
|
||||
String? propertyName;
|
||||
String? displayName;
|
||||
|
||||
AssetColumnMetadata({
|
||||
this.propertyName,
|
||||
this.displayName,
|
||||
}) : super(identifier: propertyName, name: displayName);
|
||||
|
||||
factory AssetColumnMetadata.fromJson(Map<String, dynamic> json) {
|
||||
return AssetColumnMetadata(
|
||||
propertyName: json['propertyName'],
|
||||
displayName: json['displayName'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'propertyName': propertyName,
|
||||
'displayName': displayName,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AssetColumnMetadata &&
|
||||
runtimeType == other.runtimeType &&
|
||||
propertyName == other.propertyName;
|
||||
|
||||
@override
|
||||
int get hashCode => propertyName.hashCode;
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
class ExportRequest<T> {
|
||||
final List<String> selectedColumns;
|
||||
final String format;
|
||||
final T searchModel;
|
||||
final int pageNo;
|
||||
final int pageSize;
|
||||
|
||||
ExportRequest({
|
||||
required this.selectedColumns,
|
||||
required this.format,
|
||||
required this.searchModel,
|
||||
required this.pageNo,
|
||||
required this.pageSize,
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
/// Enum to identify different module types for export functionality
|
||||
enum ExportModuleType {
|
||||
asset(1, 'Asset'),
|
||||
callRequest(2, 'AllWorkOrder');
|
||||
|
||||
final int id;
|
||||
final String displayName;
|
||||
|
||||
const ExportModuleType(this.id, this.displayName);
|
||||
|
||||
/// Get module type from id
|
||||
static ExportModuleType fromId(int id) {
|
||||
return ExportModuleType.values.firstWhere(
|
||||
(type) => type.id == id,
|
||||
orElse: () => ExportModuleType.asset,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/models/lookup.dart';
|
||||
import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart';
|
||||
import 'package:test_sa/modules/export_module/models/asset_column_metadata.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/provider/export_provider.dart';
|
||||
import 'package:test_sa/modules/export_module/provider/export_columns_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/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';
|
||||
|
||||
class ExportFormView<T> extends StatefulWidget {
|
||||
final T searchModel;
|
||||
final ExportModuleType moduleType;
|
||||
final int pageNo;
|
||||
final int pageSize;
|
||||
|
||||
const ExportFormView({
|
||||
Key? key,
|
||||
required this.searchModel,
|
||||
required this.moduleType,
|
||||
required this.pageNo,
|
||||
required this.pageSize,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExportFormView<T>> createState() => _ExportFormViewState<T>();
|
||||
}
|
||||
|
||||
class _ExportFormViewState<T> extends State<ExportFormView<T>> {
|
||||
List<AssetColumnMetadata> selectedColumns = [];
|
||||
Lookup? selectedFormat;
|
||||
|
||||
void _updateSelectedColumns(List<AssetColumnMetadata>? value) {
|
||||
if (value != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
selectedColumns = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _updateSelectedFormat(Lookup? value) {
|
||||
if (value != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
selectedFormat = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: DefaultAppBar(
|
||||
title: 'Export',
|
||||
titleStyle: AppTextStyles.heading3.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).brightness == Brightness.dark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
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>(
|
||||
context: context,
|
||||
title: 'Format',
|
||||
backgroundColor: AppColor.fieldBgColor(context),
|
||||
height: 56.toScreenHeight,
|
||||
showShadow: false,
|
||||
showAsBottomSheet: false,
|
||||
showAsFullScreenDialog: true,
|
||||
requestById: 1,
|
||||
onSelect: _updateSelectedFormat,
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context).paddingOnly(top: 20, start: 16, end: 16),
|
||||
),
|
||||
),
|
||||
18.height,
|
||||
FooterActionButton.footerContainer(
|
||||
context: context,
|
||||
child: AppFilledButton(
|
||||
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;
|
||||
}
|
||||
final exportRequest = ExportRequest<T>(
|
||||
selectedColumns: selectedColumns.map((col) => col.propertyName ?? '').toList(),
|
||||
format: selectedFormat?.name ?? '',
|
||||
searchModel: widget.searchModel,
|
||||
pageNo: widget.pageNo,
|
||||
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;
|
||||
}
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
//check if we need we can show this as well @waseem
|
||||
|
||||
String _getAppSuggestion(String format) {
|
||||
final formatLower = format.toLowerCase();
|
||||
switch (formatLower) {
|
||||
case 'excel':
|
||||
return 'Microsoft Excel, Google Sheets, or WPS Office';
|
||||
case 'pdf':
|
||||
return 'Adobe Acrobat Reader or any PDF viewer';
|
||||
case 'word':
|
||||
case 'doc':
|
||||
return 'Microsoft Word, Google Docs, or WPS Office';
|
||||
case 'text':
|
||||
case 'txt':
|
||||
return 'any text editor';
|
||||
case 'csv':
|
||||
return 'Microsoft Excel, Google Sheets, or any spreadsheet app';
|
||||
default:
|
||||
return 'a compatible viewer app';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.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/asset_column_metadata.dart';
|
||||
import 'package:test_sa/modules/export_module/models/export_module_type.dart';
|
||||
import 'package:test_sa/providers/loading_list_notifier.dart';
|
||||
|
||||
class AssetColumnsProvider extends LoadingListNotifier<AssetColumnMetadata> {
|
||||
@override
|
||||
Future<void> getData({int? id}) async {
|
||||
loading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final moduleType = id != null ? ExportModuleType.fromId(id) : ExportModuleType.asset;
|
||||
String apiUrl;
|
||||
switch (moduleType) {
|
||||
case ExportModuleType.asset:
|
||||
apiUrl = URLs.getAssetColumnsMetadata;
|
||||
break;
|
||||
case ExportModuleType.callRequest:
|
||||
apiUrl = URLs.getAllWorkOrderColumnsMetadata;
|
||||
break;
|
||||
}
|
||||
|
||||
Response response = await ApiManager.instance.get(apiUrl);
|
||||
|
||||
stateCode = response.statusCode;
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
final responseData = json.decode(response.body);
|
||||
final List<dynamic> data = responseData['data'] ?? [];
|
||||
log('Raw data received: ${data.length} items for ${moduleType.displayName}');
|
||||
|
||||
items = data.map((item) => AssetColumnMetadata.fromJson(item)).toList();
|
||||
log('First item: ${items.isNotEmpty ? items.first.displayName : "empty"}');
|
||||
} else {
|
||||
log('Failed with status code: ${response.statusCode}');
|
||||
items = [];
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error fetching columns: $error');
|
||||
stateCode = -1;
|
||||
items = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
log('Loading finished. Items count: ${items.length}, Loading: $loading, StateCode: $stateCode');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
||||
import 'package:test_sa/controllers/api_routes/urls.dart';
|
||||
import 'package:test_sa/models/lookup.dart';
|
||||
import 'package:test_sa/providers/loading_list_notifier.dart';
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
import 'package:test_sa/models/lookup.dart';
|
||||
|
||||
class ExportFormatProvider extends LoadingListNotifier<Lookup> {
|
||||
@override
|
||||
Future getData({int? id}) async {
|
||||
if (loading ?? false) return -2;
|
||||
loading = true;
|
||||
notifyListeners();
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.get(URLs.exportFormatTypeLookup);
|
||||
} catch (error) {
|
||||
loading = false;
|
||||
stateCode = -1;
|
||||
notifyListeners();
|
||||
return -1;
|
||||
}
|
||||
stateCode = response.statusCode;
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
List listJson = json.decode(response.body)["data"];
|
||||
items = listJson.map((department) => Lookup.fromJson(department)).toList();
|
||||
}
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
return response.statusCode;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
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: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';
|
||||
|
||||
class ExportApiProvider extends ChangeNotifier {
|
||||
bool _isExporting = false;
|
||||
|
||||
bool get isExporting => _isExporting;
|
||||
|
||||
int? _stateCode;
|
||||
|
||||
int? get stateCode => _stateCode;
|
||||
|
||||
String? _errorMessage;
|
||||
|
||||
String? get errorMessage => _errorMessage;
|
||||
|
||||
Future<bool> exportData<T>(ExportRequest<T> exportRequest, ExportModuleType moduleType) async {
|
||||
_isExporting = true;
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final String apiUrl = _getApiUrlForModule(moduleType);
|
||||
final Map<String, dynamic> body = _buildRequestBody(exportRequest, moduleType);
|
||||
|
||||
Response response = await ApiManager.instance.post(apiUrl, body: body);
|
||||
_stateCode = response.statusCode;
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
String ext = _getFileExtension(exportRequest.format);
|
||||
String encodedStr = base64.encode(response.bodyBytes);
|
||||
|
||||
String filePath = await _createFileFromString(encodedStr, ext);
|
||||
await OpenFile.open(filePath);
|
||||
|
||||
return true;
|
||||
} else {
|
||||
try {
|
||||
final responseData = json.decode(response.body);
|
||||
_errorMessage = responseData['message'] ?? 'Export failed';
|
||||
} catch (e) {
|
||||
_errorMessage = 'Export failed with status: ${response.statusCode}';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
log('Error exporting data: $error');
|
||||
_stateCode = -1;
|
||||
_errorMessage = 'An error occurred during export: $error';
|
||||
return false;
|
||||
} finally {
|
||||
_isExporting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
String _getApiUrlForModule(ExportModuleType moduleType) {
|
||||
switch (moduleType) {
|
||||
case ExportModuleType.asset:
|
||||
return URLs.exportAsset;
|
||||
case ExportModuleType.callRequest:
|
||||
return URLs.exportAllWorkOrder;
|
||||
default:
|
||||
return URLs.exportAsset;
|
||||
}
|
||||
}
|
||||
|
||||
String _getFileExtension(String format) {
|
||||
final formatLower = format.toLowerCase();
|
||||
switch (formatLower) {
|
||||
case 'excel':
|
||||
return 'xlsx';
|
||||
case 'pdf':
|
||||
return 'pdf';
|
||||
case 'word':
|
||||
case 'doc':
|
||||
return 'docx';
|
||||
case 'text':
|
||||
case 'txt':
|
||||
return 'txt';
|
||||
case 'csv':
|
||||
return 'csv';
|
||||
default:
|
||||
return formatLower;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildRequestBody<T>(ExportRequest<T> exportRequest, ExportModuleType moduleType) {
|
||||
final Map<String, dynamic> body = {};
|
||||
|
||||
if (exportRequest.searchModel != null) {
|
||||
try {
|
||||
final searchModelJson = (exportRequest.searchModel as dynamic).toJson() as Map<String, dynamic>;
|
||||
body.addAll(searchModelJson);
|
||||
} catch (e) {
|
||||
body['searchModel'] = exportRequest.searchModel;
|
||||
}
|
||||
}
|
||||
|
||||
// Add CallRequest-specific fields only for callRequest type
|
||||
if (moduleType == ExportModuleType.callRequest) {
|
||||
_addCallRequestFields(body);
|
||||
}
|
||||
|
||||
body['pageNo'] = exportRequest.pageNo;
|
||||
body['pageSize'] = exportRequest.pageSize;
|
||||
|
||||
body['exportRequest'] = {
|
||||
'selectedColumns': exportRequest.selectedColumns,
|
||||
'format': exportRequest.format,
|
||||
};
|
||||
|
||||
return 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) {
|
||||
body['typeTransaction'] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||
}
|
||||
|
||||
if (!body.containsKey('statusTransaction') || (body['statusTransaction'] as List?)?.isEmpty == true) {
|
||||
body['statusTransaction'] = [1, 2, 4];
|
||||
}
|
||||
|
||||
if (!body.containsKey('priority')) {
|
||||
body['priority'] = [];
|
||||
}
|
||||
|
||||
if (!body.containsKey('displayData')) {
|
||||
body['displayData'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_errorMessage = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,412 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:test_sa/controllers/api_routes/urls.dart';
|
||||
import 'package:test_sa/extensions/context_extension.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';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/app_text_form_field.dart';
|
||||
import 'package:test_sa/views/widgets/buttons/app_back_button.dart';
|
||||
|
||||
class CleanerChecklistWidget extends StatefulWidget {
|
||||
final List<ChecklistItem> checklistItems;
|
||||
final Function(List<ChecklistItem>)? onChecklistChanged;
|
||||
final String? capturedImageBase64;
|
||||
final Function(String?)? onImageCaptured;
|
||||
bool isClosed;
|
||||
|
||||
CleanerChecklistWidget({Key? key, required this.checklistItems, this.onChecklistChanged, this.capturedImageBase64, this.onImageCaptured, this.isClosed = false}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CleanerChecklistWidget> createState() => _CleanerChecklistWidgetState();
|
||||
}
|
||||
|
||||
class _CleanerChecklistWidgetState extends State<CleanerChecklistWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Only show if checklist items are not empty
|
||||
if (widget.checklistItems.isEmpty) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Checklist'.addTranslation,
|
||||
style: AppTextStyles.heading4.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
16.height,
|
||||
...widget.checklistItems.map((item) => _buildChecklistItem(context, item)).toList(),
|
||||
// 16.height,
|
||||
_buildImageCaptureWidget(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChecklistItem(BuildContext context, ChecklistItem item) {
|
||||
return IgnorePointer(
|
||||
ignoring: widget.isClosed,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Checkbox with title
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
// visualDensity: const VisualDensity(horizontal: -4.0, vertical: -4.0),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
value: item.isChecked,
|
||||
activeColor: AppColor.primary10,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
item.isChecked = value ?? false;
|
||||
if (!item.isChecked) {
|
||||
item.commentController.clear();
|
||||
}
|
||||
});
|
||||
// Notify parent of changes
|
||||
widget.onChecklistChanged?.call(widget.checklistItems);
|
||||
},
|
||||
),
|
||||
8.width,
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: item.title,
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (item.isMandatory)
|
||||
TextSpan(
|
||||
text: ' *',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Comment box - only visible when checked
|
||||
if (item.isChecked) ...[
|
||||
16.height,
|
||||
AppTextFormField(
|
||||
controller: item.commentController,
|
||||
labelText: context.translation.comment,
|
||||
backgroundColor: AppColor.fieldBgColor(context),
|
||||
showShadow: false,
|
||||
labelStyle: AppTextStyles.textFieldLabelStyle.copyWith(
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
alignLabelWithHint: true,
|
||||
textInputType: TextInputType.multiline,
|
||||
onChange: (value) {
|
||||
item.comment = value;
|
||||
// Notify parent of changes
|
||||
widget.onChecklistChanged?.call(widget.checklistItems);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
).toShadowContainer(context).paddingOnly(bottom: 16),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageCaptureWidget(BuildContext context) {
|
||||
// Base64 image display and capture widget
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Captured Image'.addTranslation,
|
||||
style: AppTextStyles.heading6.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
8.height,
|
||||
_buildImageWidget(context),
|
||||
],
|
||||
).toShadowContainer(context);
|
||||
}
|
||||
|
||||
/// Build the image widget with loading indicator for network images
|
||||
Widget _buildImageWidget(BuildContext context) {
|
||||
if (widget.capturedImageBase64 == null || widget.capturedImageBase64!.isEmpty) {
|
||||
// Show camera icon when no image
|
||||
return Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
size: 48,
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
).onPress(() async {
|
||||
final pickedFile = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 70, maxWidth: 800, maxHeight: 800);
|
||||
if (pickedFile != null) {
|
||||
// Convert image to Base64 with filename prefix (same format as CM request)
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
final base64Image = base64Encode(bytes);
|
||||
final fileName = pickedFile.path.split("/").last;
|
||||
final formattedImage = "$fileName|$base64Image";
|
||||
|
||||
// Update parent with captured image
|
||||
widget.onImageCaptured?.call(formattedImage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Widget imageWidget;
|
||||
|
||||
final imageString = widget.capturedImageBase64!;
|
||||
|
||||
// First check if it's base64 encoded (contains | separator)
|
||||
if (imageString.contains('|')) {
|
||||
// Format: "filename|base64data"
|
||||
try {
|
||||
final base64Data = imageString.split('|').last;
|
||||
imageWidget = Image.memory(base64Decode(base64Data), fit: BoxFit.cover);
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
children: [
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: imageWidget)
|
||||
.onPress(() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// InteractiveViewer(child: Image(image: getImageObject(itemIndex))).center,
|
||||
InteractiveViewer(child: imageWidget).center,
|
||||
const ABackButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
_delete(),
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error decoding base64 image: $e');
|
||||
return _buildCameraIcon(context);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a local file path
|
||||
if (_isLocalUrl(imageString)) {
|
||||
try {
|
||||
imageWidget = Image.file(File(imageString), fit: BoxFit.cover);
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
children: [
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: imageWidget)
|
||||
.onPress(() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// InteractiveViewer(child: Image(image: getImageObject(itemIndex))).center,
|
||||
InteractiveViewer(child: imageWidget).center,
|
||||
const ABackButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
_delete(),
|
||||
],
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Error loading local file: $e');
|
||||
return _buildCameraIcon(context);
|
||||
}
|
||||
}
|
||||
|
||||
// If not local and not base64, it's a server filename - use NetworkImage with loading indicator
|
||||
final imageUrl = URLs.getFileUrl(imageString);
|
||||
if (imageUrl != null) {
|
||||
imageWidget = Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) {
|
||||
return child;
|
||||
}
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColor.primary10,
|
||||
value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
debugPrint('Error loading network image: $error');
|
||||
return _buildCameraIcon(context);
|
||||
},
|
||||
);
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
children: [
|
||||
Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: imageWidget)
|
||||
.onPress(() {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// InteractiveViewer(child: Image(image: getImageObject(itemIndex))).center,
|
||||
InteractiveViewer(child: imageWidget).center,
|
||||
const ABackButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
_delete(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return _buildCameraIcon(context);
|
||||
}
|
||||
|
||||
/// Build camera icon widget
|
||||
Widget _buildCameraIcon(BuildContext context) {
|
||||
return Container(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.camera_alt,
|
||||
size: 48,
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if the URL is a local file path
|
||||
/// Same logic as multi_image_picker_item.dart
|
||||
bool _isLocalUrl(String url) {
|
||||
if (url.isEmpty) return false;
|
||||
return url.startsWith("/") || url.startsWith("file://") || (url.length > 1 && url.substring(1).startsWith(':\\'));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Note: Don't dispose controllers here as they're managed by ChecklistItem
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _delete() {
|
||||
if (widget.isClosed) return const SizedBox();
|
||||
return Container(
|
||||
height: 45.toScreenHeight,
|
||||
width: 45.toScreenHeight,
|
||||
margin: const EdgeInsets.only(top: 4, right: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: (context.isDark ? AppColor.neutral20 : AppColor.neutral40).withValues(alpha: 0.4),
|
||||
border: Border.all(
|
||||
color: (context.isDark ? AppColor.neutral20 : AppColor.neutral40).withValues(alpha: 0.7),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
//boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10)],
|
||||
),
|
||||
child: const Icon(Icons.delete_rounded, color: Color(0xffF63939)))
|
||||
.onPress(() {
|
||||
widget.onImageCaptured?.call(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Model class for checklist items
|
||||
class ChecklistItem {
|
||||
final int id;
|
||||
final String title;
|
||||
bool isChecked;
|
||||
String comment;
|
||||
final bool isMandatory;
|
||||
final TextEditingController commentController;
|
||||
|
||||
ChecklistItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.isChecked,
|
||||
required this.comment,
|
||||
this.isMandatory = false,
|
||||
}) : commentController = TextEditingController(text: comment) {
|
||||
// Update the controller text whenever comment changes
|
||||
commentController.text = comment;
|
||||
}
|
||||
|
||||
// Convert to JSON for API submission
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'isChecked': isChecked,
|
||||
'comment': commentController.text,
|
||||
'isMandatory': isMandatory,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
commentController.dispose();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue